From 2962dd5e9ac5e48aee76156dc15c4c798793f125 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:16:32 +0000 Subject: [PATCH 1/7] refactor: move to single key with on-behalf account context --- README.md | 2 +- SOUL.md | 4 +- cmd/account.go | 2 +- cmd/account_approve_agent.go | 21 ++- cmd/account_class_transfer.go | 22 ++- cmd/account_helpers.go | 32 +--- cmd/account_send_asset.go | 9 +- cmd/account_set_abstraction.go | 9 +- cmd/account_test.go | 21 ++- cmd/account_transfer.go | 22 ++- cmd/account_withdraw.go | 9 +- cmd/agent_bracket.go | 12 +- cmd/agent_pnl.go | 2 +- cmd/agent_snapshot.go | 2 +- cmd/config.go | 83 ++++----- cmd/config_test.go | 34 ++-- cmd/info_test.go | 7 +- cmd/info_user.go | 12 +- cmd/order.go | 4 +- cmd/order_batch.go | 12 +- cmd/order_cancel.go | 40 +++-- cmd/order_market.go | 12 +- cmd/order_modify.go | 30 ++-- cmd/order_place.go | 18 +- cmd/order_schedule_cancel.go | 16 +- cmd/order_test.go | 17 +- cmd/position.go | 4 +- cmd/position_leverage.go | 26 +-- cmd/position_margin.go | 26 +-- cmd/position_test.go | 8 +- cmd/root_test.go | 6 +- e2e/agent_simulation_test.go | 10 +- e2e/e2e_test.go | 4 +- e2e/integration_cli_test.go | 37 ++-- pkg/config/config.go | 10 +- pkg/config/config_test.go | 22 +-- pkg/config/context_test.go | 6 +- pkg/exchange/executor.go | 163 ++++++++++-------- pkg/exchange/types.go | 2 +- pkg/info/address.go | 8 +- pkg/info/address_test.go | 10 +- plans/hlgo-technical-spec-v0.1.0.md | 12 +- plans/issues/github-issues-seed.md | 14 +- skill/hlgo/SKILL.md | 12 +- skill/hlgo/references/command-reference.md | 20 +-- skill/hlgo/references/contracts-and-safety.md | 4 +- 46 files changed, 456 insertions(+), 402 deletions(-) diff --git a/README.md b/README.md index 5b3d8c5..88eea9a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Or download a prebuilt binary from [Releases](https://github.com/timbrinded/hlgo hlgo config init # 2. Export your agent private key -export HL_AGENT_KEY=0xYOUR_PRIVATE_KEY +export HL_PRIVATE_KEY=0xYOUR_PRIVATE_KEY # 3. Verify connectivity hlgo config test --testnet diff --git a/SOUL.md b/SOUL.md index bb54739..9e80757 100644 --- a/SOUL.md +++ b/SOUL.md @@ -44,10 +44,10 @@ No daemons. No state between invocations. No hidden side effects. Trading tools handle private keys. The architecture must make misuse structurally difficult. -- **Dual wallet isolation:** Agent wallet (limited to L1 trading) and master wallet (transfers, withdrawals) are separate config entries with separate signing paths. Commands auto-select the correct signer. +- **Single key model with explicit account context:** One configured private key is used for signing. L1 actions may optionally include an on-behalf account context when operating for another authorized account. - **No key material in output.** Ever. Not in logs, not in errors, not in dry-run output. - **Testnet-first.** `--testnet` flag and `HL_TESTNET` env var. Default is mainnet, but development and testing always happen on testnet. -- **Dangerous operations are gated.** Master wallet actions require explicit confirmation or `--dry-run` to preview. +- **Dangerous operations are gated.** Withdrawal/send/revoke actions require explicit confirmation or `--dry-run` to preview. ## Built from Scratch diff --git a/cmd/account.go b/cmd/account.go index 88bb7b5..fd26227 100644 --- a/cmd/account.go +++ b/cmd/account.go @@ -8,7 +8,7 @@ func newAccountCmd() *cobra.Command { Short: "Account transfers, withdrawals, and agent management", Long: `Transfer USDC between spot and perp, withdraw to Arbitrum, manage agent wallet approvals, and perform cross-account transfers. Account commands -sign with the master wallet via the user-signed path (chain ID 421614).`, +sign with the configured private key via the user-signed path (chain ID 421614).`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, diff --git a/cmd/account_approve_agent.go b/cmd/account_approve_agent.go index 3425db5..3963596 100644 --- a/cmd/account_approve_agent.go +++ b/cmd/account_approve_agent.go @@ -15,21 +15,26 @@ func newAccountApproveAgentCmd() *cobra.Command { cmd := &cobra.Command{ Use: "approve-agent", Short: "Approve or revoke an agent wallet", - Long: `Approve an agent address to trade on behalf of the master wallet. + Long: `Approve an agent address to trade on behalf of your account. Note: Hyperliquid may charge an activation fee when first approving an agent.`, RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - agent, _ := cmd.Flags().GetString("agent") //nolint:errcheck // known flag - name, _ := cmd.Flags().GetString("name") //nolint:errcheck // known flag - revoke, _ := cmd.Flags().GetBool("revoke") //nolint:errcheck // known flag - confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag - yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag + agent, _ := cmd.Flags().GetString("agent") //nolint:errcheck // known flag + name, _ := cmd.Flags().GetString("name") //nolint:errcheck // known flag + revoke, _ := cmd.Flags().GetBool("revoke") //nolint:errcheck // known flag + confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag + yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if !common.IsHexAddress(agent) { return output.NewCLIError(output.ErrValidation, "invalid agent address"). WithDetails("agent", agent) } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } agentName := strings.TrimSpace(name) switch { @@ -47,7 +52,7 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, WithDetails("max_length", 16) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } @@ -55,6 +60,7 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, raw, err := exec.ApproveAgent(cmd.Context(), exchange.ApproveAgentInput{ AgentAddress: strings.ToLower(agent), AgentName: agentName, + OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -67,6 +73,7 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, cmd.Flags().String("agent", "", "agent wallet address") cmd.Flags().String("name", "", "optional agent label") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().Bool("revoke", false, "revoke agent by setting an empty label") cmd.Flags().Bool("confirm", false, "confirm execution for revoke flow") cmd.Flags().Bool("yes", false, "alias for --confirm") diff --git a/cmd/account_class_transfer.go b/cmd/account_class_transfer.go index 1f4cfd4..4bf6d9e 100644 --- a/cmd/account_class_transfer.go +++ b/cmd/account_class_transfer.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/spf13/cobra" @@ -15,9 +16,10 @@ func newAccountClassTransferCmd() *cobra.Command { Short: "Alias of transfer using usdClassTransfer semantics", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag - toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag + amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag + toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag + toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if toPerp == toSpot { return output.NewCLIError(output.ErrValidation, "exactly one of --to-perp or --to-spot is required") @@ -28,16 +30,21 @@ func newAccountClassTransferCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "invalid amount"). WithDetails("value", amountStr) } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } raw, err := exec.USDClassTransfer(cmd.Context(), exchange.USDClassTransferInput{ - Amount: amount, - ToPerp: toPerp, - DryRun: cfg.DryRun, + Amount: amount, + ToPerp: toPerp, + OnBehalfOf: onBehalfOf, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -50,6 +57,7 @@ func newAccountClassTransferCmd() *cobra.Command { cmd.Flags().String("amount", "", "transfer amount") cmd.Flags().Bool("to-perp", false, "transfer toward perp class") cmd.Flags().Bool("to-spot", false, "transfer toward spot class") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") //nolint:errcheck // MarkFlagRequired on known flags never fails cmd.MarkFlagRequired("amount") diff --git a/cmd/account_helpers.go b/cmd/account_helpers.go index 82de1b3..defbf77 100644 --- a/cmd/account_helpers.go +++ b/cmd/account_helpers.go @@ -1,36 +1,6 @@ package cmd -import ( - "os" - "time" - - "github.com/timbrinded/hlgo/pkg/client" - "github.com/timbrinded/hlgo/pkg/config" - "github.com/timbrinded/hlgo/pkg/exchange" - "github.com/timbrinded/hlgo/pkg/output" - "github.com/timbrinded/hlgo/pkg/resolver" - "github.com/timbrinded/hlgo/pkg/signer" -) - -// buildMasterExecutor constructs an exchange.Executor signed by the master wallet. -func buildMasterExecutor(cfg *config.Config) (*exchange.Executor, error) { - keyHex := os.Getenv(cfg.MasterKeyEnv) - if keyHex == "" { - return nil, output.NewCLIError(output.ErrConfig, "master key not set"). - WithDetails("env_var", cfg.MasterKeyEnv) - } - - s, err := signer.NewSigner(keyHex) - if err != nil { - return nil, err - } - - c := client.NewClient(baseURL(cfg)) - cacheDir := resolveCacheDir(cfg) - r := resolver.NewResolver(c, cacheDir, time.Duration(cfg.MetadataTTL)*time.Second) - - return exchange.NewExecutor(s, c, r, !cfg.Testnet), nil -} +import "github.com/timbrinded/hlgo/pkg/output" func requireConfirm(cmdName string, confirmed bool, dryRun bool) error { if confirmed || dryRun { diff --git a/cmd/account_send_asset.go b/cmd/account_send_asset.go index f1b16ab..707188b 100644 --- a/cmd/account_send_asset.go +++ b/cmd/account_send_asset.go @@ -23,6 +23,7 @@ func newAccountSendAssetCmd() *cobra.Command { amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if err := requireConfirm("send-asset", confirm || yes, cfg.DryRun); err != nil { return err @@ -32,6 +33,10 @@ func newAccountSendAssetCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "invalid destination address"). WithDetails("destination", destination) } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } if strings.TrimSpace(token) == "" { return output.NewCLIError(output.ErrValidation, "token is required") } @@ -42,7 +47,7 @@ func newAccountSendAssetCmd() *cobra.Command { WithDetails("value", amountStr) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } @@ -51,6 +56,7 @@ func newAccountSendAssetCmd() *cobra.Command { Destination: strings.ToLower(destination), Token: token, Amount: amount, + OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -64,6 +70,7 @@ func newAccountSendAssetCmd() *cobra.Command { cmd.Flags().String("destination", "", "destination EVM address") cmd.Flags().String("token", "", "spot token identifier (e.g. PURR:0x1)") cmd.Flags().String("amount", "", "token amount") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().Bool("confirm", false, "confirm execution for asset send") cmd.Flags().Bool("yes", false, "alias for --confirm") diff --git a/cmd/account_set_abstraction.go b/cmd/account_set_abstraction.go index 6e1842d..cd87dc3 100644 --- a/cmd/account_set_abstraction.go +++ b/cmd/account_set_abstraction.go @@ -25,11 +25,16 @@ func newAccountSetAbstractionCmd() *cobra.Command { cfg := config.FromContext(cmd.Context()) user, _ := cmd.Flags().GetString("user") //nolint:errcheck // known flag abstraction, _ := cmd.Flags().GetString("abstraction") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if !common.IsHexAddress(user) { return output.NewCLIError(output.ErrValidation, "invalid user address"). WithDetails("user", user) } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } abstraction = strings.TrimSpace(abstraction) if abstraction == "" { return output.NewCLIError(output.ErrValidation, "abstraction is required") @@ -40,7 +45,7 @@ func newAccountSetAbstractionCmd() *cobra.Command { WithDetails("allowed", []string{"unifiedAccount", "portfolioMargin", "disabled"}) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } @@ -48,6 +53,7 @@ func newAccountSetAbstractionCmd() *cobra.Command { raw, err := exec.UserSetAbstraction(cmd.Context(), exchange.UserSetAbstractionInput{ User: strings.ToLower(user), Abstraction: abstraction, + OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -60,6 +66,7 @@ func newAccountSetAbstractionCmd() *cobra.Command { cmd.Flags().String("user", "", "user address") cmd.Flags().String("abstraction", "", "abstraction string") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") for _, required := range []string{"user", "abstraction"} { //nolint:errcheck // MarkFlagRequired on known flags never fails diff --git a/cmd/account_test.go b/cmd/account_test.go index e0bf5f0..91842e9 100644 --- a/cmd/account_test.go +++ b/cmd/account_test.go @@ -70,6 +70,20 @@ func TestAccountTransfer_InvalidDirection(t *testing.T) { } } +func TestAccountTransfer_OnBehalfOfUnsupported(t *testing.T) { + _, _, run := newTestRootWithServer(t, "") + + err := run("account", "transfer", + "--amount", "1", + "--to-perp", + "--on-behalf-of", "0x1111111111111111111111111111111111111111", + "--dry-run", + ) + if err == nil { + t.Fatal("expected validation error when on-behalf-of is used for user-signed action") + } +} + func TestAccountWithdraw_RequiresConfirm(t *testing.T) { _, _, run := newTestRootWithServer(t, "") @@ -243,13 +257,12 @@ func TestAccountApproveAgent_NameTooLong(t *testing.T) { } } -func TestAccountMissingMasterKey_ConfigError(t *testing.T) { +func TestAccountMissingPrivateKey_ConfigError(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - if err := os.WriteFile(cfgPath, []byte("agent_key_env: TEST_HL_KEY\nmaster_key_env: NOT_SET_MASTER\n"), 0600); err != nil { + if err := os.WriteFile(cfgPath, []byte("private_key_env: NOT_SET_PRIVATE\n"), 0600); err != nil { t.Fatal(err) } - t.Setenv("TEST_HL_KEY", "0x0123456789012345678901234567890123456789012345678901234567890123") root := NewRootCommand(BuildInfo{Version: "test"}) root.SetOut(new(bytes.Buffer)) @@ -264,7 +277,7 @@ func TestAccountMissingMasterKey_ConfigError(t *testing.T) { err := root.Execute() if err == nil { - t.Fatal("expected config error when master key env var is missing") + t.Fatal("expected config error when private key env var is missing") } var cliErr *output.CLIError diff --git a/cmd/account_transfer.go b/cmd/account_transfer.go index 6fb27c2..6842439 100644 --- a/cmd/account_transfer.go +++ b/cmd/account_transfer.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/spf13/cobra" @@ -15,9 +16,10 @@ func newAccountTransferCmd() *cobra.Command { Short: "Transfer USDC between spot and perp classes", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag - toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag + amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag + toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag + toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if toPerp == toSpot { return output.NewCLIError(output.ErrValidation, "exactly one of --to-perp or --to-spot is required") @@ -28,16 +30,21 @@ func newAccountTransferCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "invalid amount"). WithDetails("value", amountStr) } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } raw, err := exec.USDClassTransfer(cmd.Context(), exchange.USDClassTransferInput{ - Amount: amount, - ToPerp: toPerp, - DryRun: cfg.DryRun, + Amount: amount, + ToPerp: toPerp, + OnBehalfOf: onBehalfOf, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -50,6 +57,7 @@ func newAccountTransferCmd() *cobra.Command { cmd.Flags().String("amount", "", "USDC amount") cmd.Flags().Bool("to-perp", false, "transfer from spot to perp") cmd.Flags().Bool("to-spot", false, "transfer from perp to spot") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") //nolint:errcheck // MarkFlagRequired on known flags never fails cmd.MarkFlagRequired("amount") diff --git a/cmd/account_withdraw.go b/cmd/account_withdraw.go index 6089e74..f803399 100644 --- a/cmd/account_withdraw.go +++ b/cmd/account_withdraw.go @@ -22,6 +22,7 @@ func newAccountWithdrawCmd() *cobra.Command { amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if err := requireConfirm("withdraw", confirm || yes, cfg.DryRun); err != nil { return err @@ -31,6 +32,10 @@ func newAccountWithdrawCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "invalid destination address"). WithDetails("destination", destination) } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } amount, err := decimal.NewFromString(amountStr) if err != nil { @@ -38,7 +43,7 @@ func newAccountWithdrawCmd() *cobra.Command { WithDetails("value", amountStr) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } @@ -46,6 +51,7 @@ func newAccountWithdrawCmd() *cobra.Command { raw, err := exec.Withdraw3(cmd.Context(), exchange.Withdraw3Input{ Destination: strings.ToLower(destination), Amount: amount, + OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -58,6 +64,7 @@ func newAccountWithdrawCmd() *cobra.Command { cmd.Flags().String("destination", "", "destination EVM address") cmd.Flags().String("amount", "", "USDC amount") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().Bool("confirm", false, "confirm execution for withdrawal") cmd.Flags().Bool("yes", false, "alias for --confirm") diff --git a/cmd/agent_bracket.go b/cmd/agent_bracket.go index 3c3e589..4cd4413 100644 --- a/cmd/agent_bracket.go +++ b/cmd/agent_bracket.go @@ -26,7 +26,7 @@ func newAgentBracketCmd() *cobra.Command { slStr, _ := cmd.Flags().GetString("sl") //nolint:errcheck // known flag tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag @@ -94,9 +94,9 @@ func newAgentBracketCmd() *cobra.Command { cloid = &cloidStr } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) } changedBuilder := cmd.Flags().Changed("builder") @@ -151,7 +151,7 @@ func newAgentBracketCmd() *cobra.Command { SlTrigger: &slTrigger, Builder: builder, ExpiresAfter: expiresAfter, - VaultAddr: vault, + OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -170,7 +170,7 @@ func newAgentBracketCmd() *cobra.Command { cmd.Flags().String("sl", "", "stop-loss trigger price") cmd.Flags().String("tif", "gtc", "time in force: gtc, ioc, alo") cmd.Flags().String("cloid", "", "client order ID") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("builder", "", "builder address for optional builder fee routing") cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") diff --git a/cmd/agent_pnl.go b/cmd/agent_pnl.go index 2462e02..f4e4d95 100644 --- a/cmd/agent_pnl.go +++ b/cmd/agent_pnl.go @@ -232,7 +232,7 @@ func newAgentPnlCmd() *cobra.Command { }, } - cmd.Flags().String("address", "", "user address (default: derived from agent wallet)") + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") cmd.Flags().Int("lookback-hours", 24, "lookback window in hours for realized/funding attribution") cmd.Flags().Bool("aggregate-fills", false, "request aggregateByTime for user fills endpoint") return cmd diff --git a/cmd/agent_snapshot.go b/cmd/agent_snapshot.go index 508d92e..a98840b 100644 --- a/cmd/agent_snapshot.go +++ b/cmd/agent_snapshot.go @@ -194,7 +194,7 @@ func newAgentSnapshotCmd() *cobra.Command { }, } - cmd.Flags().String("address", "", "user address (default: derived from agent wallet)") + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") return cmd } diff --git a/cmd/config.go b/cmd/config.go index 70514e9..3b840f2 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -21,7 +21,7 @@ func newConfigCmd() *cobra.Command { Short: "Configure hlgo settings and credentials", Annotations: map[string]string{"skipConfig": "true"}, Long: `Initialize configuration, display resolved settings (with key redaction), -and test wallet connectivity and agent approval status. Configuration is +and test wallet connectivity. Configuration is stored at ~/.hlgo/config.yaml by default.`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() @@ -40,10 +40,9 @@ stored at ~/.hlgo/config.yaml by default.`, // configFileData is the structure written to the YAML config file. // Fields mirror the persisted fields in config.Config — keep in sync. type configFileData struct { - AgentKeyEnv string `yaml:"agent_key_env"` - MasterKeyEnv string `yaml:"master_key_env"` - DefaultDex string `yaml:"default_dex"` - MetadataTTL int `yaml:"metadata_ttl"` + PrivateKeyEnv string `yaml:"private_key_env"` + DefaultDex string `yaml:"default_dex"` + MetadataTTL int `yaml:"metadata_ttl"` } // resolveConfigPath expands the default sentinel path to an absolute path. @@ -60,11 +59,10 @@ func resolveConfigPath(flagValue string) (string, error) { func newConfigInitCmd() *cobra.Command { var ( - agentKeyEnv string - masterKeyEnv string - defaultDex string - metadataTTL int - force bool + privateKeyEnv string + defaultDex string + metadataTTL int + force bool ) cmd := &cobra.Command{ @@ -87,10 +85,9 @@ an existing config unless --force is passed.`, } data := configFileData{ - AgentKeyEnv: agentKeyEnv, - MasterKeyEnv: masterKeyEnv, - DefaultDex: defaultDex, - MetadataTTL: metadataTTL, + PrivateKeyEnv: privateKeyEnv, + DefaultDex: defaultDex, + MetadataTTL: metadataTTL, } out, err := yaml.Marshal(data) @@ -106,11 +103,9 @@ an existing config unless --force is passed.`, return fmt.Errorf("write config file: %w", err) } - for _, envName := range []string{agentKeyEnv, masterKeyEnv} { - if envName != "" && os.Getenv(envName) == "" { - if _, werr := fmt.Fprintf(cmd.ErrOrStderr(), "warning: environment variable %s is not set\n", envName); werr != nil { - return werr - } + if privateKeyEnv != "" && os.Getenv(privateKeyEnv) == "" { + if _, werr := fmt.Fprintf(cmd.ErrOrStderr(), "warning: environment variable %s is not set\n", privateKeyEnv); werr != nil { + return werr } } @@ -123,8 +118,7 @@ an existing config unless --force is passed.`, }, } - cmd.Flags().StringVar(&agentKeyEnv, "agent-key-env", "HL_AGENT_KEY", "env var name for agent private key") - cmd.Flags().StringVar(&masterKeyEnv, "master-key-env", "HL_MASTER_KEY", "env var name for master private key") + cmd.Flags().StringVar(&privateKeyEnv, "private-key-env", "HL_PRIVATE_KEY", "env var name for private key") cmd.Flags().StringVar(&defaultDex, "default-dex", "", "default HIP-3 dex name") cmd.Flags().IntVar(&metadataTTL, "metadata-ttl", 300, "metadata cache TTL in seconds") cmd.Flags().BoolVar(&force, "force", false, "overwrite existing config file") @@ -144,27 +138,21 @@ func newConfigShowCmd() *cobra.Command { return err } - agentKeyVal := os.Getenv(cfg.AgentKeyEnv) - masterKeyVal := os.Getenv(cfg.MasterKeyEnv) + privateKeyVal := os.Getenv(cfg.PrivateKeyEnv) result := map[string]any{ - "config_file": v.ConfigFileUsed(), - "agent_key_env": cfg.AgentKeyEnv, - "agent_key_set": agentKeyVal != "", - "master_key_env": cfg.MasterKeyEnv, - "master_key_set": masterKeyVal != "", - "testnet": cfg.Testnet, - "format": cfg.Format, - "dex": cfg.Dex, - "default_dex": cfg.DefaultDex, - "metadata_ttl": cfg.MetadataTTL, + "config_file": v.ConfigFileUsed(), + "private_key_env": cfg.PrivateKeyEnv, + "private_key_set": privateKeyVal != "", + "testnet": cfg.Testnet, + "format": cfg.Format, + "dex": cfg.Dex, + "default_dex": cfg.DefaultDex, + "metadata_ttl": cfg.MetadataTTL, } - if agentKeyVal != "" { - result["agent_key_preview"] = config.RedactKey(agentKeyVal) - } - if masterKeyVal != "" { - result["master_key_preview"] = config.RedactKey(masterKeyVal) + if privateKeyVal != "" { + result["private_key_preview"] = config.RedactKey(privateKeyVal) } out, err := json.MarshalIndent(result, "", " ") @@ -188,10 +176,9 @@ func newConfigTestCmd() *cobra.Command { configFile := v.ConfigFileUsed() result := map[string]any{ - "config_file": configFile, - "config_readable": configFile != "" && err == nil, - "agent_key_env_set": false, - "master_key_env_set": false, + "config_file": configFile, + "config_readable": configFile != "" && err == nil, + "private_key_env_set": false, } if err != nil { @@ -208,10 +195,8 @@ func newConfigTestCmd() *cobra.Command { return fmt.Errorf("config not readable: %w", err) } - agentKeySet := cfg.AgentKeyEnv != "" && os.Getenv(cfg.AgentKeyEnv) != "" - masterKeySet := cfg.MasterKeyEnv != "" && os.Getenv(cfg.MasterKeyEnv) != "" - result["agent_key_env_set"] = agentKeySet - result["master_key_env_set"] = masterKeySet + privateKeySet := cfg.PrivateKeyEnv != "" && os.Getenv(cfg.PrivateKeyEnv) != "" + result["private_key_env_set"] = privateKeySet // Test connectivity by fetching mid prices. ic := buildInfoClient(cfg) @@ -247,11 +232,11 @@ func newConfigTestCmd() *cobra.Command { return err } - // Return an error for missing agent key so scripts can check the exit + // Return an error for missing private key so scripts can check the exit // code. JSON is already written to stdout; SilenceErrors prevents // Cobra from writing the error to stderr. - if !agentKeySet { - return fmt.Errorf("agent key env var %q is not set", cfg.AgentKeyEnv) + if !privateKeySet { + return fmt.Errorf("private key env var %q is not set", cfg.PrivateKeyEnv) } return nil }, diff --git a/cmd/config_test.go b/cmd/config_test.go index 3e8cf73..fe06a5e 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -31,8 +31,8 @@ func TestConfigInit_CreatesFile(t *testing.T) { } content := string(data) - if !strings.Contains(content, "agent_key_env") { - t.Error("config file missing agent_key_env") + if !strings.Contains(content, "private_key_env") { + t.Error("config file missing private_key_env") } var out map[string]string @@ -88,8 +88,7 @@ func TestConfigInit_CustomValues(t *testing.T) { root.SetArgs([]string{ "config", "init", "--config", cfgPath, - "--agent-key-env", "CUSTOM_AGENT", - "--master-key-env", "CUSTOM_MASTER", + "--private-key-env", "CUSTOM_KEY", "--default-dex", "xyz", "--metadata-ttl", "60", }) @@ -100,11 +99,8 @@ func TestConfigInit_CustomValues(t *testing.T) { data, _ := os.ReadFile(cfgPath) content := string(data) - if !strings.Contains(content, "CUSTOM_AGENT") { - t.Error("custom agent key env not written") - } - if !strings.Contains(content, "CUSTOM_MASTER") { - t.Error("custom master key env not written") + if !strings.Contains(content, "CUSTOM_KEY") { + t.Error("custom private key env not written") } } @@ -128,7 +124,7 @@ func TestConfigInit_WarnsOnMissingEnv(t *testing.T) { func TestConfigShow_Output(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - os.WriteFile(cfgPath, []byte("agent_key_env: TEST_KEY\nmetadata_ttl: 120\n"), 0600) + os.WriteFile(cfgPath, []byte("private_key_env: TEST_KEY\nmetadata_ttl: 120\n"), 0600) t.Setenv("TEST_KEY", "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") root := NewRootCommand(BuildInfo{Version: "test"}) @@ -145,11 +141,11 @@ func TestConfigShow_Output(t *testing.T) { t.Fatalf("stdout not valid JSON: %v\nraw: %s", err, buf.String()) } - if out["agent_key_set"] != true { - t.Error("agent_key_set should be true") + if out["private_key_set"] != true { + t.Error("private_key_set should be true") } - preview, _ := out["agent_key_preview"].(string) + preview, _ := out["private_key_preview"].(string) if !strings.Contains(preview, "...") { t.Errorf("expected redacted preview, got %q", preview) } @@ -169,7 +165,7 @@ func TestConfigTest_AllGood(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - os.WriteFile(cfgPath, []byte("agent_key_env: TEST_KEY\n"), 0600) + os.WriteFile(cfgPath, []byte("private_key_env: TEST_KEY\n"), 0600) t.Setenv("TEST_KEY", "0xdeadbeef") root := NewRootCommand(BuildInfo{Version: "test"}) @@ -188,8 +184,8 @@ func TestConfigTest_AllGood(t *testing.T) { if out["config_readable"] != true { t.Error("config_readable should be true") } - if out["agent_key_env_set"] != true { - t.Error("agent_key_env_set should be true") + if out["private_key_env_set"] != true { + t.Error("private_key_env_set should be true") } conn, ok := out["connectivity"].(map[string]any) @@ -213,7 +209,7 @@ func TestConfigTest_NoFileReportsNotReadable(t *testing.T) { root.SetOut(buf) root.SetArgs([]string{"config", "test"}) - // Will error because agent key env defaults to HL_AGENT_KEY which isn't set, + // Will error because private key env defaults to HL_PRIVATE_KEY which isn't set, // but we care about config_readable in the JSON output. root.Execute() @@ -235,7 +231,7 @@ func TestConfigTest_MissingEnvVar(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - os.WriteFile(cfgPath, []byte("agent_key_env: DEFINITELY_NOT_SET_XYZ\n"), 0600) + os.WriteFile(cfgPath, []byte("private_key_env: DEFINITELY_NOT_SET_XYZ\n"), 0600) root := NewRootCommand(BuildInfo{Version: "test"}) buf := new(bytes.Buffer) @@ -244,6 +240,6 @@ func TestConfigTest_MissingEnvVar(t *testing.T) { err := root.Execute() if err == nil { - t.Fatal("expected error when agent key env var is not set") + t.Fatal("expected error when private key env var is not set") } } diff --git a/cmd/info_test.go b/cmd/info_test.go index 9507a0a..b4ef862 100644 --- a/cmd/info_test.go +++ b/cmd/info_test.go @@ -21,14 +21,14 @@ const testMetaJSON = `{"universe":[{"name":"BTC","szDecimals":3},{"name":"ETH"," const testSpotMetaJSON = `{"tokens":[{"name":"USDC","szDecimals":6,"index":0},{"name":"PURR","szDecimals":2,"index":1}],"universe":[{"name":"PURR/USDC","index":0,"tokens":[1,0]}]}` // newTestRootWithServer creates a root command configured to use the given test server URL. -// It writes a temporary config file pointing agent_key_env at a set env var, +// It writes a temporary config file pointing private_key_env at a set env var, // and pre-populates the resolver cache so order commands don't need a live API. func newTestRootWithServer(t *testing.T, _ string) (*bytes.Buffer, *bytes.Buffer, func(args ...string) error) { t.Helper() dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - os.WriteFile(cfgPath, []byte("agent_key_env: TEST_HL_KEY\nmaster_key_env: TEST_HL_MASTER_KEY\nmetadata_ttl: 300\n"), 0600) + os.WriteFile(cfgPath, []byte("private_key_env: TEST_HL_PRIVATE_KEY\nmetadata_ttl: 300\n"), 0600) // Set HOME so resolveCacheDir uses our temp dir. t.Setenv("HOME", dir) @@ -46,8 +46,7 @@ func newTestRootWithServer(t *testing.T, _ string) (*bytes.Buffer, *bytes.Buffer } // Set a well-known test key for address resolution. - t.Setenv("TEST_HL_KEY", "0x0123456789012345678901234567890123456789012345678901234567890123") - t.Setenv("TEST_HL_MASTER_KEY", "0x0123456789012345678901234567890123456789012345678901234567890123") + t.Setenv("TEST_HL_PRIVATE_KEY", "0x0123456789012345678901234567890123456789012345678901234567890123") stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) diff --git a/cmd/info_user.go b/cmd/info_user.go index c8f8aee..f035c98 100644 --- a/cmd/info_user.go +++ b/cmd/info_user.go @@ -42,7 +42,7 @@ func newInfoStateCmd() *cobra.Command { return printResult(cmd, cfg, raw, result) }, } - cmd.Flags().String("address", "", "user address (default: derived from agent wallet)") + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") cmd.Flags().String("dex", "", "HIP-3 perp dex name") return cmd } @@ -74,7 +74,7 @@ func newInfoSpotStateCmd() *cobra.Command { return printResult(cmd, cfg, raw, nil) }, } - cmd.Flags().String("address", "", "user address (default: derived from agent wallet)") + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") return cmd } @@ -111,7 +111,7 @@ func newInfoOpenOrdersCmd() *cobra.Command { return printResult(cmd, cfg, raw, result) }, } - cmd.Flags().String("address", "", "user address (default: derived from agent wallet)") + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") cmd.Flags().String("dex", "", "HIP-3 perp dex name") return cmd } @@ -193,7 +193,7 @@ func newInfoFillsCmd() *cobra.Command { return printResult(cmd, cfg, raw, result) }, } - cmd.Flags().String("address", "", "user address (default: derived from agent wallet)") + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") cmd.Flags().String("start", "", "start time (Unix ms or ISO 8601)") cmd.Flags().String("end", "", "end time (Unix ms or ISO 8601)") cmd.Flags().Bool("aggregate-by-time", false, "aggregate partial fills by timestamp") @@ -236,7 +236,7 @@ func newInfoOrderStatusCmd() *cobra.Command { return printResult(cmd, cfg, raw, nil) }, } - cmd.Flags().String("address", "", "user address (default: derived from agent wallet)") + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") return cmd } @@ -267,6 +267,6 @@ func newInfoRateLimitCmd() *cobra.Command { return printResult(cmd, cfg, raw, nil) }, } - cmd.Flags().String("address", "", "user address (default: derived from agent wallet)") + cmd.Flags().String("address", "", "user address (default: derived from configured private key)") return cmd } diff --git a/cmd/order.go b/cmd/order.go index 5e5223b..00a2dbe 100644 --- a/cmd/order.go +++ b/cmd/order.go @@ -7,8 +7,8 @@ func newOrderCmd() *cobra.Command { Use: "order", Short: "Place, cancel, and manage orders", Long: `Submit limit and market orders, cancel by OID or CLOID, modify existing -orders, and batch-place from file. All order commands sign with the agent -wallet via the L1 phantom agent path.`, +orders, and batch-place from file. All order commands sign with the configured +private key via the L1 phantom agent path.`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, diff --git a/cmd/order_batch.go b/cmd/order_batch.go index 8a9dfdc..7e4d8a1 100644 --- a/cmd/order_batch.go +++ b/cmd/order_batch.go @@ -35,7 +35,7 @@ func newOrderBatchCmd() *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) filePath, _ := cmd.Flags().GetString("file") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag @@ -59,9 +59,9 @@ func newOrderBatchCmd() *cobra.Command { WithDetails("path", filePath) } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) } changedBuilder := cmd.Flags().Changed("builder") @@ -169,7 +169,7 @@ func newOrderBatchCmd() *cobra.Command { return err } - result, err := exec.PlaceBatchOrders(cmd.Context(), action, vault, expiresAfter) + result, err := exec.PlaceBatchOrders(cmd.Context(), action, onBehalfOf, expiresAfter) if err != nil { return err } @@ -179,7 +179,7 @@ func newOrderBatchCmd() *cobra.Command { } cmd.Flags().String("file", "", "path to JSON batch file") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("builder", "", "builder address for optional builder fee routing") cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") diff --git a/cmd/order_cancel.go b/cmd/order_cancel.go index 7c20a75..ab1b361 100644 --- a/cmd/order_cancel.go +++ b/cmd/order_cancel.go @@ -23,7 +23,7 @@ func newOrderCancelCmd() *cobra.Command { coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag oidStr, _ := cmd.Flags().GetString("oid") //nolint:errcheck // known flag cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag // Mutual exclusion: exactly one of --oid or --cloid. @@ -39,9 +39,9 @@ func newOrderCancelCmd() *cobra.Command { return err } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) } var expiresAfter *int64 @@ -65,7 +65,7 @@ func newOrderCancelCmd() *cobra.Command { result, err := exec.CancelByCloid(cmd.Context(), []exchange.CancelByCloidWire{ {Asset: assetID, Cloid: cloidStr}, - }, vault, cfg.DryRun, expiresAfter) + }, onBehalfOf, cfg.DryRun, expiresAfter) if err != nil { return err } @@ -86,7 +86,7 @@ func newOrderCancelCmd() *cobra.Command { result, err := exec.CancelOrders(cmd.Context(), []exchange.CancelWire{ {A: assetID, O: oid}, - }, vault, cfg.DryRun, expiresAfter) + }, onBehalfOf, cfg.DryRun, expiresAfter) if err != nil { return err } @@ -97,7 +97,7 @@ func newOrderCancelCmd() *cobra.Command { cmd.Flags().String("coin", "", "coin name (required for asset ID resolution)") cmd.Flags().String("oid", "", "order ID to cancel") cmd.Flags().String("cloid", "", "client order ID to cancel") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") //nolint:errcheck // MarkFlagRequired on known flags never fails @@ -113,13 +113,22 @@ func newOrderCancelAllCmd() *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } + // Resolve user address for fetching open orders. - addr, err := info.ResolveUserAddress("", cfg) - if err != nil { - return err + addr := onBehalfOf + if addr == "" { + var err error + addr, err = info.ResolveUserAddress("", cfg) + if err != nil { + return err + } } // Fetch all open orders. @@ -145,11 +154,6 @@ func newOrderCancelAllCmd() *cobra.Command { return err } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) - } - var expiresAfter *int64 if expiresAfterStr != "" { ms, err := parseTimeFlag(expiresAfterStr) @@ -186,7 +190,7 @@ func newOrderCancelAllCmd() *cobra.Command { }), nil) } - result, err := exec.CancelOrders(cmd.Context(), cancels, vault, cfg.DryRun, expiresAfter) + result, err := exec.CancelOrders(cmd.Context(), cancels, onBehalfOf, cfg.DryRun, expiresAfter) if err != nil { return err } @@ -195,7 +199,7 @@ func newOrderCancelAllCmd() *cobra.Command { } cmd.Flags().String("coin", "", "only cancel orders for this coin") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") return cmd diff --git a/cmd/order_market.go b/cmd/order_market.go index 3dc2f85..11b7087 100644 --- a/cmd/order_market.go +++ b/cmd/order_market.go @@ -24,7 +24,7 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag slippageStr, _ := cmd.Flags().GetString("slippage") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag @@ -35,9 +35,9 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri WithDetails("value", side) } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) } changedBuilder := cmd.Flags().Changed("builder") @@ -106,7 +106,7 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri SlippagePercent: slippageDecimal, Builder: builder, ExpiresAfter: expiresAfter, - VaultAddr: vault, + OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -121,7 +121,7 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri cmd.Flags().String("side", "", "buy or sell") cmd.Flags().String("size", "", "order size") cmd.Flags().String("slippage", "0.5", "slippage percentage (default 0.5%)") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("builder", "", "builder address for optional builder fee routing") cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") diff --git a/cmd/order_modify.go b/cmd/order_modify.go index 7454999..0181d5b 100644 --- a/cmd/order_modify.go +++ b/cmd/order_modify.go @@ -27,7 +27,7 @@ func newOrderModifyCmd() *cobra.Command { sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag reduce, _ := cmd.Flags().GetBool("reduce") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag side = strings.ToLower(side) @@ -55,8 +55,13 @@ func newOrderModifyCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "at least one of --price or --size is required") } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } + if !hasPrice || !hasSize { - existing, err := lookupOpenOrderByOID(cmd, cfg, oid, coin) + existing, err := lookupOpenOrderByOID(cmd, cfg, oid, coin, onBehalfOf) if err != nil { return err } @@ -91,11 +96,6 @@ func newOrderModifyCmd() *cobra.Command { expiresAfter = &ms } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) - } - exec, err := buildExecutor(cfg) if err != nil { return err @@ -110,7 +110,7 @@ func newOrderModifyCmd() *cobra.Command { Tif: wireTif, ReduceOnly: reduce, ExpiresAfter: expiresAfter, - VaultAddr: vault, + OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -128,7 +128,7 @@ func newOrderModifyCmd() *cobra.Command { cmd.Flags().String("size", "", "new order size (optional if --price is set)") cmd.Flags().String("tif", "gtc", "time in force: gtc, ioc, alo") cmd.Flags().Bool("reduce", false, "reduce-only order") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") for _, required := range []string{"coin", "oid", "side"} { @@ -139,10 +139,14 @@ func newOrderModifyCmd() *cobra.Command { return cmd } -func lookupOpenOrderByOID(cmd *cobra.Command, cfg *config.Config, oid uint64, coin string) (*info.OpenOrder, error) { - addr, err := info.ResolveUserAddress("", cfg) - if err != nil { - return nil, err +func lookupOpenOrderByOID(cmd *cobra.Command, cfg *config.Config, oid uint64, coin string, onBehalfOf string) (*info.OpenOrder, error) { + addr := onBehalfOf + if addr == "" { + var err error + addr, err = info.ResolveUserAddress("", cfg) + if err != nil { + return nil, err + } } ic := buildInfoClient(cfg) diff --git a/cmd/order_place.go b/cmd/order_place.go index ace3822..1e4b97b 100644 --- a/cmd/order_place.go +++ b/cmd/order_place.go @@ -37,7 +37,7 @@ func newOrderPlaceCmd() *cobra.Command { tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag reduce, _ := cmd.Flags().GetBool("reduce") //nolint:errcheck // known flag cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag @@ -74,9 +74,9 @@ func newOrderPlaceCmd() *cobra.Command { cloid = &cloidStr } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) } changedBuilder := cmd.Flags().Changed("builder") @@ -128,7 +128,7 @@ func newOrderPlaceCmd() *cobra.Command { Cloid: cloid, Builder: builder, ExpiresAfter: expiresAfter, - VaultAddr: vault, + OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -146,7 +146,7 @@ func newOrderPlaceCmd() *cobra.Command { cmd.Flags().String("tif", "gtc", "time in force: gtc, ioc, alo") cmd.Flags().Bool("reduce", false, "reduce-only order") cmd.Flags().String("cloid", "", "client order ID") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("builder", "", "builder address for optional builder fee routing") cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") @@ -161,10 +161,10 @@ func newOrderPlaceCmd() *cobra.Command { // buildExecutor constructs an exchange.Executor from the current config. func buildExecutor(cfg *config.Config) (*exchange.Executor, error) { - keyHex := os.Getenv(cfg.AgentKeyEnv) + keyHex := os.Getenv(cfg.PrivateKeyEnv) if keyHex == "" { - return nil, output.NewCLIError(output.ErrConfig, "agent key not set"). - WithDetails("env_var", cfg.AgentKeyEnv) + return nil, output.NewCLIError(output.ErrConfig, "private key not set"). + WithDetails("env_var", cfg.PrivateKeyEnv) } s, err := signer.NewSigner(keyHex) diff --git a/cmd/order_schedule_cancel.go b/cmd/order_schedule_cancel.go index 8654733..98b6b57 100644 --- a/cmd/order_schedule_cancel.go +++ b/cmd/order_schedule_cancel.go @@ -3,6 +3,7 @@ package cmd import ( "time" + "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" @@ -18,8 +19,9 @@ func newOrderScheduleCancelCmd() *cobra.Command { or clear an existing schedule. Exactly one of --timeout or --clear must be provided.`, RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - timeoutStr, _ := cmd.Flags().GetString("timeout") //nolint:errcheck // known flag - clear, _ := cmd.Flags().GetBool("clear") //nolint:errcheck // known flag + timeoutStr, _ := cmd.Flags().GetString("timeout") //nolint:errcheck // known flag + clear, _ := cmd.Flags().GetBool("clear") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag hasTimeout := cmd.Flags().Changed("timeout") @@ -29,6 +31,10 @@ or clear an existing schedule. Exactly one of --timeout or --clear must be provi if !hasTimeout && !clear { return output.NewCLIError(output.ErrValidation, "one of --timeout or --clear is required") } + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) + } var cancelTime *int64 if hasTimeout { @@ -57,8 +63,9 @@ or clear an existing schedule. Exactly one of --timeout or --clear must be provi } result, err := exec.ScheduleCancel(cmd.Context(), exchange.ScheduleCancelInput{ - Time: cancelTime, - DryRun: cfg.DryRun, + Time: cancelTime, + OnBehalfOf: onBehalfOf, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -70,6 +77,7 @@ or clear an existing schedule. Exactly one of --timeout or --clear must be provi cmd.Flags().String("timeout", "", "cancellation timeout (Go duration, e.g. 5m, 1h)") cmd.Flags().Bool("clear", false, "clear existing schedule") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") return cmd } diff --git a/cmd/order_test.go b/cmd/order_test.go index fec6fef..070c337 100644 --- a/cmd/order_test.go +++ b/cmd/order_test.go @@ -314,10 +314,10 @@ func TestOrderModify_InvalidVault(t *testing.T) { err := run("order", "modify", "--coin", "BTC", "--oid", "12345", "--side", "buy", "--price", "50000", "--size", "0.01", - "--vault", "not-a-hex-address", "--dry-run", + "--on-behalf-of", "not-a-hex-address", "--dry-run", ) if err == nil { - t.Fatal("expected error for invalid vault address") + t.Fatal("expected error for invalid on-behalf-of address") } } @@ -400,6 +400,19 @@ func TestOrderScheduleCancel_NeitherFlag(t *testing.T) { } } +func TestOrderScheduleCancel_OnBehalfOfUnsupported(t *testing.T) { + _, _, run := newTestRootWithServer(t, "") + + err := run("order", "schedule-cancel", + "--timeout", "5m", + "--on-behalf-of", "0x1111111111111111111111111111111111111111", + "--dry-run", + ) + if err == nil { + t.Fatal("expected validation error when on-behalf-of is used for schedule-cancel") + } +} + func TestOrderScheduleCancel_TimeoutTooShort(t *testing.T) { _, _, run := newTestRootWithServer(t, "") diff --git a/cmd/position.go b/cmd/position.go index 3096785..8a01f10 100644 --- a/cmd/position.go +++ b/cmd/position.go @@ -7,8 +7,8 @@ func newPositionCmd() *cobra.Command { Use: "position", Short: "Manage positions: leverage and margin", Long: `Set leverage mode (cross/isolated) and multiplier, and update isolated -margin for open positions. Position commands sign with the agent wallet -via the L1 phantom agent path.`, +margin for open positions. Position commands sign with the configured private +key via the L1 phantom agent path.`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, diff --git a/cmd/position_leverage.go b/cmd/position_leverage.go index 05b4969..0f71a13 100644 --- a/cmd/position_leverage.go +++ b/cmd/position_leverage.go @@ -17,10 +17,10 @@ func newPositionLeverageCmd() *cobra.Command { Short: "Set leverage and margin mode for a coin", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - leverage, _ := cmd.Flags().GetInt("leverage") //nolint:errcheck // known flag - mode, _ := cmd.Flags().GetString("mode") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + leverage, _ := cmd.Flags().GetInt("leverage") //nolint:errcheck // known flag + mode, _ := cmd.Flags().GetString("mode") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag mode = strings.ToLower(mode) if mode != "cross" && mode != "isolated" { @@ -33,9 +33,9 @@ func newPositionLeverageCmd() *cobra.Command { WithDetails("value", leverage) } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) } exec, err := buildExecutor(cfg) @@ -44,11 +44,11 @@ func newPositionLeverageCmd() *cobra.Command { } result, err := exec.UpdateLeverage(cmd.Context(), exchange.UpdateLeverageInput{ - Coin: coin, - IsCross: mode == "cross", - Leverage: leverage, - VaultAddr: vault, - DryRun: cfg.DryRun, + Coin: coin, + IsCross: mode == "cross", + Leverage: leverage, + OnBehalfOf: onBehalfOf, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -61,7 +61,7 @@ func newPositionLeverageCmd() *cobra.Command { cmd.Flags().String("coin", "", "coin name (e.g. BTC, ETH)") cmd.Flags().Int("leverage", 0, "leverage multiplier (max is asset-specific, API-enforced)") cmd.Flags().String("mode", "cross", "margin mode: cross or isolated") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") for _, required := range []string{"coin", "leverage"} { //nolint:errcheck // MarkFlagRequired on known flags never fails diff --git a/cmd/position_margin.go b/cmd/position_margin.go index 9f10239..926c162 100644 --- a/cmd/position_margin.go +++ b/cmd/position_margin.go @@ -18,10 +18,10 @@ func newPositionMarginCmd() *cobra.Command { Short: "Adjust isolated margin for a position", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag - amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - vault, _ := cmd.Flags().GetString("vault") //nolint:errcheck // known flag + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag + amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag + onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag side = strings.ToLower(side) if side != "buy" && side != "sell" { @@ -35,9 +35,9 @@ func newPositionMarginCmd() *cobra.Command { WithDetails("value", amountStr) } - if vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) + if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { + return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). + WithDetails("on_behalf_of", onBehalfOf) } exec, err := buildExecutor(cfg) @@ -46,11 +46,11 @@ func newPositionMarginCmd() *cobra.Command { } result, err := exec.UpdateIsolatedMargin(cmd.Context(), exchange.UpdateIsolatedMarginInput{ - Coin: coin, - IsBuy: side == "buy", - Amount: amount, - VaultAddr: vault, - DryRun: cfg.DryRun, + Coin: coin, + IsBuy: side == "buy", + Amount: amount, + OnBehalfOf: onBehalfOf, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -63,7 +63,7 @@ func newPositionMarginCmd() *cobra.Command { cmd.Flags().String("coin", "", "coin name (e.g. BTC, ETH)") cmd.Flags().String("side", "", "position side: buy or sell") cmd.Flags().String("amount", "", "margin amount (positive to add, negative to remove)") - cmd.Flags().String("vault", "", "vault address") + cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") for _, required := range []string{"coin", "side", "amount"} { //nolint:errcheck // MarkFlagRequired on known flags never fails diff --git a/cmd/position_test.go b/cmd/position_test.go index d3188f0..bffaeb5 100644 --- a/cmd/position_test.go +++ b/cmd/position_test.go @@ -71,10 +71,10 @@ func TestPositionLeverage_InvalidVault(t *testing.T) { err := run("position", "leverage", "--coin", "ETH", "--leverage", "5", "--mode", "cross", - "--vault", "not-a-hex-address", "--dry-run", + "--on-behalf-of", "not-a-hex-address", "--dry-run", ) if err == nil { - t.Fatal("expected error for invalid vault address") + t.Fatal("expected error for invalid on-behalf-of address") } } @@ -122,10 +122,10 @@ func TestPositionMargin_InvalidVault(t *testing.T) { err := run("position", "margin", "--coin", "BTC", "--side", "buy", "--amount", "100", - "--vault", "not-a-hex-address", "--dry-run", + "--on-behalf-of", "not-a-hex-address", "--dry-run", ) if err == nil { - t.Fatal("expected error for invalid vault address") + t.Fatal("expected error for invalid on-behalf-of address") } } diff --git a/cmd/root_test.go b/cmd/root_test.go index caa74c6..20d8a50 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -92,7 +92,7 @@ func TestGlobalFlags_Registered(t *testing.T) { func TestConfigLoading_InjectsContext(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - content := []byte("agent_key_env: TEST_AGENT_KEY\nmetadata_ttl: 60\n") + content := []byte("private_key_env: TEST_AGENT_KEY\nmetadata_ttl: 60\n") if err := os.WriteFile(cfgPath, content, 0600); err != nil { t.Fatal(err) } @@ -115,8 +115,8 @@ func TestConfigLoading_InjectsContext(t *testing.T) { if gotCfg == nil { t.Fatal("config not injected into context") } - if gotCfg.AgentKeyEnv != "TEST_AGENT_KEY" { - t.Errorf("AgentKeyEnv = %q, want %q", gotCfg.AgentKeyEnv, "TEST_AGENT_KEY") + if gotCfg.PrivateKeyEnv != "TEST_AGENT_KEY" { + t.Errorf("PrivateKeyEnv = %q, want %q", gotCfg.PrivateKeyEnv, "TEST_AGENT_KEY") } } diff --git a/e2e/agent_simulation_test.go b/e2e/agent_simulation_test.go index 9b667c3..34f6c7d 100644 --- a/e2e/agent_simulation_test.go +++ b/e2e/agent_simulation_test.go @@ -18,14 +18,12 @@ import ( const agentSimulationTimeout = 3 * time.Minute func TestE2E_AgentSimulation(t *testing.T) { - agentKey := strings.TrimSpace(os.Getenv("HL_TEST_AGENT_KEY")) - masterKey := strings.TrimSpace(os.Getenv("HL_TEST_MASTER_KEY")) - if agentKey == "" || masterKey == "" { - t.Skip("skipping agent simulation: set HL_TEST_AGENT_KEY and HL_TEST_MASTER_KEY") + privateKey := strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) + if privateKey == "" { + t.Skip("skipping agent simulation: set HL_TEST_PRIVATE_KEY") } - t.Setenv("HL_AGENT_KEY", agentKey) - t.Setenv("HL_MASTER_KEY", masterKey) + t.Setenv("HL_PRIVATE_KEY", privateKey) start := time.Now() assertWithinTimeout := func(step string) { diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index c611469..4af1854 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -38,8 +38,8 @@ func TestMain(m *testing.M) { if os.Getenv("HL_CONFIG") == "" { _ = os.Setenv("HL_CONFIG", filepath.Join(dir, "e2e-config.yaml")) } - if os.Getenv("HL_AGENT_KEY") == "" { - _ = os.Setenv("HL_AGENT_KEY", e2eTestPrivateKey) + if os.Getenv("HL_PRIVATE_KEY") == "" { + _ = os.Setenv("HL_PRIVATE_KEY", e2eTestPrivateKey) } os.Exit(m.Run()) diff --git a/e2e/integration_cli_test.go b/e2e/integration_cli_test.go index 7d9a9af..1798e0c 100644 --- a/e2e/integration_cli_test.go +++ b/e2e/integration_cli_test.go @@ -38,7 +38,7 @@ func TestMain(m *testing.M) { if os.Getenv("HL_CONFIG") == "" { cfgPath := filepath.Join(dir, "integration-config.yaml") - cfg := []byte("agent_key_env: HL_TEST_AGENT_KEY\nmaster_key_env: HL_TEST_MASTER_KEY\nmetadata_ttl: 300\n") + cfg := []byte("private_key_env: HL_TEST_PRIVATE_KEY\nmetadata_ttl: 300\n") if err := os.WriteFile(cfgPath, cfg, 0600); err != nil { panic("cannot write integration config: " + err.Error()) } @@ -183,10 +183,10 @@ func requireErrorCode(t *testing.T, stderr, want string) map[string]any { return errObj } -func ensureMasterKeyForAccountDryRun(t *testing.T) { +func ensurePrivateKeyForAccountDryRun(t *testing.T) { t.Helper() - if strings.TrimSpace(os.Getenv("HL_TEST_MASTER_KEY")) == "" { - t.Setenv("HL_TEST_MASTER_KEY", integrationTestPrivateKey) + if strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) == "" { + t.Setenv("HL_TEST_PRIVATE_KEY", integrationTestPrivateKey) } } @@ -194,8 +194,7 @@ func assertNoSecretLeak(t *testing.T, stdout, stderr string) { t.Helper() candidates := []string{ integrationTestPrivateKey, - strings.TrimSpace(os.Getenv("HL_TEST_MASTER_KEY")), - strings.TrimSpace(os.Getenv("HL_TEST_AGENT_KEY")), + strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")), } for _, secret := range candidates { @@ -210,7 +209,7 @@ func assertNoSecretLeak(t *testing.T, stdout, stderr string) { func TestIntegration_InfoLookupAllDexesAllowsInheritedDefaultDex(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "integration-config.yaml") - cfg := []byte("agent_key_env: HL_TEST_AGENT_KEY\nmaster_key_env: HL_TEST_MASTER_KEY\ndefault_dex: tngs\nmetadata_ttl: 300\n") + cfg := []byte("private_key_env: HL_TEST_PRIVATE_KEY\ndefault_dex: tngs\nmetadata_ttl: 300\n") if err := os.WriteFile(cfgPath, cfg, 0600); err != nil { t.Fatalf("writing temp config: %v", err) } @@ -330,8 +329,8 @@ func findOpenOrderByLimitPx(orders []map[string]any, px string) (map[string]any, } func TestIntegration_OrderLifecycle(t *testing.T) { - if os.Getenv("HL_TEST_AGENT_KEY") == "" { - t.Skip("skipping order lifecycle integration: HL_TEST_AGENT_KEY is not set") + if os.Getenv("HL_TEST_PRIVATE_KEY") == "" { + t.Skip("skipping order lifecycle integration: HL_TEST_PRIVATE_KEY is not set") } cloid := newIntegrationCloid(t) @@ -750,11 +749,11 @@ func TestIntegration_AccountValidationContracts(t *testing.T) { } } -func TestIntegration_AccountMissingMasterKeyConfigError(t *testing.T) { - t.Setenv("HL_TEST_MASTER_KEY_NOT_SET", "") +func TestIntegration_AccountMissingPrivateKeyConfigError(t *testing.T) { + t.Setenv("HL_TEST_PRIVATE_KEY_NOT_SET", "") cfgPath := filepath.Join(t.TempDir(), "integration-config.yaml") - cfg := []byte("agent_key_env: HL_TEST_AGENT_KEY\nmaster_key_env: HL_TEST_MASTER_KEY_NOT_SET\nmetadata_ttl: 300\n") + cfg := []byte("private_key_env: HL_TEST_PRIVATE_KEY_NOT_SET\nmetadata_ttl: 300\n") if err := os.WriteFile(cfgPath, cfg, 0600); err != nil { t.Fatalf("writing temp config: %v", err) } @@ -769,17 +768,17 @@ func TestIntegration_AccountMissingMasterKeyConfigError(t *testing.T) { assertNoSecretLeak(t, stdout, stderr) requireIntegrationExitCode(t, code, 2, stderr) errObj := requireErrorCode(t, stderr, "CONFIG_ERROR") - requireFieldString(t, errObj, "error", "master key not set") + requireFieldString(t, errObj, "error", "private key not set") details, ok := errObj["details"].(map[string]any) if !ok { t.Fatalf("expected details map, got %#v", errObj["details"]) } - requireFieldString(t, details, "env_var", "HL_TEST_MASTER_KEY_NOT_SET") + requireFieldString(t, details, "env_var", "HL_TEST_PRIVATE_KEY_NOT_SET") } func TestIntegration_AccountDryRunNonceFreshness(t *testing.T) { - ensureMasterKeyForAccountDryRun(t) + ensurePrivateKeyForAccountDryRun(t) stdout1, stderr1, code1 := runIntegrationHLGO(t, "account", "transfer", @@ -811,8 +810,8 @@ func TestIntegration_AccountDryRunNonceFreshness(t *testing.T) { } func TestIntegration_AccountLiveReversibleFlows(t *testing.T) { - if strings.TrimSpace(os.Getenv("HL_TEST_MASTER_KEY")) == "" { - t.Skip("skipping live account reversible flows: HL_TEST_MASTER_KEY is not set") + if strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) == "" { + t.Skip("skipping live account reversible flows: HL_TEST_PRIVATE_KEY is not set") } req := requiredLiveEnv(t, "HL_TEST_ACCOUNT_TRANSFER_AMOUNT") @@ -946,8 +945,8 @@ func TestIntegration_AccountLiveReversibleFlows(t *testing.T) { } func TestIntegration_AccountLiveOneWayOperations(t *testing.T) { - if strings.TrimSpace(os.Getenv("HL_TEST_MASTER_KEY")) == "" { - t.Skip("skipping live account one-way operations: HL_TEST_MASTER_KEY is not set") + if strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) == "" { + t.Skip("skipping live account one-way operations: HL_TEST_PRIVATE_KEY is not set") } req := requiredLiveEnv( diff --git a/pkg/config/config.go b/pkg/config/config.go index 6690585..7010f7f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,10 +16,9 @@ const DefaultConfigPath = "~/.hlgo/config.yaml" // Config holds all resolved configuration for an hlgo invocation. type Config struct { // Persisted fields (written to config file) - AgentKeyEnv string `mapstructure:"agent_key_env" yaml:"agent_key_env"` - MasterKeyEnv string `mapstructure:"master_key_env" yaml:"master_key_env"` - DefaultDex string `mapstructure:"default_dex" yaml:"default_dex"` - MetadataTTL int `mapstructure:"metadata_ttl" yaml:"metadata_ttl"` + PrivateKeyEnv string `mapstructure:"private_key_env" yaml:"private_key_env"` + DefaultDex string `mapstructure:"default_dex" yaml:"default_dex"` + MetadataTTL int `mapstructure:"metadata_ttl" yaml:"metadata_ttl"` // Runtime fields (resolved from flags/env, never persisted) Testnet bool `mapstructure:"-" yaml:"-"` @@ -81,8 +80,7 @@ func Load(v *viper.Viper) (*Config, error) { // setDefaults registers default values for all config keys. func setDefaults(v *viper.Viper) { - v.SetDefault("agent_key_env", "HL_AGENT_KEY") - v.SetDefault("master_key_env", "HL_MASTER_KEY") + v.SetDefault("private_key_env", "HL_PRIVATE_KEY") v.SetDefault("default_dex", "") v.SetDefault("metadata_ttl", 300) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index db89381..59fe898 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -16,8 +16,7 @@ func TestDefaults(t *testing.T) { key string want any }{ - {"agent_key_env", "HL_AGENT_KEY"}, - {"master_key_env", "HL_MASTER_KEY"}, + {"private_key_env", "HL_PRIVATE_KEY"}, {"default_dex", ""}, {"metadata_ttl", 300}, } @@ -45,7 +44,7 @@ func newTestViper(configPath string) *viper.Viper { func TestLoad_FromFile(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - content := []byte("agent_key_env: MY_KEY\nmaster_key_env: MY_MASTER\ndefault_dex: xyz\nmetadata_ttl: 120\n") + content := []byte("private_key_env: MY_KEY\ndefault_dex: xyz\nmetadata_ttl: 120\n") if err := os.WriteFile(cfgPath, content, 0600); err != nil { t.Fatal(err) } @@ -59,11 +58,8 @@ func TestLoad_FromFile(t *testing.T) { t.Fatalf("Load error: %v", err) } - if cfg.AgentKeyEnv != "MY_KEY" { - t.Errorf("AgentKeyEnv = %q, want %q", cfg.AgentKeyEnv, "MY_KEY") - } - if cfg.MasterKeyEnv != "MY_MASTER" { - t.Errorf("MasterKeyEnv = %q, want %q", cfg.MasterKeyEnv, "MY_MASTER") + if cfg.PrivateKeyEnv != "MY_KEY" { + t.Errorf("PrivateKeyEnv = %q, want %q", cfg.PrivateKeyEnv, "MY_KEY") } if cfg.DefaultDex != "xyz" { t.Errorf("DefaultDex = %q, want %q", cfg.DefaultDex, "xyz") @@ -86,8 +82,8 @@ func TestLoad_MissingConfigTolerated(t *testing.T) { if err != nil { t.Fatalf("expected no error for missing config, got: %v", err) } - if cfg.AgentKeyEnv != "HL_AGENT_KEY" { - t.Errorf("AgentKeyEnv = %q, want default %q", cfg.AgentKeyEnv, "HL_AGENT_KEY") + if cfg.PrivateKeyEnv != "HL_PRIVATE_KEY" { + t.Errorf("PrivateKeyEnv = %q, want default %q", cfg.PrivateKeyEnv, "HL_PRIVATE_KEY") } } @@ -146,7 +142,7 @@ func TestLoad_XDGFallback(t *testing.T) { hlgoDir := filepath.Join(xdgDir, "hlgo") os.MkdirAll(hlgoDir, 0700) cfgPath := filepath.Join(hlgoDir, "config.yaml") - os.WriteFile(cfgPath, []byte("agent_key_env: XDG_FOUND\n"), 0600) + os.WriteFile(cfgPath, []byte("private_key_env: XDG_FOUND\n"), 0600) // Use default config path so discovery kicks in via AddConfigPath v := newTestViper(DefaultConfigPath) @@ -155,7 +151,7 @@ func TestLoad_XDGFallback(t *testing.T) { if err != nil { t.Fatalf("Load error: %v", err) } - if cfg.AgentKeyEnv != "XDG_FOUND" { - t.Errorf("AgentKeyEnv = %q, want %q (from XDG path)", cfg.AgentKeyEnv, "XDG_FOUND") + if cfg.PrivateKeyEnv != "XDG_FOUND" { + t.Errorf("PrivateKeyEnv = %q, want %q (from XDG path)", cfg.PrivateKeyEnv, "XDG_FOUND") } } diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go index 595e093..3db82e9 100644 --- a/pkg/config/context_test.go +++ b/pkg/config/context_test.go @@ -6,15 +6,15 @@ import ( ) func TestContext_RoundTrip(t *testing.T) { - cfg := &Config{AgentKeyEnv: "TEST_KEY", MetadataTTL: 600} + cfg := &Config{PrivateKeyEnv: "TEST_KEY", MetadataTTL: 600} ctx := WithContext(context.Background(), cfg) got := FromContext(ctx) if got == nil { t.Fatal("FromContext returned nil") } - if got.AgentKeyEnv != "TEST_KEY" { - t.Errorf("AgentKeyEnv = %q, want %q", got.AgentKeyEnv, "TEST_KEY") + if got.PrivateKeyEnv != "TEST_KEY" { + t.Errorf("PrivateKeyEnv = %q, want %q", got.PrivateKeyEnv, "TEST_KEY") } if got.MetadataTTL != 600 { t.Errorf("MetadataTTL = %d, want %d", got.MetadataTTL, 600) diff --git a/pkg/exchange/executor.go b/pkg/exchange/executor.go index 04fae2c..810401e 100644 --- a/pkg/exchange/executor.go +++ b/pkg/exchange/executor.go @@ -62,26 +62,26 @@ type PlaceOrderInput struct { Builder *BuilderInfo // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. ExpiresAfter *int64 - VaultAddr string + OnBehalfOf string DryRun bool } // UpdateLeverageInput holds parameters for updating leverage. type UpdateLeverageInput struct { - Coin string - IsCross bool - Leverage int - VaultAddr string - DryRun bool + Coin string + IsCross bool + Leverage int + OnBehalfOf string + DryRun bool } // UpdateIsolatedMarginInput holds parameters for adjusting isolated margin. type UpdateIsolatedMarginInput struct { - Coin string - IsBuy bool - Amount decimal.Decimal - VaultAddr string - DryRun bool + Coin string + IsBuy bool + Amount decimal.Decimal + OnBehalfOf string + DryRun bool } // ModifyOrderInput holds parameters for modifying an existing order. @@ -95,14 +95,15 @@ type ModifyOrderInput struct { ReduceOnly bool Cloid *string ExpiresAfter *int64 - VaultAddr string + OnBehalfOf string DryRun bool } // ScheduleCancelInput holds parameters for the dead man's switch. type ScheduleCancelInput struct { - Time *int64 - DryRun bool + Time *int64 + OnBehalfOf string + DryRun bool } // PlaceMarketOrderInput bundles parameters for placing a market order. @@ -115,29 +116,32 @@ type PlaceMarketOrderInput struct { Builder *BuilderInfo // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. ExpiresAfter *int64 - VaultAddr string + OnBehalfOf string DryRun bool } // USDClassTransferInput holds parameters for usdClassTransfer account actions. type USDClassTransferInput struct { - Amount decimal.Decimal - ToPerp bool - DryRun bool + Amount decimal.Decimal + ToPerp bool + OnBehalfOf string + DryRun bool } // Withdraw3Input holds parameters for withdraw3 account actions. type Withdraw3Input struct { Destination string Amount decimal.Decimal + OnBehalfOf string DryRun bool } // ClassTransferInput holds parameters for classTransfer account actions. type ClassTransferInput struct { - Amount decimal.Decimal - ToPerp bool - DryRun bool + Amount decimal.Decimal + ToPerp bool + OnBehalfOf string + DryRun bool } // SpotSendInput holds parameters for spotSend account actions. @@ -145,6 +149,7 @@ type SpotSendInput struct { Destination string Token string Amount decimal.Decimal + OnBehalfOf string DryRun bool } @@ -152,6 +157,7 @@ type SpotSendInput struct { type ApproveAgentInput struct { AgentAddress string AgentName string + OnBehalfOf string DryRun bool } @@ -159,6 +165,7 @@ type ApproveAgentInput struct { type UserSetAbstractionInput struct { User string Abstraction string + OnBehalfOf string DryRun bool } @@ -305,7 +312,7 @@ func (e *Executor) PlaceMarketOrder(ctx context.Context, input PlaceMarketOrderI Tif: "Ioc", Builder: input.Builder, ExpiresAfter: input.ExpiresAfter, - VaultAddr: input.VaultAddr, + OnBehalfOf: input.OnBehalfOf, DryRun: input.DryRun, }) } @@ -439,19 +446,19 @@ func (e *Executor) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Plac // 7. Generate nonce and sign. nonce := time.Now().UnixMilli() - var vaultAddr *common.Address - if input.VaultAddr != "" { - a := common.HexToAddress(input.VaultAddr) - vaultAddr = &a + var onBehalfOf *common.Address + if input.OnBehalfOf != "" { + a := common.HexToAddress(input.OnBehalfOf) + onBehalfOf = &a } - sig, err := e.signer.SignL1Action(action, nonce, vaultAddr, input.ExpiresAfter, e.mainnet) + sig, err := e.signer.SignL1Action(action, nonce, onBehalfOf, input.ExpiresAfter, e.mainnet) if err != nil { return nil, err } // 8. Send to exchange. - resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.VaultAddr, input.ExpiresAfter) + resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.OnBehalfOf, input.ExpiresAfter) if err != nil { return nil, err } @@ -464,7 +471,7 @@ func (e *Executor) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Plac } // CancelOrders cancels orders by OID. -func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, vaultAddr string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { +func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, onBehalfOf string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { action := BuildCancelAction(cancels) if dryRun { @@ -473,22 +480,22 @@ func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, vault nonce := time.Now().UnixMilli() - var vault *common.Address - if vaultAddr != "" { - a := common.HexToAddress(vaultAddr) - vault = &a + var onBehalfOfAddr *common.Address + if onBehalfOf != "" { + a := common.HexToAddress(onBehalfOf) + onBehalfOfAddr = &a } - sig, err := e.signer.SignL1Action(action, nonce, vault, expiresAfter, e.mainnet) + sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, expiresAfter, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), vaultAddr, expiresAfter) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), onBehalfOf, expiresAfter) } // CancelByCloid cancels orders by client order ID. -func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWire, vaultAddr string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { +func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWire, onBehalfOf string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { action := BuildCancelByCloidAction(cancels) if dryRun { @@ -497,18 +504,18 @@ func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWir nonce := time.Now().UnixMilli() - var vault *common.Address - if vaultAddr != "" { - a := common.HexToAddress(vaultAddr) - vault = &a + var onBehalfOfAddr *common.Address + if onBehalfOf != "" { + a := common.HexToAddress(onBehalfOf) + onBehalfOfAddr = &a } - sig, err := e.signer.SignL1Action(action, nonce, vault, expiresAfter, e.mainnet) + sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, expiresAfter, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), vaultAddr, expiresAfter) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), onBehalfOf, expiresAfter) } // UpdateLeverage sets leverage and margin mode for a coin. @@ -531,18 +538,18 @@ func (e *Executor) UpdateLeverage(ctx context.Context, input UpdateLeverageInput nonce := time.Now().UnixMilli() - var vault *common.Address - if input.VaultAddr != "" { - a := common.HexToAddress(input.VaultAddr) - vault = &a + var onBehalfOfAddr *common.Address + if input.OnBehalfOf != "" { + a := common.HexToAddress(input.OnBehalfOf) + onBehalfOfAddr = &a } - sig, err := e.signer.SignL1Action(action, nonce, vault, nil, e.mainnet) + sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, nil, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.VaultAddr, nil) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.OnBehalfOf, nil) } // UpdateIsolatedMargin adjusts isolated margin for a position. @@ -572,18 +579,18 @@ func (e *Executor) UpdateIsolatedMargin(ctx context.Context, input UpdateIsolate nonce := time.Now().UnixMilli() - var vault *common.Address - if input.VaultAddr != "" { - a := common.HexToAddress(input.VaultAddr) - vault = &a + var onBehalfOfAddr *common.Address + if input.OnBehalfOf != "" { + a := common.HexToAddress(input.OnBehalfOf) + onBehalfOfAddr = &a } - sig, err := e.signer.SignL1Action(action, nonce, vault, nil, e.mainnet) + sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, nil, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.VaultAddr, nil) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.OnBehalfOf, nil) } // ModifyOrder modifies an existing order. @@ -639,18 +646,18 @@ func (e *Executor) ModifyOrder(ctx context.Context, input ModifyOrderInput) (*Mo nonce := time.Now().UnixMilli() - var vault *common.Address - if input.VaultAddr != "" { - a := common.HexToAddress(input.VaultAddr) - vault = &a + var onBehalfOfAddr *common.Address + if input.OnBehalfOf != "" { + a := common.HexToAddress(input.OnBehalfOf) + onBehalfOfAddr = &a } - sig, err := e.signer.SignL1Action(action, nonce, vault, input.ExpiresAfter, e.mainnet) + sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, input.ExpiresAfter, e.mainnet) if err != nil { return nil, err } - resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.VaultAddr, input.ExpiresAfter) + resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.OnBehalfOf, input.ExpiresAfter) if err != nil { return nil, err } @@ -662,25 +669,31 @@ func (e *Executor) ModifyOrder(ctx context.Context, input ModifyOrderInput) (*Mo } // PlaceBatchOrders signs and sends a pre-built OrderAction for batch order placement. -func (e *Executor) PlaceBatchOrders(ctx context.Context, action *OrderAction, vaultAddr string, expiresAfter *int64) (json.RawMessage, error) { +func (e *Executor) PlaceBatchOrders(ctx context.Context, action *OrderAction, onBehalfOf string, expiresAfter *int64) (json.RawMessage, error) { nonce := time.Now().UnixMilli() - var vault *common.Address - if vaultAddr != "" { - a := common.HexToAddress(vaultAddr) - vault = &a + var onBehalfOfAddr *common.Address + if onBehalfOf != "" { + a := common.HexToAddress(onBehalfOf) + onBehalfOfAddr = &a } - sig, err := e.signer.SignL1Action(action, nonce, vault, expiresAfter, e.mainnet) + sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, expiresAfter, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), vaultAddr, expiresAfter) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), onBehalfOf, expiresAfter) } // ScheduleCancel sets or clears the dead man's switch for order cancellation. func (e *Executor) ScheduleCancel(ctx context.Context, input ScheduleCancelInput) (json.RawMessage, error) { + if input.OnBehalfOf != "" { + return nil, output.NewCLIError(output.ErrValidation, "on-behalf-of is not supported for schedule-cancel"). + WithDetails("on_behalf_of", input.OnBehalfOf). + WithDetails("hint", "schedule-cancel always applies to the signing wallet") + } + action := BuildScheduleCancelAction(input.Time) if input.DryRun { @@ -689,7 +702,7 @@ func (e *Executor) ScheduleCancel(ctx context.Context, input ScheduleCancelInput nonce := time.Now().UnixMilli() - // ScheduleCancel does not support vault addresses — the Hyperliquid API + // ScheduleCancel does not support on-behalf-of contexts — the Hyperliquid API // applies the dead man's switch to the signing wallet only. sig, err := e.signer.SignL1Action(action, nonce, nil, nil, e.mainnet) if err != nil { @@ -714,6 +727,7 @@ func (e *Executor) USDClassTransfer(ctx context.Context, input USDClassTransferI nonce, "HyperliquidTransaction:UsdClassTransfer", usdClassTransferSignTypes, + input.OnBehalfOf, input.DryRun, ) } @@ -737,6 +751,7 @@ func (e *Executor) Withdraw3(ctx context.Context, input Withdraw3Input) (json.Ra nonce, "HyperliquidTransaction:Withdraw", withdrawSignTypes, + input.OnBehalfOf, input.DryRun, ) } @@ -756,6 +771,7 @@ func (e *Executor) ClassTransfer(ctx context.Context, input ClassTransferInput) nonce, "HyperliquidTransaction:UsdClassTransfer", usdClassTransferSignTypes, + input.OnBehalfOf, input.DryRun, ) } @@ -782,6 +798,7 @@ func (e *Executor) SpotSend(ctx context.Context, input SpotSendInput) (json.RawM nonce, "HyperliquidTransaction:SpotSend", spotSendSignTypes, + input.OnBehalfOf, input.DryRun, ) } @@ -801,6 +818,7 @@ func (e *Executor) ApproveAgent(ctx context.Context, input ApproveAgentInput) (j nonce, "HyperliquidTransaction:ApproveAgent", approveAgentSignTypes, + input.OnBehalfOf, input.DryRun, ) } @@ -829,6 +847,7 @@ func (e *Executor) UserSetAbstraction(ctx context.Context, input UserSetAbstract nonce, "HyperliquidTransaction:UserSetAbstraction", userSetAbstractionSignTypes, + input.OnBehalfOf, input.DryRun, ) } @@ -839,8 +858,16 @@ func (e *Executor) executeUserAction( nonce int64, typeName string, typeFields []apitypes.Type, + onBehalfOf string, dryRun bool, ) (json.RawMessage, error) { + if onBehalfOf != "" { + return nil, output.NewCLIError(output.ErrValidation, "on-behalf-of is not supported for user-signed actions"). + WithDetails("on_behalf_of", onBehalfOf). + WithDetails("action", typeName). + WithDetails("hint", "remove --on-behalf-of for account commands") + } + actionMap, err := userActionMap(action) if err != nil { return nil, output.NewCLIError(output.ErrAPI, "failed to build action payload"). diff --git a/pkg/exchange/types.go b/pkg/exchange/types.go index d9cc91b..50d2575 100644 --- a/pkg/exchange/types.go +++ b/pkg/exchange/types.go @@ -133,7 +133,7 @@ type SpotSendAction struct { Time int64 `msgpack:"time" json:"time"` } -// ApproveAgentAction approves an agent wallet for trading on behalf of the master wallet. +// ApproveAgentAction approves an agent wallet for trading on behalf of an account. type ApproveAgentAction struct { Type string `msgpack:"type" json:"type"` AgentAddress string `msgpack:"agentAddress" json:"agentAddress"` diff --git a/pkg/info/address.go b/pkg/info/address.go index 515ea7a..d29c46f 100644 --- a/pkg/info/address.go +++ b/pkg/info/address.go @@ -16,7 +16,7 @@ var ethAddrRegex = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) // // Priority: // 1. Explicit address argument (validated for 0x + 40 hex chars) -// 2. Agent wallet derived from config's agent key env var +// 2. Wallet derived from config's private key env var // 3. Error if neither is available func ResolveUserAddress(explicitAddr string, cfg *config.Config) (string, error) { if explicitAddr != "" { @@ -28,10 +28,10 @@ func ResolveUserAddress(explicitAddr string, cfg *config.Config) (string, error) return explicitAddr, nil } - keyHex := os.Getenv(cfg.AgentKeyEnv) + keyHex := os.Getenv(cfg.PrivateKeyEnv) if keyHex == "" { - return "", output.NewCLIError(output.ErrConfig, "no address available: provide --address or set "+cfg.AgentKeyEnv). - WithDetails("agent_key_env", cfg.AgentKeyEnv) + return "", output.NewCLIError(output.ErrConfig, "no address available: provide --address or set "+cfg.PrivateKeyEnv). + WithDetails("private_key_env", cfg.PrivateKeyEnv) } s, err := signer.NewSigner(keyHex) diff --git a/pkg/info/address_test.go b/pkg/info/address_test.go index 2c6e107..df0d561 100644 --- a/pkg/info/address_test.go +++ b/pkg/info/address_test.go @@ -42,11 +42,11 @@ func TestResolveUserAddress_ExplicitInvalid(t *testing.T) { } } -func TestResolveUserAddress_FromAgentKey(t *testing.T) { +func TestResolveUserAddress_FromPrivateKey(t *testing.T) { // Use the well-known test key. - t.Setenv("TEST_AGENT_KEY", "0x0123456789012345678901234567890123456789012345678901234567890123") + t.Setenv("TEST_PRIVATE_KEY", "0x0123456789012345678901234567890123456789012345678901234567890123") - cfg := &config.Config{AgentKeyEnv: "TEST_AGENT_KEY"} + cfg := &config.Config{PrivateKeyEnv: "TEST_PRIVATE_KEY"} addr, err := ResolveUserAddress("", cfg) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -57,9 +57,9 @@ func TestResolveUserAddress_FromAgentKey(t *testing.T) { } func TestResolveUserAddress_NoAddressAvailable(t *testing.T) { - t.Setenv("HL_AGENT_KEY", "") + t.Setenv("HL_PRIVATE_KEY", "") - cfg := &config.Config{AgentKeyEnv: "HL_AGENT_KEY"} + cfg := &config.Config{PrivateKeyEnv: "HL_PRIVATE_KEY"} _, err := ResolveUserAddress("", cfg) if err == nil { t.Fatal("expected error when no address available") diff --git a/plans/hlgo-technical-spec-v0.1.0.md b/plans/hlgo-technical-spec-v0.1.0.md index 2ca37f0..3e6d5ab 100644 --- a/plans/hlgo-technical-spec-v0.1.0.md +++ b/plans/hlgo-technical-spec-v0.1.0.md @@ -57,10 +57,10 @@ Two distinct signing paths, both using EIP-712 typed data: **User-Signed Actions** — used for transfers, withdrawals, agent management: -- Chain ID: `0xa4b1` (Arbitrum, 42161) +- Chain ID: `0x66eee` (Arbitrum Sepolia, 421614; SDK-compat signing constant) - Domain name: `"HyperliquidSignTransaction"` - Flow: construct EIP-712 typed data directly → sign -- **Only master wallet can sign these** +- **Signed with the configured private key** ### 2.3 Nonces @@ -181,7 +181,7 @@ POST /info {"type": "clearinghouseState", "user": "0x..."} POST /info {"type": "clearinghouseState", "user": "0x...", "dex": "xyz"} ``` -Defaults to agent wallet address if no address provided. +Defaults to the address derived from the configured private key if no address is provided. #### `hlgo info spot-state [address]` Spot balances and holds. @@ -227,9 +227,9 @@ List all HIP-3 perp dexes. POST /info {"type": "perpDexs"} ``` -### 4.2 Exchange Commands (Signed, Agent Wallet) +### 4.2 Exchange Commands (Signed, L1 Path) -All exchange commands use the **agent wallet** for signing (L1 phantom agent path). +All exchange commands use the configured private key for signing (L1 phantom agent path), with optional `--on-behalf-of` account context where supported. #### `hlgo order place` Place a limit order. @@ -241,7 +241,7 @@ Key flags: - `--cloid` - `--tp`, `--sl` - `--builder-fee` -- `--vault` +- `--on-behalf-of` The CLI resolves asset IDs, tick size, and lot size from metadata, then rounds and signs correctly. diff --git a/plans/issues/github-issues-seed.md b/plans/issues/github-issues-seed.md index 23f8826..5fa1962 100644 --- a/plans/issues/github-issues-seed.md +++ b/plans/issues/github-issues-seed.md @@ -24,7 +24,7 @@ Implement Viper-backed config resolution + env override support and interactive ### Acceptance Criteria - Supports `~/.hlgo/config.yaml` and `HL_CONFIG` override. -- Env var based key references (`HL_AGENT_KEY`, `HL_MASTER_KEY`) only. +- Env var based key reference (`HL_PRIVATE_KEY`) only. - `config show` redacts secrets. - `config test` validates API connectivity and agent approval status. - Unit tests for missing/invalid config + redaction behavior. @@ -36,8 +36,8 @@ Implement Viper-backed config resolution + env override support and interactive Wrap `sonirico/go-hyperliquid` signing APIs with explicit wallet selection and deterministic unit tests from Python SDK vectors. ### Acceptance Criteria -- L1 phantom signing path implemented (agent wallet). -- User-signed path implemented (master wallet). +- L1 phantom signing path implemented (configured key). +- User-signed path implemented (configured key). - All required deterministic signature vectors ported and passing. - Nonce generation helper uses Unix ms. - CI job runs signer vector tests on every PR. @@ -76,7 +76,7 @@ Implement MVP read commands: state, mids, book, trades, open-orders, fills, meta ### Acceptance Criteria - All listed commands return JSON by default and support `--format table`. -- Address optional args default to configured agent wallet. +- Address optional args default to configured signer address. - `--dex` and `--spot` behavior implemented where applicable. - Errors follow standard JSON schema on stderr. - Unit tests for request payload shaping. @@ -85,7 +85,7 @@ Implement MVP read commands: state, mids, book, trades, open-orders, fills, meta **Labels:** `phase-3`, `exchange`, `mvp` ### Description -Implement order place/market/cancel/cancel-all/modify/batch with agent wallet signing. +Implement order place/market/cancel/cancel-all/modify/batch with configured-key L1 signing. ### Acceptance Criteria - `order place` maps all required wire fields correctly. @@ -99,10 +99,10 @@ Implement order place/market/cancel/cancel-all/modify/batch with agent wallet si **Labels:** `phase-4`, `account`, `exchange` ### Description -Implement leverage/margin and master-wallet account actions (transfer, withdraw, class-transfer, send-asset, approve-agent, dex-abstraction). +Implement leverage/margin and account actions (transfer, withdraw, class-transfer, send-asset, approve-agent, dex-abstraction). ### Acceptance Criteria -- Agent vs master wallet enforcement implemented. +- Single-key signer behavior implemented for both L1 and user-signed paths. - Dangerous commands require `--confirm` unless `--dry-run`. - Successful action responses normalized to JSON output schema. - Integration tests for at least one user-signed flow. diff --git a/skill/hlgo/SKILL.md b/skill/hlgo/SKILL.md index d09f9a3..3af0949 100644 --- a/skill/hlgo/SKILL.md +++ b/skill/hlgo/SKILL.md @@ -48,13 +48,13 @@ hlgo info open-orders --testnet --format json |---|---|---|---| | `info` | Market and account reads | None | No signing | | `agent snapshot/pnl` | Composed read workflows | None | No signing | -| `agent bracket` | Entry + TP + SL grouped order | Agent (`agent_key_env`) | L1 phantom-agent | -| `order` | Order lifecycle (place, cancel, modify, batch) | Agent (`agent_key_env`) | L1 phantom-agent | -| `position` | Leverage and margin changes | Agent (`agent_key_env`) | L1 phantom-agent | -| `account` | Transfers, withdrawals, agent approval | Master (`master_key_env`) | User-signed | +| `agent bracket` | Entry + TP + SL grouped order | Configured key (`private_key_env`) | L1 phantom-agent | +| `order` | Order lifecycle (place, cancel, modify, batch) | Configured key (`private_key_env`) | L1 phantom-agent | +| `position` | Leverage and margin changes | Configured key (`private_key_env`) | L1 phantom-agent | +| `account` | Transfers, withdrawals, agent approval | Configured key (`private_key_env`) | User-signed | | `config` / `version` | Setup and environment checks | None | No signing | -Pick the wrong wallet and signing fails silently or with `SIGNING_ERROR`. When in doubt, check the table. +Pick the wrong command/signing path and requests can fail with `SIGNING_ERROR` or `VALIDATION_ERROR`. When in doubt, check the table. ## Operating Rules @@ -85,6 +85,6 @@ Load only the reference needed for the current task: ## Completion Checklist - Document command + required flags + example. -- Confirm whether the command uses agent-wallet or master-wallet signing (check table above). +- Confirm whether the command uses L1 phantom-agent or user-signed flow (check table above). - Include a `--dry-run` step for mutating actions unless explicitly told to execute live. - Include one or more post-mutation verification commands. diff --git a/skill/hlgo/references/command-reference.md b/skill/hlgo/references/command-reference.md index 350a9bd..740f1e7 100644 --- a/skill/hlgo/references/command-reference.md +++ b/skill/hlgo/references/command-reference.md @@ -47,7 +47,7 @@ hlgo info mids --testnet --format json | Command | Purpose | Key Flags | Example | |---|---|---|---| -| `hlgo config init` | Create config file | `--agent-key-env`, `--master-key-env`, `--default-dex`, `--metadata-ttl`, `--force` | `hlgo config init --agent-key-env HL_AGENT_KEY --master-key-env HL_MASTER_KEY` | +| `hlgo config init` | Create config file | `--private-key-env`, `--private-key-env`, `--default-dex`, `--metadata-ttl`, `--force` | `hlgo config init --private-key-env HL_PRIVATE_KEY --private-key-env HL_PRIVATE_KEY` | | `hlgo config show` | Show resolved config with key redaction | `--config`, `--testnet`, `--format` | `hlgo config show --testnet` | | `hlgo config test` | Validate config readability, key envs, and API connectivity | `--config`, `--testnet` | `hlgo config test --testnet` | @@ -77,26 +77,26 @@ hlgo info mids --testnet --format json |---|---|---|---| | `hlgo agent snapshot` | Aggregate state, spot-state, open-orders, fills, and mids | `--address` | `hlgo agent snapshot --testnet --format json` | | `hlgo agent pnl` | Compute unrealized, realized, and funding PnL | `--address`, `--lookback-hours`, `--aggregate-fills` | `hlgo agent pnl --lookback-hours 24 --aggregate-fills --testnet --format json` | -| `hlgo agent bracket` | Place entry + TP + SL in one grouped action | `--coin`, `--side`, `--price`, `--size`, `--tp`, `--sl`, optional `--tif`, `--cloid`, `--vault`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo agent bracket --coin ETH --side buy --price 3000 --size 0.1 --tp 3100 --sl 2950 --testnet --dry-run` | +| `hlgo agent bracket` | Place entry + TP + SL in one grouped action | `--coin`, `--side`, `--price`, `--size`, `--tp`, `--sl`, optional `--tif`, `--cloid`, `--on-behalf-of`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo agent bracket --coin ETH --side buy --price 3000 --size 0.1 --tp 3100 --sl 2950 --testnet --dry-run` | ## Order Commands | Command | Purpose | Key Flags | Example | |---|---|---|---| -| `hlgo order place` | Place limit order | Required: `--coin`, `--side`, `--price`, `--size`; Optional: `--tif`, `--reduce`, `--cloid`, `--vault`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order place --coin ETH --side buy --price 3000 --size 0.1 --tif gtc --testnet --dry-run` | -| `hlgo order market` | Place market IOC via slippage-adjusted mid | Required: `--coin`, `--side`, `--size`; Optional: `--slippage`, `--vault`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order market --coin ETH --side buy --size 0.1 --slippage 0.5 --testnet --dry-run` | -| `hlgo order cancel` | Cancel by OID or CLOID | Required: `--coin` and exactly one of `--oid` or `--cloid`; Optional: `--vault`, `--expires-after` | `hlgo order cancel --coin ETH --oid 12345 --testnet --format json` | -| `hlgo order cancel-all` | Cancel all open orders (optional coin filter) | Optional: `--coin`, `--vault`, `--expires-after` | `hlgo order cancel-all --coin ETH --testnet --format json` | -| `hlgo order modify` | Modify existing order by OID | Required: `--coin`, `--oid`, `--side`, plus at least one of `--price`/`--size`; Optional: `--tif`, `--reduce`, `--vault`, `--expires-after` | `hlgo order modify --coin ETH --oid 12345 --side buy --price 2990 --size 0.1 --testnet --dry-run` | -| `hlgo order batch` | Place multiple orders from JSON file | Required: `--file`; Optional: `--vault`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order batch --file ./orders.json --testnet --dry-run` | +| `hlgo order place` | Place limit order | Required: `--coin`, `--side`, `--price`, `--size`; Optional: `--tif`, `--reduce`, `--cloid`, `--on-behalf-of`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order place --coin ETH --side buy --price 3000 --size 0.1 --tif gtc --testnet --dry-run` | +| `hlgo order market` | Place market IOC via slippage-adjusted mid | Required: `--coin`, `--side`, `--size`; Optional: `--slippage`, `--on-behalf-of`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order market --coin ETH --side buy --size 0.1 --slippage 0.5 --testnet --dry-run` | +| `hlgo order cancel` | Cancel by OID or CLOID | Required: `--coin` and exactly one of `--oid` or `--cloid`; Optional: `--on-behalf-of`, `--expires-after` | `hlgo order cancel --coin ETH --oid 12345 --testnet --format json` | +| `hlgo order cancel-all` | Cancel all open orders (optional coin filter) | Optional: `--coin`, `--on-behalf-of`, `--expires-after` | `hlgo order cancel-all --coin ETH --testnet --format json` | +| `hlgo order modify` | Modify existing order by OID | Required: `--coin`, `--oid`, `--side`, plus at least one of `--price`/`--size`; Optional: `--tif`, `--reduce`, `--on-behalf-of`, `--expires-after` | `hlgo order modify --coin ETH --oid 12345 --side buy --price 2990 --size 0.1 --testnet --dry-run` | +| `hlgo order batch` | Place multiple orders from JSON file | Required: `--file`; Optional: `--on-behalf-of`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order batch --file ./orders.json --testnet --dry-run` | | `hlgo order schedule-cancel` | Set or clear dead-man switch | Exactly one of `--timeout` or `--clear` | `hlgo order schedule-cancel --timeout 15m --testnet --format json` | ## Position Commands | Command | Purpose | Key Flags | Example | |---|---|---|---| -| `hlgo position leverage` | Set leverage + margin mode | Required: `--coin`, `--leverage`; Optional: `--mode`, `--vault` | `hlgo position leverage --coin ETH --leverage 5 --mode cross --testnet --format json` | -| `hlgo position margin` | Adjust isolated margin | Required: `--coin`, `--side`, `--amount`; Optional: `--vault` | `hlgo position margin --coin ETH --side buy --amount 25 --testnet --format json` | +| `hlgo position leverage` | Set leverage + margin mode | Required: `--coin`, `--leverage`; Optional: `--mode`, `--on-behalf-of` | `hlgo position leverage --coin ETH --leverage 5 --mode cross --testnet --format json` | +| `hlgo position margin` | Adjust isolated margin | Required: `--coin`, `--side`, `--amount`; Optional: `--on-behalf-of` | `hlgo position margin --coin ETH --side buy --amount 25 --testnet --format json` | ## Account Commands diff --git a/skill/hlgo/references/contracts-and-safety.md b/skill/hlgo/references/contracts-and-safety.md index 67b3853..96a58b4 100644 --- a/skill/hlgo/references/contracts-and-safety.md +++ b/skill/hlgo/references/contracts-and-safety.md @@ -26,8 +26,8 @@ | Command Group | Wallet | Path | |---|---|---| -| `order`, `position`, `agent bracket` | Agent wallet (`agent_key_env`) | L1 phantom-agent path | -| `account` commands | Master wallet (`master_key_env`) | User-signed path | +| `order`, `position`, `agent bracket` | Agent wallet (`private_key_env`) | L1 phantom-agent path | +| `account` commands | Master wallet (`private_key_env`) | User-signed path | | `info`, `agent snapshot`, `agent pnl`, `config`, `version` | No signing | Read/config path | ## Precision and Serialization Rules From f931ad2e83a427e1b689023f917ab39a0e6afe97 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:20:48 +0000 Subject: [PATCH 2/7] fixes --- AGENTS.md | 4 ++-- cmd/order_test.go | 2 +- cmd/position_test.go | 4 ++-- plans/hlgo-technical-spec-v0.1.0.md | 8 ++++---- skill/hlgo/references/command-reference.md | 2 +- skill/hlgo/references/contracts-and-safety.md | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a3925e1..39b408c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,8 +39,8 @@ - **Decimal string serialization:** JSON output uses string representations — `"95123.5"`, not `95123.5`. No numeric JSON fields for financial values. - **Nonces are millisecond timestamps**, not sequential counters. Use `time.Now().UnixMilli()`. Each nonce is single-use per signer address. - **Two distinct signing paths:** - - L1 (phantom agent): chain ID `1337`, domain `"Exchange"`, msgpack → keccak256 → EIP-712. Agent wallet signs these. - - User-signed: chain ID `421614` (Arbitrum Sepolia, `0x66eee` — hardcoded per Python SDK for all environments), domain `"HyperliquidSignTransaction"`, EIP-712 direct. Master wallet only. + - L1 (phantom agent): chain ID `1337`, domain `"Exchange"`, msgpack → keccak256 → EIP-712. Used by order, position, and agent bracket commands. + - User-signed: chain ID `421614` (Arbitrum Sepolia, `0x66eee` — hardcoded per Python SDK for all environments), domain `"HyperliquidSignTransaction"`, EIP-712 direct. Used by account commands. - Never mix these up. Commands must auto-select the correct signer based on action type. - **Asset ID resolution:** Perp IDs are index-based, spot IDs are `10000 + index`, HIP-3 IDs are `100000 + (dex_index × 10000) + index`. HIP-3 coins use `{dex}:{coin}` format (e.g. `xyz:XYZ100`). - **Tick and lot size validation happens before signing.** Perps: max 6 decimals. Spot: max 8 decimals. Price: max 5 significant figures. Validate early, fail loud. diff --git a/cmd/order_test.go b/cmd/order_test.go index 070c337..2150c4a 100644 --- a/cmd/order_test.go +++ b/cmd/order_test.go @@ -308,7 +308,7 @@ func TestOrderModify_DryRun_PriceOnly(t *testing.T) { } } -func TestOrderModify_InvalidVault(t *testing.T) { +func TestOrderModify_InvalidOnBehalfOf(t *testing.T) { _, _, run := newTestRootWithServer(t, "") err := run("order", "modify", diff --git a/cmd/position_test.go b/cmd/position_test.go index bffaeb5..c82f6c2 100644 --- a/cmd/position_test.go +++ b/cmd/position_test.go @@ -66,7 +66,7 @@ func TestPositionLeverage_ModeCaseInsensitive(t *testing.T) { } } -func TestPositionLeverage_InvalidVault(t *testing.T) { +func TestPositionLeverage_InvalidOnBehalfOf(t *testing.T) { _, _, run := newTestRootWithServer(t, "") err := run("position", "leverage", @@ -117,7 +117,7 @@ func TestPositionMargin_DryRun(t *testing.T) { } } -func TestPositionMargin_InvalidVault(t *testing.T) { +func TestPositionMargin_InvalidOnBehalfOf(t *testing.T) { _, _, run := newTestRootWithServer(t, "") err := run("position", "margin", diff --git a/plans/hlgo-technical-spec-v0.1.0.md b/plans/hlgo-technical-spec-v0.1.0.md index 3e6d5ab..8c18e13 100644 --- a/plans/hlgo-technical-spec-v0.1.0.md +++ b/plans/hlgo-technical-spec-v0.1.0.md @@ -15,7 +15,7 @@ 1. **JSON-first output.** Every command returns valid JSON to stdout by default. Human-readable table format via `--format table`. Errors return JSON to stderr with a consistent schema: `{"error": "", "code": ""}`. 2. **Stateless per invocation.** No daemon, no long-running process. Read config, execute, return, exit. The agent controls the loop. -3. **Dual wallet architecture.** Agent wallet (limited permissions) for all trading. Master wallet for transfers, withdrawals, and agent approval. Config specifies both; commands auto-select the correct signer. +3. **Single key model.** One configured private key for all signing. L1 actions support optional `--on-behalf-of` for delegated account context. Commands auto-select the correct signing path based on action type. 4. **Decimal-everywhere.** All prices, sizes, and financial values use `shopspring/decimal` internally and string representations in JSON. Never `float64`. 5. **Fail loud.** Non-zero exit codes on any error. The agent must be able to distinguish success from failure programmatically. 6. **Testnet-first development.** `--testnet` flag (or `HL_TESTNET=true`) switches all endpoints. Default is mainnet. @@ -53,7 +53,7 @@ Two distinct signing paths, both using EIP-712 typed data: - Chain ID: `1337` - Domain name: `"Exchange"` - Flow: serialize action → msgpack encode → keccak256 hash → construct phantom agent `{source, connectionId}` → EIP-712 sign the phantom agent -- **Agent wallet CAN sign these** (this is the primary path for the agent) +- **Used by order, position, and agent bracket commands** **User-Signed Actions** — used for transfers, withdrawals, agent management: @@ -272,7 +272,7 @@ Update isolated margin. #### `hlgo order schedule-cancel` Dead man’s switch. -### 4.3 Account Commands (Signed, Master Wallet) +### 4.3 Account Commands (Signed, User Path) - `hlgo account transfer` - `hlgo account withdraw` @@ -420,7 +420,7 @@ go install . ## 11. Security Considerations 1. Never log private keys. -2. Agent wallet isolated to L1 trading actions. +2. Signing path auto-selected by action type (L1 for trading, user-signed for account operations). 3. Dangerous operations gated with explicit confirmation and dry-run support. 4. No key material in output. 5. Testnet-first workflow for development. diff --git a/skill/hlgo/references/command-reference.md b/skill/hlgo/references/command-reference.md index 740f1e7..9bd6d69 100644 --- a/skill/hlgo/references/command-reference.md +++ b/skill/hlgo/references/command-reference.md @@ -47,7 +47,7 @@ hlgo info mids --testnet --format json | Command | Purpose | Key Flags | Example | |---|---|---|---| -| `hlgo config init` | Create config file | `--private-key-env`, `--private-key-env`, `--default-dex`, `--metadata-ttl`, `--force` | `hlgo config init --private-key-env HL_PRIVATE_KEY --private-key-env HL_PRIVATE_KEY` | +| `hlgo config init` | Create config file | `--private-key-env`, `--default-dex`, `--metadata-ttl`, `--force` | `hlgo config init --private-key-env HL_PRIVATE_KEY` | | `hlgo config show` | Show resolved config with key redaction | `--config`, `--testnet`, `--format` | `hlgo config show --testnet` | | `hlgo config test` | Validate config readability, key envs, and API connectivity | `--config`, `--testnet` | `hlgo config test --testnet` | diff --git a/skill/hlgo/references/contracts-and-safety.md b/skill/hlgo/references/contracts-and-safety.md index 96a58b4..55b5903 100644 --- a/skill/hlgo/references/contracts-and-safety.md +++ b/skill/hlgo/references/contracts-and-safety.md @@ -26,8 +26,8 @@ | Command Group | Wallet | Path | |---|---|---| -| `order`, `position`, `agent bracket` | Agent wallet (`private_key_env`) | L1 phantom-agent path | -| `account` commands | Master wallet (`private_key_env`) | User-signed path | +| `order`, `position`, `agent bracket` | Configured key (`private_key_env`) | L1 phantom-agent path | +| `account` commands | Configured key (`private_key_env`) | User-signed path | | `info`, `agent snapshot`, `agent pnl`, `config`, `version` | No signing | Read/config path | ## Precision and Serialization Rules From 5d5186d4d96e8c706823c74357c7c243d29071be Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:24:42 +0000 Subject: [PATCH 3/7] update skill --- skill/hlgo/SKILL.md | 1 + skill/hlgo/references/agent-workflows.md | 34 +++++++++++++++++++ skill/hlgo/references/command-reference.md | 2 ++ skill/hlgo/references/contracts-and-safety.md | 18 ++++++++++ 4 files changed, 55 insertions(+) diff --git a/skill/hlgo/SKILL.md b/skill/hlgo/SKILL.md index 3af0949..304df18 100644 --- a/skill/hlgo/SKILL.md +++ b/skill/hlgo/SKILL.md @@ -67,6 +67,7 @@ Pick the wrong command/signing path and requests can fail with `SIGNING_ERROR` o - Use returned `matches[].coin` (for example `tngs:CHARIZARD-TGUSD`) in `order` commands. - **Treat non-zero exit codes as failures; branch on JSON `code`.** Exit codes map to error categories (1=validation, 3=network, 6=rate-limit). The `code` field in stderr JSON is the stable contract. - **Keep `--testnet` enabled during development and simulation.** Testnet is free. Mainnet costs real money. No flag = mainnet. +- **Use `--on-behalf-of` only on L1 commands.** Order, position, and agent bracket commands support delegated account context. Account commands and schedule-cancel reject it. See `contracts-and-safety.md` for the full matrix. - **Never output private key material.** hlgo redacts keys in `config show`. Your scripts must too. ## Progressive Disclosure Map diff --git a/skill/hlgo/references/agent-workflows.md b/skill/hlgo/references/agent-workflows.md index 18d7592..019d281 100644 --- a/skill/hlgo/references/agent-workflows.md +++ b/skill/hlgo/references/agent-workflows.md @@ -99,6 +99,40 @@ Output fields: Partial success: if some substeps fail, returns `partial: true` with an `errors[]` array listing each failed step, its error code, and message. Branch on the `partial` field to decide whether to proceed or abort. +## Delegated Trading (`--on-behalf-of`) + +When the configured key is an approved agent for another account, use `--on-behalf-of` to trade on that account: + +```bash +VAULT="0x1234567890abcdef1234567890abcdef12345678" + +# 1. Check the delegated account's positions +hlgo info state --address "$VAULT" --testnet --format json + +# 2. Set leverage on the delegated account +hlgo position leverage --coin ETH --leverage 3 --mode cross \ + --on-behalf-of "$VAULT" --testnet --format json + +# 3. Dry-run an order on their behalf +hlgo order place --coin ETH --side buy --price 3000 --size 0.1 \ + --on-behalf-of "$VAULT" --dry-run --testnet --format json + +# 4. Place live +hlgo order place --coin ETH --side buy --price 3000 --size 0.1 \ + --on-behalf-of "$VAULT" --testnet --format json + +# 5. Verify — open-orders uses --address for reads +hlgo info open-orders --address "$VAULT" --testnet --format json + +# 6. Cancel all on the delegated account +hlgo order cancel-all --on-behalf-of "$VAULT" --testnet --format json +``` + +Key points: +- Read commands (`info`) use `--address` to query another account. Write commands (`order`, `position`) use `--on-behalf-of` to act on their behalf. +- `cancel-all` and `modify` automatically query open orders from the `--on-behalf-of` address. +- `--on-behalf-of` is **not supported** for `account` commands or `order schedule-cancel`. + ## CLOID Generation Client Order IDs (CLOIDs) are 16-byte random hex strings with `0x` prefix. Each CLOID is single-use per order. diff --git a/skill/hlgo/references/command-reference.md b/skill/hlgo/references/command-reference.md index 9bd6d69..0aaba00 100644 --- a/skill/hlgo/references/command-reference.md +++ b/skill/hlgo/references/command-reference.md @@ -109,6 +109,8 @@ hlgo info mids --testnet --format json | `hlgo account approve-agent` | Approve/revoke agent wallet | Required: `--agent`; Use `--name` to approve or `--revoke --confirm` to revoke | `hlgo account approve-agent --agent 0xabc... --name trader01 --testnet --format json` | | `hlgo account set-abstraction` | Set abstraction mode | Required: `--user`, `--abstraction` (`unifiedAccount`, `portfolioMargin`, `disabled`) | `hlgo account set-abstraction --user 0xabc... --abstraction disabled --testnet --format json` | +> **Note:** Account commands accept `--on-behalf-of` syntactically but reject it at runtime with `VALIDATION_ERROR`. User-signed actions do not support delegated account context. See [contracts-and-safety.md](contracts-and-safety.md#delegated-account-context---on-behalf-of) for details. + ## Order Batch File Shape Use decimal-safe strings for all numeric fields: diff --git a/skill/hlgo/references/contracts-and-safety.md b/skill/hlgo/references/contracts-and-safety.md index 55b5903..4a7fe6f 100644 --- a/skill/hlgo/references/contracts-and-safety.md +++ b/skill/hlgo/references/contracts-and-safety.md @@ -30,6 +30,24 @@ | `account` commands | Configured key (`private_key_env`) | User-signed path | | `info`, `agent snapshot`, `agent pnl`, `config`, `version` | No signing | Read/config path | +## Delegated Account Context (`--on-behalf-of`) + +An approved agent can operate on another account's behalf using `--on-behalf-of
`. + +**Supported** — L1 phantom-agent commands: +- `order place`, `order market`, `order cancel`, `order cancel-all`, `order modify`, `order batch` +- `position leverage`, `position margin` +- `agent bracket` + +**Not supported** — these reject `--on-behalf-of` with `VALIDATION_ERROR`: +- All `account` commands (transfer, withdraw, send-asset, approve-agent, set-abstraction, class-transfer) — these use the user-signed path, which does not support delegation. +- `order schedule-cancel` — the dead man's switch always applies to the signing wallet only. + +**Behaviour when set:** +- The action is signed by the configured private key but executed in the context of the `--on-behalf-of` account. +- `cancel-all` and `modify` also query open orders from the `--on-behalf-of` address (not the signer's address). +- The signer must be an approved agent for the target account, or the exchange will reject the request. + ## Precision and Serialization Rules - Use decimal-safe strings for all prices, sizes, and amounts. From 94faea5f97f4840db714313b4c60c40cd66e23a9 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:07:48 +0000 Subject: [PATCH 4/7] fix: treat on-behalf as account context for L1 actions --- e2e/agent_simulation_test.go | 10 +- e2e/integration_cli_test.go | 206 ++++++++++++++++++++++++++++++++++- pkg/exchange/executor.go | 81 +++++--------- 3 files changed, 235 insertions(+), 62 deletions(-) diff --git a/e2e/agent_simulation_test.go b/e2e/agent_simulation_test.go index 34f6c7d..b0ef332 100644 --- a/e2e/agent_simulation_test.go +++ b/e2e/agent_simulation_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/shopspring/decimal" + "github.com/timbrinded/hlgo/pkg/wire" ) const agentSimulationTimeout = 3 * time.Minute @@ -186,10 +187,11 @@ func restingBracketPrices(t *testing.T, midPrice string) (entry, tp, sl string) t.Fatalf("invalid non-positive ETH mid %s", mid.String()) } - // Keep entry comfortably below market to reduce fill probability during the test. - entryPx := mid.Mul(decimal.NewFromFloat(0.8)).Round(2) - tpPx := entryPx.Mul(decimal.NewFromFloat(1.05)).Round(2) - slPx := entryPx.Mul(decimal.NewFromFloat(0.95)).Round(2) + // Keep entry comfortably below market to reduce fill probability during the test, + // then snap all prices to valid wire-format constraints (5 sig figs). + entryPx := wire.NearestValidPrice(mid.Mul(decimal.NewFromFloat(0.8)), 4, false) + tpPx := wire.NearestValidPrice(entryPx.Mul(decimal.NewFromFloat(1.05)), 4, false) + slPx := wire.NearestValidPrice(entryPx.Mul(decimal.NewFromFloat(0.95)), 4, false) return entryPx.String(), tpPx.String(), slPx.String() } diff --git a/e2e/integration_cli_test.go b/e2e/integration_cli_test.go index 1798e0c..8eaa93c 100644 --- a/e2e/integration_cli_test.go +++ b/e2e/integration_cli_test.go @@ -16,6 +16,8 @@ import ( "strings" "testing" "time" + + ethcrypto "github.com/ethereum/go-ethereum/crypto" ) var integrationBinaryPath string @@ -190,6 +192,61 @@ func ensurePrivateKeyForAccountDryRun(t *testing.T) { } } +func setIntegrationPrivateKey(t *testing.T, privateKey string) { + t.Helper() + t.Setenv("HL_TEST_PRIVATE_KEY", strings.TrimSpace(privateKey)) +} + +func integrationAddressFromPrivateKey(t *testing.T, privateKeyHex string) string { + t.Helper() + + key, err := ethcrypto.HexToECDSA(strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x")) + if err != nil { + t.Fatalf("failed to parse private key: %v", err) + } + return strings.ToLower(ethcrypto.PubkeyToAddress(key.PublicKey).Hex()) +} + +func newIntegrationSignerKey(t *testing.T) (privateKeyHex, address string) { + t.Helper() + + key, err := ethcrypto.GenerateKey() + if err != nil { + t.Fatalf("failed to generate signer key: %v", err) + } + + return "0x" + hex.EncodeToString(ethcrypto.FromECDSA(key)), strings.ToLower(ethcrypto.PubkeyToAddress(key.PublicKey).Hex()) +} + +func restingBtcBidPrice(t *testing.T) string { + t.Helper() + + stdout, stderr, code := runIntegrationHLGOWithRetry(t, "info", "mids") + assertNoSecretLeak(t, stdout, stderr) + requireIntegrationExitCode(t, code, 0, stderr) + + mids := parseJSONObject(t, stdout) + rawMid, ok := mids["BTC"].(string) + if !ok || strings.TrimSpace(rawMid) == "" { + t.Fatalf("expected BTC mid string, got %#v", mids["BTC"]) + } + + mid, err := strconv.ParseFloat(rawMid, 64) + if err != nil { + t.Fatalf("failed to parse BTC mid %q: %v", rawMid, err) + } + if mid <= 0 { + t.Fatalf("BTC mid must be positive, got %f", mid) + } + + // Keep a resting bid comfortably below market to reduce fill probability. + price := int(mid * 0.8) + if price < 1 { + price = 1 + } + return strconv.Itoa(price) +} + func assertNoSecretLeak(t *testing.T, stdout, stderr string) { t.Helper() candidates := []string{ @@ -470,8 +527,153 @@ func TestIntegration_OrderLifecycle(t *testing.T) { _ = parseJSONObject(t, stdout) } +func TestIntegration_OnBehalf_AuthorizedRandomSignerFlow(t *testing.T) { + deployerKey := strings.TrimSpace(os.Getenv("HL_TEST_PRIVATE_KEY")) + if deployerKey == "" { + t.Skip("skipping on-behalf integration: HL_TEST_PRIVATE_KEY is not set") + } + + deployerAddr := integrationAddressFromPrivateKey(t, deployerKey) + agentKey, agentAddr := newIntegrationSignerKey(t) + agentName := fmt.Sprintf("agt%013d", time.Now().UnixNano()%10_000_000_000_000) + cloid := newIntegrationCloid(t) + price := restingBtcBidPrice(t) + + agentApproved := false + orderPlaced := false + defer func() { + // Cleanup should always run as the deployer account. + setIntegrationPrivateKey(t, deployerKey) + if orderPlaced { + _, _, _ = runIntegrationHLGO(t, + "order", "cancel", + "--coin", "BTC", + "--cloid", cloid, + ) + } + if agentApproved { + _, _, _ = runIntegrationHLGO(t, + "account", "approve-agent", + "--agent", agentAddr, + "--revoke", + "--confirm", + ) + } + }() + + t.Log("step 1: authorize random signer as agent from deployer account") + setIntegrationPrivateKey(t, deployerKey) + stdout, stderr, code := runIntegrationHLGOWithRetry(t, + "account", "approve-agent", + "--agent", agentAddr, + "--name", agentName, + ) + assertNoSecretLeak(t, stdout, stderr) + if code != 0 && strings.Contains(stderr, "does not exist") { + t.Skipf("skipping on-behalf integration: deployer account unavailable: %s", strings.TrimSpace(stderr)) + } + requireIntegrationExitCode(t, code, 0, stderr) + _ = parseJSONObject(t, stdout) + agentApproved = true + + t.Log("step 2: place order with random signer on behalf of deployer account") + setIntegrationPrivateKey(t, agentKey) + for attempt := 1; attempt <= 6; attempt++ { + stdout, stderr, code = runIntegrationHLGO(t, + "order", "place", + "--coin", "BTC", + "--side", "buy", + "--price", price, + "--size", "0.001", + "--cloid", cloid, + "--on-behalf-of", deployerAddr, + ) + assertNoSecretLeak(t, stdout, stderr) + if code == 0 { + break + } + + // Approvals may take a moment to become usable in live environments. + if strings.Contains(stderr, "does not exist") || strings.Contains(strings.ToLower(stderr), "not approved") { + if attempt < 6 { + time.Sleep(500 * time.Millisecond) + continue + } + } + + if isTransientIntegrationError(code, stderr) && attempt < 6 { + time.Sleep(500 * time.Millisecond) + continue + } + break + } + if code != 0 && strings.Contains(stderr, "does not exist") { + t.Skipf("skipping on-behalf integration: account/approval not ready: %s", strings.TrimSpace(stderr)) + } + requireIntegrationExitCode(t, code, 0, stderr) + _ = parseJSONObject(t, stdout) + orderPlaced = true + + t.Log("step 3: verify on-behalf order appears in deployer open-orders") + found := false + for range 8 { + stdout, stderr, code = runIntegrationHLGOWithRetry(t, + "info", "open-orders", + "--address", deployerAddr, + ) + assertNoSecretLeak(t, stdout, stderr) + requireIntegrationExitCode(t, code, 0, stderr) + orders := parseJSONArray(t, stdout) + if _, ok := findOpenOrderByCloid(orders, cloid); ok { + found = true + break + } + time.Sleep(300 * time.Millisecond) + } + if !found { + t.Fatalf("expected on-behalf order with cloid %q in deployer open-orders", cloid) + } + + t.Log("step 4: verify unsupported on-behalf account action is rejected") + stdout, stderr, code = runIntegrationHLGO(t, + "account", "transfer", + "--amount", "1", + "--to-perp", + "--on-behalf-of", deployerAddr, + "--dry-run", + ) + assertNoSecretLeak(t, stdout, stderr) + requireIntegrationExitCode(t, code, 1, stderr) + errObj := requireErrorCode(t, stderr, "VALIDATION_ERROR") + msg, ok := errObj["error"].(string) + if !ok { + t.Fatalf("missing error message: %#v", errObj["error"]) + } + if !strings.Contains(msg, "on-behalf-of is not supported for user-signed actions") { + t.Fatalf("unexpected error message: %q", msg) + } + + t.Log("step 5: verify schedule-cancel rejects on-behalf context") + stdout, stderr, code = runIntegrationHLGO(t, + "order", "schedule-cancel", + "--timeout", "5m", + "--on-behalf-of", deployerAddr, + "--dry-run", + ) + assertNoSecretLeak(t, stdout, stderr) + requireIntegrationExitCode(t, code, 1, stderr) + errObj = requireErrorCode(t, stderr, "VALIDATION_ERROR") + msg, ok = errObj["error"].(string) + if !ok { + t.Fatalf("missing error message: %#v", errObj["error"]) + } + if !strings.Contains(msg, "on-behalf-of is not supported for schedule-cancel") { + t.Fatalf("unexpected error message: %q", msg) + } +} + func TestIntegration_AccountDryRunPayloadContracts(t *testing.T) { - ensureMasterKeyForAccountDryRun(t) + ensurePrivateKeyForAccountDryRun(t) const mixedAddr = "0xABABABABABABABABABABABABABABABABABABABAB" const lowerAddr = "0xabababababababababababababababababababab" @@ -614,7 +816,7 @@ func TestIntegration_AccountDryRunPayloadContracts(t *testing.T) { } func TestIntegration_AccountValidationContracts(t *testing.T) { - ensureMasterKeyForAccountDryRun(t) + ensurePrivateKeyForAccountDryRun(t) const validAddr = "0x1111111111111111111111111111111111111111" tests := []struct { diff --git a/pkg/exchange/executor.go b/pkg/exchange/executor.go index 810401e..1a908e9 100644 --- a/pkg/exchange/executor.go +++ b/pkg/exchange/executor.go @@ -446,19 +446,15 @@ func (e *Executor) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Plac // 7. Generate nonce and sign. nonce := time.Now().UnixMilli() - var onBehalfOf *common.Address - if input.OnBehalfOf != "" { - a := common.HexToAddress(input.OnBehalfOf) - onBehalfOf = &a - } - - sig, err := e.signer.SignL1Action(action, nonce, onBehalfOf, input.ExpiresAfter, e.mainnet) + // On-behalf trading uses the agent signer identity; it is not encoded as + // vaultAddress in the L1 action hash/payload. + sig, err := e.signer.SignL1Action(action, nonce, nil, input.ExpiresAfter, e.mainnet) if err != nil { return nil, err } // 8. Send to exchange. - resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.OnBehalfOf, input.ExpiresAfter) + resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", input.ExpiresAfter) if err != nil { return nil, err } @@ -472,6 +468,7 @@ func (e *Executor) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Plac // CancelOrders cancels orders by OID. func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, onBehalfOf string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { + _ = onBehalfOf // validated at command layer; kept for interface parity and account-context flows. action := BuildCancelAction(cancels) if dryRun { @@ -480,22 +477,18 @@ func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, onBeh nonce := time.Now().UnixMilli() - var onBehalfOfAddr *common.Address - if onBehalfOf != "" { - a := common.HexToAddress(onBehalfOf) - onBehalfOfAddr = &a - } - - sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, expiresAfter, e.mainnet) + // On-behalf trading context is handled by agent authorization, not vaultAddress. + sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), onBehalfOf, expiresAfter) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) } // CancelByCloid cancels orders by client order ID. func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWire, onBehalfOf string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { + _ = onBehalfOf // validated at command layer; kept for interface parity and account-context flows. action := BuildCancelByCloidAction(cancels) if dryRun { @@ -504,18 +497,13 @@ func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWir nonce := time.Now().UnixMilli() - var onBehalfOfAddr *common.Address - if onBehalfOf != "" { - a := common.HexToAddress(onBehalfOf) - onBehalfOfAddr = &a - } - - sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, expiresAfter, e.mainnet) + // On-behalf trading context is handled by agent authorization, not vaultAddress. + sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), onBehalfOf, expiresAfter) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) } // UpdateLeverage sets leverage and margin mode for a coin. @@ -538,18 +526,13 @@ func (e *Executor) UpdateLeverage(ctx context.Context, input UpdateLeverageInput nonce := time.Now().UnixMilli() - var onBehalfOfAddr *common.Address - if input.OnBehalfOf != "" { - a := common.HexToAddress(input.OnBehalfOf) - onBehalfOfAddr = &a - } - - sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, nil, e.mainnet) + // On-behalf trading context is handled by agent authorization, not vaultAddress. + sig, err := e.signer.SignL1Action(action, nonce, nil, nil, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.OnBehalfOf, nil) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", nil) } // UpdateIsolatedMargin adjusts isolated margin for a position. @@ -579,18 +562,13 @@ func (e *Executor) UpdateIsolatedMargin(ctx context.Context, input UpdateIsolate nonce := time.Now().UnixMilli() - var onBehalfOfAddr *common.Address - if input.OnBehalfOf != "" { - a := common.HexToAddress(input.OnBehalfOf) - onBehalfOfAddr = &a - } - - sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, nil, e.mainnet) + // On-behalf trading context is handled by agent authorization, not vaultAddress. + sig, err := e.signer.SignL1Action(action, nonce, nil, nil, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.OnBehalfOf, nil) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", nil) } // ModifyOrder modifies an existing order. @@ -646,18 +624,13 @@ func (e *Executor) ModifyOrder(ctx context.Context, input ModifyOrderInput) (*Mo nonce := time.Now().UnixMilli() - var onBehalfOfAddr *common.Address - if input.OnBehalfOf != "" { - a := common.HexToAddress(input.OnBehalfOf) - onBehalfOfAddr = &a - } - - sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, input.ExpiresAfter, e.mainnet) + // On-behalf trading context is handled by agent authorization, not vaultAddress. + sig, err := e.signer.SignL1Action(action, nonce, nil, input.ExpiresAfter, e.mainnet) if err != nil { return nil, err } - resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), input.OnBehalfOf, input.ExpiresAfter) + resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", input.ExpiresAfter) if err != nil { return nil, err } @@ -670,20 +643,16 @@ func (e *Executor) ModifyOrder(ctx context.Context, input ModifyOrderInput) (*Mo // PlaceBatchOrders signs and sends a pre-built OrderAction for batch order placement. func (e *Executor) PlaceBatchOrders(ctx context.Context, action *OrderAction, onBehalfOf string, expiresAfter *int64) (json.RawMessage, error) { + _ = onBehalfOf // validated at command layer; kept for interface parity and account-context flows. nonce := time.Now().UnixMilli() - var onBehalfOfAddr *common.Address - if onBehalfOf != "" { - a := common.HexToAddress(onBehalfOf) - onBehalfOfAddr = &a - } - - sig, err := e.signer.SignL1Action(action, nonce, onBehalfOfAddr, expiresAfter, e.mainnet) + // On-behalf trading context is handled by agent authorization, not vaultAddress. + sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) if err != nil { return nil, err } - return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), onBehalfOf, expiresAfter) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) } // ScheduleCancel sets or clears the dead man's switch for order cancellation. From 65202c9ba0e20b00c62b4d757e8bc49f71c6d2fe Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:56:06 +0000 Subject: [PATCH 5/7] impl account address --- cmd/account_approve_agent.go | 17 ++-- cmd/account_class_transfer.go | 20 ++--- cmd/account_send_asset.go | 7 -- cmd/account_set_abstraction.go | 7 -- cmd/account_transfer.go | 20 ++--- cmd/account_withdraw.go | 7 -- cmd/agent_bracket.go | 8 -- cmd/config.go | 36 ++++++--- cmd/config_test.go | 25 +++++- cmd/info_test.go | 41 ++++++++++ cmd/order_batch.go | 9 +-- cmd/order_cancel.go | 13 +-- cmd/order_market.go | 8 -- cmd/order_modify.go | 1 - cmd/order_place.go | 8 -- cmd/order_schedule_cancel.go | 16 +--- cmd/position_leverage.go | 23 ++---- cmd/position_margin.go | 23 ++---- e2e/integration_cli_test.go | 13 ++- pkg/config/config.go | 8 +- pkg/config/config_test.go | 6 +- pkg/exchange/executor.go | 79 +++++-------------- pkg/exchange/executor_test.go | 4 +- pkg/info/address.go | 15 +++- pkg/info/address_test.go | 34 ++++++++ skill/hlgo/SKILL.md | 2 +- skill/hlgo/references/agent-workflows.md | 35 ++++---- skill/hlgo/references/command-reference.md | 16 ++-- skill/hlgo/references/contracts-and-safety.md | 22 +++--- 29 files changed, 253 insertions(+), 270 deletions(-) diff --git a/cmd/account_approve_agent.go b/cmd/account_approve_agent.go index 3963596..10e5bd2 100644 --- a/cmd/account_approve_agent.go +++ b/cmd/account_approve_agent.go @@ -20,21 +20,16 @@ func newAccountApproveAgentCmd() *cobra.Command { Note: Hyperliquid may charge an activation fee when first approving an agent.`, RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - agent, _ := cmd.Flags().GetString("agent") //nolint:errcheck // known flag - name, _ := cmd.Flags().GetString("name") //nolint:errcheck // known flag - revoke, _ := cmd.Flags().GetBool("revoke") //nolint:errcheck // known flag - confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag - yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag + agent, _ := cmd.Flags().GetString("agent") //nolint:errcheck // known flag + name, _ := cmd.Flags().GetString("name") //nolint:errcheck // known flag + revoke, _ := cmd.Flags().GetBool("revoke") //nolint:errcheck // known flag + confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag + yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag if !common.IsHexAddress(agent) { return output.NewCLIError(output.ErrValidation, "invalid agent address"). WithDetails("agent", agent) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } agentName := strings.TrimSpace(name) switch { @@ -60,7 +55,6 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, raw, err := exec.ApproveAgent(cmd.Context(), exchange.ApproveAgentInput{ AgentAddress: strings.ToLower(agent), AgentName: agentName, - OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -73,7 +67,6 @@ Note: Hyperliquid may charge an activation fee when first approving an agent.`, cmd.Flags().String("agent", "", "agent wallet address") cmd.Flags().String("name", "", "optional agent label") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().Bool("revoke", false, "revoke agent by setting an empty label") cmd.Flags().Bool("confirm", false, "confirm execution for revoke flow") cmd.Flags().Bool("yes", false, "alias for --confirm") diff --git a/cmd/account_class_transfer.go b/cmd/account_class_transfer.go index 4bf6d9e..8f5ed15 100644 --- a/cmd/account_class_transfer.go +++ b/cmd/account_class_transfer.go @@ -1,7 +1,6 @@ package cmd import ( - "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/spf13/cobra" @@ -16,10 +15,9 @@ func newAccountClassTransferCmd() *cobra.Command { Short: "Alias of transfer using usdClassTransfer semantics", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag - toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag + amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag + toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag + toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag if toPerp == toSpot { return output.NewCLIError(output.ErrValidation, "exactly one of --to-perp or --to-spot is required") @@ -30,10 +28,6 @@ func newAccountClassTransferCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "invalid amount"). WithDetails("value", amountStr) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } exec, err := buildExecutor(cfg) if err != nil { @@ -41,10 +35,9 @@ func newAccountClassTransferCmd() *cobra.Command { } raw, err := exec.USDClassTransfer(cmd.Context(), exchange.USDClassTransferInput{ - Amount: amount, - ToPerp: toPerp, - OnBehalfOf: onBehalfOf, - DryRun: cfg.DryRun, + Amount: amount, + ToPerp: toPerp, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -57,7 +50,6 @@ func newAccountClassTransferCmd() *cobra.Command { cmd.Flags().String("amount", "", "transfer amount") cmd.Flags().Bool("to-perp", false, "transfer toward perp class") cmd.Flags().Bool("to-spot", false, "transfer toward spot class") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") //nolint:errcheck // MarkFlagRequired on known flags never fails cmd.MarkFlagRequired("amount") diff --git a/cmd/account_send_asset.go b/cmd/account_send_asset.go index 707188b..57739a0 100644 --- a/cmd/account_send_asset.go +++ b/cmd/account_send_asset.go @@ -23,7 +23,6 @@ func newAccountSendAssetCmd() *cobra.Command { amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if err := requireConfirm("send-asset", confirm || yes, cfg.DryRun); err != nil { return err @@ -33,10 +32,6 @@ func newAccountSendAssetCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "invalid destination address"). WithDetails("destination", destination) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } if strings.TrimSpace(token) == "" { return output.NewCLIError(output.ErrValidation, "token is required") } @@ -56,7 +51,6 @@ func newAccountSendAssetCmd() *cobra.Command { Destination: strings.ToLower(destination), Token: token, Amount: amount, - OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -70,7 +64,6 @@ func newAccountSendAssetCmd() *cobra.Command { cmd.Flags().String("destination", "", "destination EVM address") cmd.Flags().String("token", "", "spot token identifier (e.g. PURR:0x1)") cmd.Flags().String("amount", "", "token amount") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().Bool("confirm", false, "confirm execution for asset send") cmd.Flags().Bool("yes", false, "alias for --confirm") diff --git a/cmd/account_set_abstraction.go b/cmd/account_set_abstraction.go index cd87dc3..367b02e 100644 --- a/cmd/account_set_abstraction.go +++ b/cmd/account_set_abstraction.go @@ -25,16 +25,11 @@ func newAccountSetAbstractionCmd() *cobra.Command { cfg := config.FromContext(cmd.Context()) user, _ := cmd.Flags().GetString("user") //nolint:errcheck // known flag abstraction, _ := cmd.Flags().GetString("abstraction") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if !common.IsHexAddress(user) { return output.NewCLIError(output.ErrValidation, "invalid user address"). WithDetails("user", user) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } abstraction = strings.TrimSpace(abstraction) if abstraction == "" { return output.NewCLIError(output.ErrValidation, "abstraction is required") @@ -53,7 +48,6 @@ func newAccountSetAbstractionCmd() *cobra.Command { raw, err := exec.UserSetAbstraction(cmd.Context(), exchange.UserSetAbstractionInput{ User: strings.ToLower(user), Abstraction: abstraction, - OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -66,7 +60,6 @@ func newAccountSetAbstractionCmd() *cobra.Command { cmd.Flags().String("user", "", "user address") cmd.Flags().String("abstraction", "", "abstraction string") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") for _, required := range []string{"user", "abstraction"} { //nolint:errcheck // MarkFlagRequired on known flags never fails diff --git a/cmd/account_transfer.go b/cmd/account_transfer.go index 6842439..873882c 100644 --- a/cmd/account_transfer.go +++ b/cmd/account_transfer.go @@ -1,7 +1,6 @@ package cmd import ( - "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/spf13/cobra" @@ -16,10 +15,9 @@ func newAccountTransferCmd() *cobra.Command { Short: "Transfer USDC between spot and perp classes", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag - toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag + amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag + toPerp, _ := cmd.Flags().GetBool("to-perp") //nolint:errcheck // known flag + toSpot, _ := cmd.Flags().GetBool("to-spot") //nolint:errcheck // known flag if toPerp == toSpot { return output.NewCLIError(output.ErrValidation, "exactly one of --to-perp or --to-spot is required") @@ -30,10 +28,6 @@ func newAccountTransferCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "invalid amount"). WithDetails("value", amountStr) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } exec, err := buildExecutor(cfg) if err != nil { @@ -41,10 +35,9 @@ func newAccountTransferCmd() *cobra.Command { } raw, err := exec.USDClassTransfer(cmd.Context(), exchange.USDClassTransferInput{ - Amount: amount, - ToPerp: toPerp, - OnBehalfOf: onBehalfOf, - DryRun: cfg.DryRun, + Amount: amount, + ToPerp: toPerp, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -57,7 +50,6 @@ func newAccountTransferCmd() *cobra.Command { cmd.Flags().String("amount", "", "USDC amount") cmd.Flags().Bool("to-perp", false, "transfer from spot to perp") cmd.Flags().Bool("to-spot", false, "transfer from perp to spot") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") //nolint:errcheck // MarkFlagRequired on known flags never fails cmd.MarkFlagRequired("amount") diff --git a/cmd/account_withdraw.go b/cmd/account_withdraw.go index f803399..9ad826c 100644 --- a/cmd/account_withdraw.go +++ b/cmd/account_withdraw.go @@ -22,7 +22,6 @@ func newAccountWithdrawCmd() *cobra.Command { amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag confirm, _ := cmd.Flags().GetBool("confirm") //nolint:errcheck // known flag yes, _ := cmd.Flags().GetBool("yes") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag if err := requireConfirm("withdraw", confirm || yes, cfg.DryRun); err != nil { return err @@ -32,10 +31,6 @@ func newAccountWithdrawCmd() *cobra.Command { return output.NewCLIError(output.ErrValidation, "invalid destination address"). WithDetails("destination", destination) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } amount, err := decimal.NewFromString(amountStr) if err != nil { @@ -51,7 +46,6 @@ func newAccountWithdrawCmd() *cobra.Command { raw, err := exec.Withdraw3(cmd.Context(), exchange.Withdraw3Input{ Destination: strings.ToLower(destination), Amount: amount, - OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -64,7 +58,6 @@ func newAccountWithdrawCmd() *cobra.Command { cmd.Flags().String("destination", "", "destination EVM address") cmd.Flags().String("amount", "", "USDC amount") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().Bool("confirm", false, "confirm execution for withdrawal") cmd.Flags().Bool("yes", false, "alias for --confirm") diff --git a/cmd/agent_bracket.go b/cmd/agent_bracket.go index 4cd4413..e40b8ef 100644 --- a/cmd/agent_bracket.go +++ b/cmd/agent_bracket.go @@ -26,7 +26,6 @@ func newAgentBracketCmd() *cobra.Command { slStr, _ := cmd.Flags().GetString("sl") //nolint:errcheck // known flag tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag @@ -94,11 +93,6 @@ func newAgentBracketCmd() *cobra.Command { cloid = &cloidStr } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - changedBuilder := cmd.Flags().Changed("builder") changedBuilderFee := cmd.Flags().Changed("builder-fee-tenths-bp") if changedBuilder != changedBuilderFee { @@ -151,7 +145,6 @@ func newAgentBracketCmd() *cobra.Command { SlTrigger: &slTrigger, Builder: builder, ExpiresAfter: expiresAfter, - OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -170,7 +163,6 @@ func newAgentBracketCmd() *cobra.Command { cmd.Flags().String("sl", "", "stop-loss trigger price") cmd.Flags().String("tif", "gtc", "time in force: gtc, ioc, alo") cmd.Flags().String("cloid", "", "client order ID") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("builder", "", "builder address for optional builder fee routing") cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") diff --git a/cmd/config.go b/cmd/config.go index 3b840f2..8f67791 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" "github.com/spf13/viper" "go.yaml.in/yaml/v3" @@ -40,9 +41,10 @@ stored at ~/.hlgo/config.yaml by default.`, // configFileData is the structure written to the YAML config file. // Fields mirror the persisted fields in config.Config — keep in sync. type configFileData struct { - PrivateKeyEnv string `yaml:"private_key_env"` - DefaultDex string `yaml:"default_dex"` - MetadataTTL int `yaml:"metadata_ttl"` + PrivateKeyEnv string `yaml:"private_key_env"` + AccountAddress string `yaml:"account_address"` + DefaultDex string `yaml:"default_dex"` + MetadataTTL int `yaml:"metadata_ttl"` } // resolveConfigPath expands the default sentinel path to an absolute path. @@ -59,10 +61,11 @@ func resolveConfigPath(flagValue string) (string, error) { func newConfigInitCmd() *cobra.Command { var ( - privateKeyEnv string - defaultDex string - metadataTTL int - force bool + privateKeyEnv string + accountAddress string + defaultDex string + metadataTTL int + force bool ) cmd := &cobra.Command{ @@ -85,9 +88,10 @@ an existing config unless --force is passed.`, } data := configFileData{ - PrivateKeyEnv: privateKeyEnv, - DefaultDex: defaultDex, - MetadataTTL: metadataTTL, + PrivateKeyEnv: privateKeyEnv, + AccountAddress: accountAddress, + DefaultDex: defaultDex, + MetadataTTL: metadataTTL, } out, err := yaml.Marshal(data) @@ -119,10 +123,21 @@ an existing config unless --force is passed.`, } cmd.Flags().StringVar(&privateKeyEnv, "private-key-env", "HL_PRIVATE_KEY", "env var name for private key") + cmd.Flags().StringVar(&accountAddress, "account-address", "", "default account address for reads and account-context commands") cmd.Flags().StringVar(&defaultDex, "default-dex", "", "default HIP-3 dex name") cmd.Flags().IntVar(&metadataTTL, "metadata-ttl", 300, "metadata cache TTL in seconds") cmd.Flags().BoolVar(&force, "force", false, "overwrite existing config file") + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if accountAddress == "" { + return nil + } + if !common.IsHexAddress(accountAddress) { + return fmt.Errorf("invalid --account-address: must be 0x-prefixed 40-hex address") + } + return nil + } + return cmd } @@ -144,6 +159,7 @@ func newConfigShowCmd() *cobra.Command { "config_file": v.ConfigFileUsed(), "private_key_env": cfg.PrivateKeyEnv, "private_key_set": privateKeyVal != "", + "account_address": cfg.AccountAddress, "testnet": cfg.Testnet, "format": cfg.Format, "dex": cfg.Dex, diff --git a/cmd/config_test.go b/cmd/config_test.go index fe06a5e..9e639b3 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -89,6 +89,7 @@ func TestConfigInit_CustomValues(t *testing.T) { "config", "init", "--config", cfgPath, "--private-key-env", "CUSTOM_KEY", + "--account-address", "0x1111111111111111111111111111111111111111", "--default-dex", "xyz", "--metadata-ttl", "60", }) @@ -102,6 +103,9 @@ func TestConfigInit_CustomValues(t *testing.T) { if !strings.Contains(content, "CUSTOM_KEY") { t.Error("custom private key env not written") } + if !strings.Contains(content, "account_address: 0x1111111111111111111111111111111111111111") { + t.Error("account_address not written") + } } func TestConfigInit_WarnsOnMissingEnv(t *testing.T) { @@ -124,7 +128,7 @@ func TestConfigInit_WarnsOnMissingEnv(t *testing.T) { func TestConfigShow_Output(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - os.WriteFile(cfgPath, []byte("private_key_env: TEST_KEY\nmetadata_ttl: 120\n"), 0600) + os.WriteFile(cfgPath, []byte("private_key_env: TEST_KEY\naccount_address: 0x1111111111111111111111111111111111111111\nmetadata_ttl: 120\n"), 0600) t.Setenv("TEST_KEY", "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") root := NewRootCommand(BuildInfo{Version: "test"}) @@ -144,6 +148,9 @@ func TestConfigShow_Output(t *testing.T) { if out["private_key_set"] != true { t.Error("private_key_set should be true") } + if out["account_address"] != "0x1111111111111111111111111111111111111111" { + t.Errorf("account_address = %v, want configured address", out["account_address"]) + } preview, _ := out["private_key_preview"].(string) if !strings.Contains(preview, "...") { @@ -243,3 +250,19 @@ func TestConfigTest_MissingEnvVar(t *testing.T) { t.Fatal("expected error when private key env var is not set") } } + +func TestConfigInit_InvalidAccountAddress(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + + root := NewRootCommand(BuildInfo{Version: "test"}) + root.SetArgs([]string{ + "config", "init", + "--config", cfgPath, + "--account-address", "not-an-address", + }) + + if err := root.Execute(); err == nil { + t.Fatal("expected error for invalid --account-address") + } +} diff --git a/cmd/info_test.go b/cmd/info_test.go index b4ef862..8afb7f8 100644 --- a/cmd/info_test.go +++ b/cmd/info_test.go @@ -225,6 +225,47 @@ func TestInfoState_DryRun(t *testing.T) { } } +func TestInfoState_UsesConfiguredAccountAddress(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("private_key_env: TEST_HL_PRIVATE_KEY\naccount_address: 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nmetadata_ttl: 300\n"), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("HOME", dir) + t.Setenv("TEST_HL_PRIVATE_KEY", "0x0123456789012345678901234567890123456789012345678901234567890123") + for _, network := range []string{"mainnet", "testnet"} { + cacheDir := filepath.Join(dir, ".hlgo", "cache", network) + if err := os.MkdirAll(cacheDir, 0700); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + now := `"2099-01-01T00:00:00Z"` + _ = os.WriteFile(filepath.Join(cacheDir, "meta.json"), + fmt.Appendf(nil, `{"timestamp":%s,"data":%s}`, now, testMetaJSON), 0600) + _ = os.WriteFile(filepath.Join(cacheDir, "spot_meta.json"), + fmt.Appendf(nil, `{"timestamp":%s,"data":%s}`, now, testSpotMetaJSON), 0600) + } + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + root := NewRootCommand(BuildInfo{Version: "test"}) + root.SetOut(stdout) + root.SetErr(stderr) + root.SetArgs([]string{"--config", cfgPath, "info", "state", "--dry-run"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v, stderr=%s", err, stderr.String()) + } + + var req map[string]string + if err := json.Unmarshal(stdout.Bytes(), &req); err != nil { + t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String()) + } + if req["user"] != "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" { + t.Errorf("user = %q, want configured account_address", req["user"]) + } +} + func TestInfoState_ExplicitAddress(t *testing.T) { stdout, _, run := newTestRootWithServer(t, "") diff --git a/cmd/order_batch.go b/cmd/order_batch.go index 7e4d8a1..6c3945f 100644 --- a/cmd/order_batch.go +++ b/cmd/order_batch.go @@ -35,7 +35,6 @@ func newOrderBatchCmd() *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) filePath, _ := cmd.Flags().GetString("file") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag @@ -59,11 +58,6 @@ func newOrderBatchCmd() *cobra.Command { WithDetails("path", filePath) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - changedBuilder := cmd.Flags().Changed("builder") changedBuilderFee := cmd.Flags().Changed("builder-fee-tenths-bp") if changedBuilder != changedBuilderFee { @@ -169,7 +163,7 @@ func newOrderBatchCmd() *cobra.Command { return err } - result, err := exec.PlaceBatchOrders(cmd.Context(), action, onBehalfOf, expiresAfter) + result, err := exec.PlaceBatchOrders(cmd.Context(), action, expiresAfter) if err != nil { return err } @@ -179,7 +173,6 @@ func newOrderBatchCmd() *cobra.Command { } cmd.Flags().String("file", "", "path to JSON batch file") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("builder", "", "builder address for optional builder fee routing") cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") diff --git a/cmd/order_cancel.go b/cmd/order_cancel.go index ab1b361..ac46d53 100644 --- a/cmd/order_cancel.go +++ b/cmd/order_cancel.go @@ -23,7 +23,6 @@ func newOrderCancelCmd() *cobra.Command { coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag oidStr, _ := cmd.Flags().GetString("oid") //nolint:errcheck // known flag cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag // Mutual exclusion: exactly one of --oid or --cloid. @@ -39,11 +38,6 @@ func newOrderCancelCmd() *cobra.Command { return err } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - var expiresAfter *int64 if expiresAfterStr != "" { ms, err := parseTimeFlag(expiresAfterStr) @@ -65,7 +59,7 @@ func newOrderCancelCmd() *cobra.Command { result, err := exec.CancelByCloid(cmd.Context(), []exchange.CancelByCloidWire{ {Asset: assetID, Cloid: cloidStr}, - }, onBehalfOf, cfg.DryRun, expiresAfter) + }, cfg.DryRun, expiresAfter) if err != nil { return err } @@ -86,7 +80,7 @@ func newOrderCancelCmd() *cobra.Command { result, err := exec.CancelOrders(cmd.Context(), []exchange.CancelWire{ {A: assetID, O: oid}, - }, onBehalfOf, cfg.DryRun, expiresAfter) + }, cfg.DryRun, expiresAfter) if err != nil { return err } @@ -97,7 +91,6 @@ func newOrderCancelCmd() *cobra.Command { cmd.Flags().String("coin", "", "coin name (required for asset ID resolution)") cmd.Flags().String("oid", "", "order ID to cancel") cmd.Flags().String("cloid", "", "client order ID to cancel") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") //nolint:errcheck // MarkFlagRequired on known flags never fails @@ -190,7 +183,7 @@ func newOrderCancelAllCmd() *cobra.Command { }), nil) } - result, err := exec.CancelOrders(cmd.Context(), cancels, onBehalfOf, cfg.DryRun, expiresAfter) + result, err := exec.CancelOrders(cmd.Context(), cancels, cfg.DryRun, expiresAfter) if err != nil { return err } diff --git a/cmd/order_market.go b/cmd/order_market.go index 11b7087..fc43579 100644 --- a/cmd/order_market.go +++ b/cmd/order_market.go @@ -24,7 +24,6 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag sizeStr, _ := cmd.Flags().GetString("size") //nolint:errcheck // known flag slippageStr, _ := cmd.Flags().GetString("slippage") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag @@ -35,11 +34,6 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri WithDetails("value", side) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - changedBuilder := cmd.Flags().Changed("builder") changedBuilderFee := cmd.Flags().Changed("builder-fee-tenths-bp") if changedBuilder != changedBuilderFee { @@ -106,7 +100,6 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri SlippagePercent: slippageDecimal, Builder: builder, ExpiresAfter: expiresAfter, - OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -121,7 +114,6 @@ The order is placed as an IOC (immediate-or-cancel) at the slippage-adjusted pri cmd.Flags().String("side", "", "buy or sell") cmd.Flags().String("size", "", "order size") cmd.Flags().String("slippage", "0.5", "slippage percentage (default 0.5%)") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("builder", "", "builder address for optional builder fee routing") cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") diff --git a/cmd/order_modify.go b/cmd/order_modify.go index 0181d5b..c2ea09c 100644 --- a/cmd/order_modify.go +++ b/cmd/order_modify.go @@ -110,7 +110,6 @@ func newOrderModifyCmd() *cobra.Command { Tif: wireTif, ReduceOnly: reduce, ExpiresAfter: expiresAfter, - OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { diff --git a/cmd/order_place.go b/cmd/order_place.go index 1e4b97b..1822f6c 100644 --- a/cmd/order_place.go +++ b/cmd/order_place.go @@ -37,7 +37,6 @@ func newOrderPlaceCmd() *cobra.Command { tifFlag, _ := cmd.Flags().GetString("tif") //nolint:errcheck // known flag reduce, _ := cmd.Flags().GetBool("reduce") //nolint:errcheck // known flag cloidStr, _ := cmd.Flags().GetString("cloid") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag builderAddr, _ := cmd.Flags().GetString("builder") //nolint:errcheck // known flag builderFeeTenthsBp, _ := cmd.Flags().GetInt("builder-fee-tenths-bp") //nolint:errcheck // known flag expiresAfterStr, _ := cmd.Flags().GetString("expires-after") //nolint:errcheck // known flag @@ -74,11 +73,6 @@ func newOrderPlaceCmd() *cobra.Command { cloid = &cloidStr } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - changedBuilder := cmd.Flags().Changed("builder") changedBuilderFee := cmd.Flags().Changed("builder-fee-tenths-bp") if changedBuilder != changedBuilderFee { @@ -128,7 +122,6 @@ func newOrderPlaceCmd() *cobra.Command { Cloid: cloid, Builder: builder, ExpiresAfter: expiresAfter, - OnBehalfOf: onBehalfOf, DryRun: cfg.DryRun, }) if err != nil { @@ -146,7 +139,6 @@ func newOrderPlaceCmd() *cobra.Command { cmd.Flags().String("tif", "gtc", "time in force: gtc, ioc, alo") cmd.Flags().Bool("reduce", false, "reduce-only order") cmd.Flags().String("cloid", "", "client order ID") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") cmd.Flags().String("builder", "", "builder address for optional builder fee routing") cmd.Flags().Int("builder-fee-tenths-bp", 0, "builder fee in tenths of a basis point (requires --builder)") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") diff --git a/cmd/order_schedule_cancel.go b/cmd/order_schedule_cancel.go index 98b6b57..8654733 100644 --- a/cmd/order_schedule_cancel.go +++ b/cmd/order_schedule_cancel.go @@ -3,7 +3,6 @@ package cmd import ( "time" - "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" @@ -19,9 +18,8 @@ func newOrderScheduleCancelCmd() *cobra.Command { or clear an existing schedule. Exactly one of --timeout or --clear must be provided.`, RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - timeoutStr, _ := cmd.Flags().GetString("timeout") //nolint:errcheck // known flag - clear, _ := cmd.Flags().GetBool("clear") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag + timeoutStr, _ := cmd.Flags().GetString("timeout") //nolint:errcheck // known flag + clear, _ := cmd.Flags().GetBool("clear") //nolint:errcheck // known flag hasTimeout := cmd.Flags().Changed("timeout") @@ -31,10 +29,6 @@ or clear an existing schedule. Exactly one of --timeout or --clear must be provi if !hasTimeout && !clear { return output.NewCLIError(output.ErrValidation, "one of --timeout or --clear is required") } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } var cancelTime *int64 if hasTimeout { @@ -63,9 +57,8 @@ or clear an existing schedule. Exactly one of --timeout or --clear must be provi } result, err := exec.ScheduleCancel(cmd.Context(), exchange.ScheduleCancelInput{ - Time: cancelTime, - OnBehalfOf: onBehalfOf, - DryRun: cfg.DryRun, + Time: cancelTime, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -77,7 +70,6 @@ or clear an existing schedule. Exactly one of --timeout or --clear must be provi cmd.Flags().String("timeout", "", "cancellation timeout (Go duration, e.g. 5m, 1h)") cmd.Flags().Bool("clear", false, "clear existing schedule") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") return cmd } diff --git a/cmd/position_leverage.go b/cmd/position_leverage.go index 0f71a13..acd70c1 100644 --- a/cmd/position_leverage.go +++ b/cmd/position_leverage.go @@ -3,7 +3,6 @@ package cmd import ( "strings" - "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" "github.com/timbrinded/hlgo/pkg/config" @@ -17,10 +16,9 @@ func newPositionLeverageCmd() *cobra.Command { Short: "Set leverage and margin mode for a coin", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - leverage, _ := cmd.Flags().GetInt("leverage") //nolint:errcheck // known flag - mode, _ := cmd.Flags().GetString("mode") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + leverage, _ := cmd.Flags().GetInt("leverage") //nolint:errcheck // known flag + mode, _ := cmd.Flags().GetString("mode") //nolint:errcheck // known flag mode = strings.ToLower(mode) if mode != "cross" && mode != "isolated" { @@ -33,22 +31,16 @@ func newPositionLeverageCmd() *cobra.Command { WithDetails("value", leverage) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - exec, err := buildExecutor(cfg) if err != nil { return err } result, err := exec.UpdateLeverage(cmd.Context(), exchange.UpdateLeverageInput{ - Coin: coin, - IsCross: mode == "cross", - Leverage: leverage, - OnBehalfOf: onBehalfOf, - DryRun: cfg.DryRun, + Coin: coin, + IsCross: mode == "cross", + Leverage: leverage, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -61,7 +53,6 @@ func newPositionLeverageCmd() *cobra.Command { cmd.Flags().String("coin", "", "coin name (e.g. BTC, ETH)") cmd.Flags().Int("leverage", 0, "leverage multiplier (max is asset-specific, API-enforced)") cmd.Flags().String("mode", "cross", "margin mode: cross or isolated") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") for _, required := range []string{"coin", "leverage"} { //nolint:errcheck // MarkFlagRequired on known flags never fails diff --git a/cmd/position_margin.go b/cmd/position_margin.go index 926c162..716436d 100644 --- a/cmd/position_margin.go +++ b/cmd/position_margin.go @@ -3,7 +3,6 @@ package cmd import ( "strings" - "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/spf13/cobra" @@ -18,10 +17,9 @@ func newPositionMarginCmd() *cobra.Command { Short: "Adjust isolated margin for a position", RunE: func(cmd *cobra.Command, _ []string) error { cfg := config.FromContext(cmd.Context()) - coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag - side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag - amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag - onBehalfOf, _ := cmd.Flags().GetString("on-behalf-of") //nolint:errcheck // known flag + coin, _ := cmd.Flags().GetString("coin") //nolint:errcheck // known flag + side, _ := cmd.Flags().GetString("side") //nolint:errcheck // known flag + amountStr, _ := cmd.Flags().GetString("amount") //nolint:errcheck // known flag side = strings.ToLower(side) if side != "buy" && side != "sell" { @@ -35,22 +33,16 @@ func newPositionMarginCmd() *cobra.Command { WithDetails("value", amountStr) } - if onBehalfOf != "" && !common.IsHexAddress(onBehalfOf) { - return output.NewCLIError(output.ErrValidation, "invalid on-behalf-of address"). - WithDetails("on_behalf_of", onBehalfOf) - } - exec, err := buildExecutor(cfg) if err != nil { return err } result, err := exec.UpdateIsolatedMargin(cmd.Context(), exchange.UpdateIsolatedMarginInput{ - Coin: coin, - IsBuy: side == "buy", - Amount: amount, - OnBehalfOf: onBehalfOf, - DryRun: cfg.DryRun, + Coin: coin, + IsBuy: side == "buy", + Amount: amount, + DryRun: cfg.DryRun, }) if err != nil { return err @@ -63,7 +55,6 @@ func newPositionMarginCmd() *cobra.Command { cmd.Flags().String("coin", "", "coin name (e.g. BTC, ETH)") cmd.Flags().String("side", "", "position side: buy or sell") cmd.Flags().String("amount", "", "margin amount (positive to add, negative to remove)") - cmd.Flags().String("on-behalf-of", "", "account address to act on behalf of") for _, required := range []string{"coin", "side", "amount"} { //nolint:errcheck // MarkFlagRequired on known flags never fails diff --git a/e2e/integration_cli_test.go b/e2e/integration_cli_test.go index 8eaa93c..2df3d25 100644 --- a/e2e/integration_cli_test.go +++ b/e2e/integration_cli_test.go @@ -586,7 +586,6 @@ func TestIntegration_OnBehalf_AuthorizedRandomSignerFlow(t *testing.T) { "--price", price, "--size", "0.001", "--cloid", cloid, - "--on-behalf-of", deployerAddr, ) assertNoSecretLeak(t, stdout, stderr) if code == 0 { @@ -643,13 +642,13 @@ func TestIntegration_OnBehalf_AuthorizedRandomSignerFlow(t *testing.T) { "--dry-run", ) assertNoSecretLeak(t, stdout, stderr) - requireIntegrationExitCode(t, code, 1, stderr) - errObj := requireErrorCode(t, stderr, "VALIDATION_ERROR") + requireIntegrationExitCode(t, code, 4, stderr) + errObj := requireErrorCode(t, stderr, "API_ERROR") msg, ok := errObj["error"].(string) if !ok { t.Fatalf("missing error message: %#v", errObj["error"]) } - if !strings.Contains(msg, "on-behalf-of is not supported for user-signed actions") { + if !strings.Contains(msg, "unknown flag") { t.Fatalf("unexpected error message: %q", msg) } @@ -661,13 +660,13 @@ func TestIntegration_OnBehalf_AuthorizedRandomSignerFlow(t *testing.T) { "--dry-run", ) assertNoSecretLeak(t, stdout, stderr) - requireIntegrationExitCode(t, code, 1, stderr) - errObj = requireErrorCode(t, stderr, "VALIDATION_ERROR") + requireIntegrationExitCode(t, code, 4, stderr) + errObj = requireErrorCode(t, stderr, "API_ERROR") msg, ok = errObj["error"].(string) if !ok { t.Fatalf("missing error message: %#v", errObj["error"]) } - if !strings.Contains(msg, "on-behalf-of is not supported for schedule-cancel") { + if !strings.Contains(msg, "unknown flag") { t.Fatalf("unexpected error message: %q", msg) } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 7010f7f..ce9b549 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,9 +16,10 @@ const DefaultConfigPath = "~/.hlgo/config.yaml" // Config holds all resolved configuration for an hlgo invocation. type Config struct { // Persisted fields (written to config file) - PrivateKeyEnv string `mapstructure:"private_key_env" yaml:"private_key_env"` - DefaultDex string `mapstructure:"default_dex" yaml:"default_dex"` - MetadataTTL int `mapstructure:"metadata_ttl" yaml:"metadata_ttl"` + PrivateKeyEnv string `mapstructure:"private_key_env" yaml:"private_key_env"` + AccountAddress string `mapstructure:"account_address" yaml:"account_address"` + DefaultDex string `mapstructure:"default_dex" yaml:"default_dex"` + MetadataTTL int `mapstructure:"metadata_ttl" yaml:"metadata_ttl"` // Runtime fields (resolved from flags/env, never persisted) Testnet bool `mapstructure:"-" yaml:"-"` @@ -81,6 +82,7 @@ func Load(v *viper.Viper) (*Config, error) { // setDefaults registers default values for all config keys. func setDefaults(v *viper.Viper) { v.SetDefault("private_key_env", "HL_PRIVATE_KEY") + v.SetDefault("account_address", "") v.SetDefault("default_dex", "") v.SetDefault("metadata_ttl", 300) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 59fe898..7558260 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -17,6 +17,7 @@ func TestDefaults(t *testing.T) { want any }{ {"private_key_env", "HL_PRIVATE_KEY"}, + {"account_address", ""}, {"default_dex", ""}, {"metadata_ttl", 300}, } @@ -44,7 +45,7 @@ func newTestViper(configPath string) *viper.Viper { func TestLoad_FromFile(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - content := []byte("private_key_env: MY_KEY\ndefault_dex: xyz\nmetadata_ttl: 120\n") + content := []byte("private_key_env: MY_KEY\naccount_address: 0x1111111111111111111111111111111111111111\ndefault_dex: xyz\nmetadata_ttl: 120\n") if err := os.WriteFile(cfgPath, content, 0600); err != nil { t.Fatal(err) } @@ -64,6 +65,9 @@ func TestLoad_FromFile(t *testing.T) { if cfg.DefaultDex != "xyz" { t.Errorf("DefaultDex = %q, want %q", cfg.DefaultDex, "xyz") } + if cfg.AccountAddress != "0x1111111111111111111111111111111111111111" { + t.Errorf("AccountAddress = %q, want %q", cfg.AccountAddress, "0x1111111111111111111111111111111111111111") + } if cfg.MetadataTTL != 120 { t.Errorf("MetadataTTL = %d, want %d", cfg.MetadataTTL, 120) } diff --git a/pkg/exchange/executor.go b/pkg/exchange/executor.go index 1a908e9..914535c 100644 --- a/pkg/exchange/executor.go +++ b/pkg/exchange/executor.go @@ -62,26 +62,23 @@ type PlaceOrderInput struct { Builder *BuilderInfo // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. ExpiresAfter *int64 - OnBehalfOf string DryRun bool } // UpdateLeverageInput holds parameters for updating leverage. type UpdateLeverageInput struct { - Coin string - IsCross bool - Leverage int - OnBehalfOf string - DryRun bool + Coin string + IsCross bool + Leverage int + DryRun bool } // UpdateIsolatedMarginInput holds parameters for adjusting isolated margin. type UpdateIsolatedMarginInput struct { - Coin string - IsBuy bool - Amount decimal.Decimal - OnBehalfOf string - DryRun bool + Coin string + IsBuy bool + Amount decimal.Decimal + DryRun bool } // ModifyOrderInput holds parameters for modifying an existing order. @@ -95,15 +92,13 @@ type ModifyOrderInput struct { ReduceOnly bool Cloid *string ExpiresAfter *int64 - OnBehalfOf string DryRun bool } // ScheduleCancelInput holds parameters for the dead man's switch. type ScheduleCancelInput struct { - Time *int64 - OnBehalfOf string - DryRun bool + Time *int64 + DryRun bool } // PlaceMarketOrderInput bundles parameters for placing a market order. @@ -116,32 +111,28 @@ type PlaceMarketOrderInput struct { Builder *BuilderInfo // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. ExpiresAfter *int64 - OnBehalfOf string DryRun bool } // USDClassTransferInput holds parameters for usdClassTransfer account actions. type USDClassTransferInput struct { - Amount decimal.Decimal - ToPerp bool - OnBehalfOf string - DryRun bool + Amount decimal.Decimal + ToPerp bool + DryRun bool } // Withdraw3Input holds parameters for withdraw3 account actions. type Withdraw3Input struct { Destination string Amount decimal.Decimal - OnBehalfOf string DryRun bool } // ClassTransferInput holds parameters for classTransfer account actions. type ClassTransferInput struct { - Amount decimal.Decimal - ToPerp bool - OnBehalfOf string - DryRun bool + Amount decimal.Decimal + ToPerp bool + DryRun bool } // SpotSendInput holds parameters for spotSend account actions. @@ -149,7 +140,6 @@ type SpotSendInput struct { Destination string Token string Amount decimal.Decimal - OnBehalfOf string DryRun bool } @@ -157,7 +147,6 @@ type SpotSendInput struct { type ApproveAgentInput struct { AgentAddress string AgentName string - OnBehalfOf string DryRun bool } @@ -165,7 +154,6 @@ type ApproveAgentInput struct { type UserSetAbstractionInput struct { User string Abstraction string - OnBehalfOf string DryRun bool } @@ -312,7 +300,6 @@ func (e *Executor) PlaceMarketOrder(ctx context.Context, input PlaceMarketOrderI Tif: "Ioc", Builder: input.Builder, ExpiresAfter: input.ExpiresAfter, - OnBehalfOf: input.OnBehalfOf, DryRun: input.DryRun, }) } @@ -467,8 +454,7 @@ func (e *Executor) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Plac } // CancelOrders cancels orders by OID. -func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, onBehalfOf string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { - _ = onBehalfOf // validated at command layer; kept for interface parity and account-context flows. +func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { action := BuildCancelAction(cancels) if dryRun { @@ -477,7 +463,6 @@ func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, onBeh nonce := time.Now().UnixMilli() - // On-behalf trading context is handled by agent authorization, not vaultAddress. sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) if err != nil { return nil, err @@ -487,8 +472,7 @@ func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, onBeh } // CancelByCloid cancels orders by client order ID. -func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWire, onBehalfOf string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { - _ = onBehalfOf // validated at command layer; kept for interface parity and account-context flows. +func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWire, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { action := BuildCancelByCloidAction(cancels) if dryRun { @@ -497,7 +481,6 @@ func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWir nonce := time.Now().UnixMilli() - // On-behalf trading context is handled by agent authorization, not vaultAddress. sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) if err != nil { return nil, err @@ -642,11 +625,9 @@ func (e *Executor) ModifyOrder(ctx context.Context, input ModifyOrderInput) (*Mo } // PlaceBatchOrders signs and sends a pre-built OrderAction for batch order placement. -func (e *Executor) PlaceBatchOrders(ctx context.Context, action *OrderAction, onBehalfOf string, expiresAfter *int64) (json.RawMessage, error) { - _ = onBehalfOf // validated at command layer; kept for interface parity and account-context flows. +func (e *Executor) PlaceBatchOrders(ctx context.Context, action *OrderAction, expiresAfter *int64) (json.RawMessage, error) { nonce := time.Now().UnixMilli() - // On-behalf trading context is handled by agent authorization, not vaultAddress. sig, err := e.signer.SignL1Action(action, nonce, nil, expiresAfter, e.mainnet) if err != nil { return nil, err @@ -657,12 +638,6 @@ func (e *Executor) PlaceBatchOrders(ctx context.Context, action *OrderAction, on // ScheduleCancel sets or clears the dead man's switch for order cancellation. func (e *Executor) ScheduleCancel(ctx context.Context, input ScheduleCancelInput) (json.RawMessage, error) { - if input.OnBehalfOf != "" { - return nil, output.NewCLIError(output.ErrValidation, "on-behalf-of is not supported for schedule-cancel"). - WithDetails("on_behalf_of", input.OnBehalfOf). - WithDetails("hint", "schedule-cancel always applies to the signing wallet") - } - action := BuildScheduleCancelAction(input.Time) if input.DryRun { @@ -671,8 +646,6 @@ func (e *Executor) ScheduleCancel(ctx context.Context, input ScheduleCancelInput nonce := time.Now().UnixMilli() - // ScheduleCancel does not support on-behalf-of contexts — the Hyperliquid API - // applies the dead man's switch to the signing wallet only. sig, err := e.signer.SignL1Action(action, nonce, nil, nil, e.mainnet) if err != nil { return nil, err @@ -696,7 +669,6 @@ func (e *Executor) USDClassTransfer(ctx context.Context, input USDClassTransferI nonce, "HyperliquidTransaction:UsdClassTransfer", usdClassTransferSignTypes, - input.OnBehalfOf, input.DryRun, ) } @@ -720,7 +692,6 @@ func (e *Executor) Withdraw3(ctx context.Context, input Withdraw3Input) (json.Ra nonce, "HyperliquidTransaction:Withdraw", withdrawSignTypes, - input.OnBehalfOf, input.DryRun, ) } @@ -740,7 +711,6 @@ func (e *Executor) ClassTransfer(ctx context.Context, input ClassTransferInput) nonce, "HyperliquidTransaction:UsdClassTransfer", usdClassTransferSignTypes, - input.OnBehalfOf, input.DryRun, ) } @@ -767,7 +737,6 @@ func (e *Executor) SpotSend(ctx context.Context, input SpotSendInput) (json.RawM nonce, "HyperliquidTransaction:SpotSend", spotSendSignTypes, - input.OnBehalfOf, input.DryRun, ) } @@ -787,7 +756,6 @@ func (e *Executor) ApproveAgent(ctx context.Context, input ApproveAgentInput) (j nonce, "HyperliquidTransaction:ApproveAgent", approveAgentSignTypes, - input.OnBehalfOf, input.DryRun, ) } @@ -816,7 +784,6 @@ func (e *Executor) UserSetAbstraction(ctx context.Context, input UserSetAbstract nonce, "HyperliquidTransaction:UserSetAbstraction", userSetAbstractionSignTypes, - input.OnBehalfOf, input.DryRun, ) } @@ -827,16 +794,8 @@ func (e *Executor) executeUserAction( nonce int64, typeName string, typeFields []apitypes.Type, - onBehalfOf string, dryRun bool, ) (json.RawMessage, error) { - if onBehalfOf != "" { - return nil, output.NewCLIError(output.ErrValidation, "on-behalf-of is not supported for user-signed actions"). - WithDetails("on_behalf_of", onBehalfOf). - WithDetails("action", typeName). - WithDetails("hint", "remove --on-behalf-of for account commands") - } - actionMap, err := userActionMap(action) if err != nil { return nil, output.NewCLIError(output.ErrAPI, "failed to build action payload"). diff --git a/pkg/exchange/executor_test.go b/pkg/exchange/executor_test.go index 3ef3342..1043aef 100644 --- a/pkg/exchange/executor_test.go +++ b/pkg/exchange/executor_test.go @@ -689,7 +689,7 @@ func TestExecutor_CancelOrders_DryRun(t *testing.T) { result, err := exec.CancelOrders(context.Background(), []CancelWire{ {A: 0, O: 12345}, - }, "", true, nil) + }, true, nil) if err != nil { t.Fatalf("CancelOrders dry-run error: %v", err) } @@ -716,7 +716,7 @@ func TestExecutor_CancelByCloid_DryRun(t *testing.T) { result, err := exec.CancelByCloid(context.Background(), []CancelByCloidWire{ {Asset: 0, Cloid: "my-id"}, - }, "", true, nil) + }, true, nil) if err != nil { t.Fatalf("CancelByCloid dry-run error: %v", err) } diff --git a/pkg/info/address.go b/pkg/info/address.go index d29c46f..3f681e8 100644 --- a/pkg/info/address.go +++ b/pkg/info/address.go @@ -3,6 +3,7 @@ package info import ( "os" "regexp" + "strings" "github.com/timbrinded/hlgo/pkg/config" "github.com/timbrinded/hlgo/pkg/output" @@ -28,9 +29,21 @@ func ResolveUserAddress(explicitAddr string, cfg *config.Config) (string, error) return explicitAddr, nil } + if cfg != nil { + accountAddress := strings.TrimSpace(cfg.AccountAddress) + if accountAddress != "" { + if !ethAddrRegex.MatchString(accountAddress) { + return "", output.NewCLIError(output.ErrConfig, "invalid account_address in config"). + WithDetails("account_address", cfg.AccountAddress). + WithDetails("hint", "set account_address to a 0x-prefixed 40-hex address") + } + return accountAddress, nil + } + } + keyHex := os.Getenv(cfg.PrivateKeyEnv) if keyHex == "" { - return "", output.NewCLIError(output.ErrConfig, "no address available: provide --address or set "+cfg.PrivateKeyEnv). + return "", output.NewCLIError(output.ErrConfig, "no address available: provide --address, set account_address, or set "+cfg.PrivateKeyEnv). WithDetails("private_key_env", cfg.PrivateKeyEnv) } diff --git a/pkg/info/address_test.go b/pkg/info/address_test.go index df0d561..9c74fff 100644 --- a/pkg/info/address_test.go +++ b/pkg/info/address_test.go @@ -56,6 +56,40 @@ func TestResolveUserAddress_FromPrivateKey(t *testing.T) { } } +func TestResolveUserAddress_FromConfigAccountAddress(t *testing.T) { + cfg := &config.Config{ + AccountAddress: "0x2222222222222222222222222222222222222222", + PrivateKeyEnv: "TEST_PRIVATE_KEY", + } + + addr, err := ResolveUserAddress("", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr != "0x2222222222222222222222222222222222222222" { + t.Errorf("got %q, want config account address", addr) + } +} + +func TestResolveUserAddress_InvalidConfigAccountAddress(t *testing.T) { + cfg := &config.Config{ + AccountAddress: "bad-address", + PrivateKeyEnv: "TEST_PRIVATE_KEY", + } + + _, err := ResolveUserAddress("", cfg) + if err == nil { + t.Fatal("expected error for invalid config account address") + } + var cliErr *output.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("expected *output.CLIError, got %T", err) + } + if cliErr.Code != output.ErrConfig { + t.Errorf("expected CONFIG_ERROR, got %s", cliErr.Code) + } +} + func TestResolveUserAddress_NoAddressAvailable(t *testing.T) { t.Setenv("HL_PRIVATE_KEY", "") diff --git a/skill/hlgo/SKILL.md b/skill/hlgo/SKILL.md index 304df18..6cf20b2 100644 --- a/skill/hlgo/SKILL.md +++ b/skill/hlgo/SKILL.md @@ -67,7 +67,7 @@ Pick the wrong command/signing path and requests can fail with `SIGNING_ERROR` o - Use returned `matches[].coin` (for example `tngs:CHARIZARD-TGUSD`) in `order` commands. - **Treat non-zero exit codes as failures; branch on JSON `code`.** Exit codes map to error categories (1=validation, 3=network, 6=rate-limit). The `code` field in stderr JSON is the stable contract. - **Keep `--testnet` enabled during development and simulation.** Testnet is free. Mainnet costs real money. No flag = mainnet. -- **Use `--on-behalf-of` only on L1 commands.** Order, position, and agent bracket commands support delegated account context. Account commands and schedule-cancel reject it. See `contracts-and-safety.md` for the full matrix. +- **Use `--on-behalf-of` only where it changes account-context reads.** Currently this is `order cancel-all` and `order modify` backfill lookups. Other commands reject the flag. See `contracts-and-safety.md` for the full matrix. - **Never output private key material.** hlgo redacts keys in `config show`. Your scripts must too. ## Progressive Disclosure Map diff --git a/skill/hlgo/references/agent-workflows.md b/skill/hlgo/references/agent-workflows.md index 019d281..0cc5030 100644 --- a/skill/hlgo/references/agent-workflows.md +++ b/skill/hlgo/references/agent-workflows.md @@ -101,37 +101,36 @@ Partial success: if some substeps fail, returns `partial: true` with an `errors[ ## Delegated Trading (`--on-behalf-of`) -When the configured key is an approved agent for another account, use `--on-behalf-of` to trade on that account: +When using an approved agent/API wallet, set the target account context once (config `account_address`) and use explicit overrides only for read-dependent operations: ```bash -VAULT="0x1234567890abcdef1234567890abcdef12345678" +ACCOUNT_ADDRESS="0x1234567890abcdef1234567890abcdef12345678" -# 1. Check the delegated account's positions -hlgo info state --address "$VAULT" --testnet --format json +# 0. Persist default account context for reads/lookups +hlgo config init --account-address "$ACCOUNT_ADDRESS" --force -# 2. Set leverage on the delegated account -hlgo position leverage --coin ETH --leverage 3 --mode cross \ - --on-behalf-of "$VAULT" --testnet --format json +# 1. Check the delegated account's positions +hlgo info state --address "$ACCOUNT_ADDRESS" --testnet --format json -# 3. Dry-run an order on their behalf +# 2. Place/adjust orders normally (agent authorization determines execution identity) hlgo order place --coin ETH --side buy --price 3000 --size 0.1 \ - --on-behalf-of "$VAULT" --dry-run --testnet --format json + --dry-run --testnet --format json -# 4. Place live +# 3. Place live hlgo order place --coin ETH --side buy --price 3000 --size 0.1 \ - --on-behalf-of "$VAULT" --testnet --format json + --testnet --format json -# 5. Verify — open-orders uses --address for reads -hlgo info open-orders --address "$VAULT" --testnet --format json +# 4. Verify — open-orders uses account context reads +hlgo info open-orders --address "$ACCOUNT_ADDRESS" --testnet --format json -# 6. Cancel all on the delegated account -hlgo order cancel-all --on-behalf-of "$VAULT" --testnet --format json +# 5. Cancel all using an explicit account-context override +hlgo order cancel-all --on-behalf-of "$ACCOUNT_ADDRESS" --testnet --format json ``` Key points: -- Read commands (`info`) use `--address` to query another account. Write commands (`order`, `position`) use `--on-behalf-of` to act on their behalf. -- `cancel-all` and `modify` automatically query open orders from the `--on-behalf-of` address. -- `--on-behalf-of` is **not supported** for `account` commands or `order schedule-cancel`. +- Read commands (`info`) use `--address` (or configured `account_address`) to query account state. +- `cancel-all` and `modify` use `--on-behalf-of` only for account-context open-order lookups. +- `--on-behalf-of` is rejected by `account` commands and by write paths where it has no direct effect. ## CLOID Generation diff --git a/skill/hlgo/references/command-reference.md b/skill/hlgo/references/command-reference.md index 0aaba00..7880630 100644 --- a/skill/hlgo/references/command-reference.md +++ b/skill/hlgo/references/command-reference.md @@ -77,26 +77,26 @@ hlgo info mids --testnet --format json |---|---|---|---| | `hlgo agent snapshot` | Aggregate state, spot-state, open-orders, fills, and mids | `--address` | `hlgo agent snapshot --testnet --format json` | | `hlgo agent pnl` | Compute unrealized, realized, and funding PnL | `--address`, `--lookback-hours`, `--aggregate-fills` | `hlgo agent pnl --lookback-hours 24 --aggregate-fills --testnet --format json` | -| `hlgo agent bracket` | Place entry + TP + SL in one grouped action | `--coin`, `--side`, `--price`, `--size`, `--tp`, `--sl`, optional `--tif`, `--cloid`, `--on-behalf-of`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo agent bracket --coin ETH --side buy --price 3000 --size 0.1 --tp 3100 --sl 2950 --testnet --dry-run` | +| `hlgo agent bracket` | Place entry + TP + SL in one grouped action | `--coin`, `--side`, `--price`, `--size`, `--tp`, `--sl`, optional `--tif`, `--cloid`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo agent bracket --coin ETH --side buy --price 3000 --size 0.1 --tp 3100 --sl 2950 --testnet --dry-run` | ## Order Commands | Command | Purpose | Key Flags | Example | |---|---|---|---| -| `hlgo order place` | Place limit order | Required: `--coin`, `--side`, `--price`, `--size`; Optional: `--tif`, `--reduce`, `--cloid`, `--on-behalf-of`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order place --coin ETH --side buy --price 3000 --size 0.1 --tif gtc --testnet --dry-run` | -| `hlgo order market` | Place market IOC via slippage-adjusted mid | Required: `--coin`, `--side`, `--size`; Optional: `--slippage`, `--on-behalf-of`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order market --coin ETH --side buy --size 0.1 --slippage 0.5 --testnet --dry-run` | -| `hlgo order cancel` | Cancel by OID or CLOID | Required: `--coin` and exactly one of `--oid` or `--cloid`; Optional: `--on-behalf-of`, `--expires-after` | `hlgo order cancel --coin ETH --oid 12345 --testnet --format json` | +| `hlgo order place` | Place limit order | Required: `--coin`, `--side`, `--price`, `--size`; Optional: `--tif`, `--reduce`, `--cloid`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order place --coin ETH --side buy --price 3000 --size 0.1 --tif gtc --testnet --dry-run` | +| `hlgo order market` | Place market IOC via slippage-adjusted mid | Required: `--coin`, `--side`, `--size`; Optional: `--slippage`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order market --coin ETH --side buy --size 0.1 --slippage 0.5 --testnet --dry-run` | +| `hlgo order cancel` | Cancel by OID or CLOID | Required: `--coin` and exactly one of `--oid` or `--cloid`; Optional: `--expires-after` | `hlgo order cancel --coin ETH --oid 12345 --testnet --format json` | | `hlgo order cancel-all` | Cancel all open orders (optional coin filter) | Optional: `--coin`, `--on-behalf-of`, `--expires-after` | `hlgo order cancel-all --coin ETH --testnet --format json` | | `hlgo order modify` | Modify existing order by OID | Required: `--coin`, `--oid`, `--side`, plus at least one of `--price`/`--size`; Optional: `--tif`, `--reduce`, `--on-behalf-of`, `--expires-after` | `hlgo order modify --coin ETH --oid 12345 --side buy --price 2990 --size 0.1 --testnet --dry-run` | -| `hlgo order batch` | Place multiple orders from JSON file | Required: `--file`; Optional: `--on-behalf-of`, `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order batch --file ./orders.json --testnet --dry-run` | +| `hlgo order batch` | Place multiple orders from JSON file | Required: `--file`; Optional: `--builder`, `--builder-fee-tenths-bp`, `--expires-after` | `hlgo order batch --file ./orders.json --testnet --dry-run` | | `hlgo order schedule-cancel` | Set or clear dead-man switch | Exactly one of `--timeout` or `--clear` | `hlgo order schedule-cancel --timeout 15m --testnet --format json` | ## Position Commands | Command | Purpose | Key Flags | Example | |---|---|---|---| -| `hlgo position leverage` | Set leverage + margin mode | Required: `--coin`, `--leverage`; Optional: `--mode`, `--on-behalf-of` | `hlgo position leverage --coin ETH --leverage 5 --mode cross --testnet --format json` | -| `hlgo position margin` | Adjust isolated margin | Required: `--coin`, `--side`, `--amount`; Optional: `--on-behalf-of` | `hlgo position margin --coin ETH --side buy --amount 25 --testnet --format json` | +| `hlgo position leverage` | Set leverage + margin mode | Required: `--coin`, `--leverage`; Optional: `--mode` | `hlgo position leverage --coin ETH --leverage 5 --mode cross --testnet --format json` | +| `hlgo position margin` | Adjust isolated margin | Required: `--coin`, `--side`, `--amount` | `hlgo position margin --coin ETH --side buy --amount 25 --testnet --format json` | ## Account Commands @@ -109,7 +109,7 @@ hlgo info mids --testnet --format json | `hlgo account approve-agent` | Approve/revoke agent wallet | Required: `--agent`; Use `--name` to approve or `--revoke --confirm` to revoke | `hlgo account approve-agent --agent 0xabc... --name trader01 --testnet --format json` | | `hlgo account set-abstraction` | Set abstraction mode | Required: `--user`, `--abstraction` (`unifiedAccount`, `portfolioMargin`, `disabled`) | `hlgo account set-abstraction --user 0xabc... --abstraction disabled --testnet --format json` | -> **Note:** Account commands accept `--on-behalf-of` syntactically but reject it at runtime with `VALIDATION_ERROR`. User-signed actions do not support delegated account context. See [contracts-and-safety.md](contracts-and-safety.md#delegated-account-context---on-behalf-of) for details. +> **Note:** Account commands do not expose `--on-behalf-of`. User-signed actions are scoped to the configured signer/account context. ## Order Batch File Shape diff --git a/skill/hlgo/references/contracts-and-safety.md b/skill/hlgo/references/contracts-and-safety.md index 4a7fe6f..2eafb2c 100644 --- a/skill/hlgo/references/contracts-and-safety.md +++ b/skill/hlgo/references/contracts-and-safety.md @@ -32,21 +32,23 @@ ## Delegated Account Context (`--on-behalf-of`) -An approved agent can operate on another account's behalf using `--on-behalf-of
`. +`--on-behalf-of
` is an account-context override used only where commands must read open-order state before acting. -**Supported** — L1 phantom-agent commands: -- `order place`, `order market`, `order cancel`, `order cancel-all`, `order modify`, `order batch` +**Supported**: +- `order cancel-all` +- `order modify` (for open-order backfill lookups) + +**Not supported**: +- `order place`, `order market`, `order cancel`, `order batch` - `position leverage`, `position margin` - `agent bracket` - -**Not supported** — these reject `--on-behalf-of` with `VALIDATION_ERROR`: -- All `account` commands (transfer, withdraw, send-asset, approve-agent, set-abstraction, class-transfer) — these use the user-signed path, which does not support delegation. -- `order schedule-cancel` — the dead man's switch always applies to the signing wallet only. +- `order schedule-cancel` +- all `account` commands (transfer, withdraw, send-asset, approve-agent, set-abstraction, class-transfer) **Behaviour when set:** -- The action is signed by the configured private key but executed in the context of the `--on-behalf-of` account. -- `cancel-all` and `modify` also query open orders from the `--on-behalf-of` address (not the signer's address). -- The signer must be an approved agent for the target account, or the exchange will reject the request. +- `cancel-all` and `modify` query open orders from the `--on-behalf-of` address (not the signer's derived address). +- Unsupported commands fail fast instead of silently accepting a no-op flag. +- For ongoing account context, configure `account_address` and use `--address` on read commands when needed. ## Precision and Serialization Rules From 3e256a467cf8f211eaab08458f0bba5adfece375 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:56:21 +0000 Subject: [PATCH 6/7] test fix --- e2e/integration_cli_test.go | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/e2e/integration_cli_test.go b/e2e/integration_cli_test.go index 2df3d25..fc047ea 100644 --- a/e2e/integration_cli_test.go +++ b/e2e/integration_cli_test.go @@ -978,6 +978,50 @@ func TestIntegration_AccountMissingPrivateKeyConfigError(t *testing.T) { requireFieldString(t, details, "env_var", "HL_TEST_PRIVATE_KEY_NOT_SET") } +func TestIntegration_InfoOpenOrders_UsesConfiguredAccountAddress(t *testing.T) { + t.Setenv("HL_TEST_PRIVATE_KEY_NOT_SET", "") + + const accountAddress = "0x1111111111111111111111111111111111111111" + + cfgWithAccountPath := filepath.Join(t.TempDir(), "integration-config-with-account.yaml") + cfgWithAccount := []byte(fmt.Sprintf( + "private_key_env: HL_TEST_PRIVATE_KEY_NOT_SET\naccount_address: %s\nmetadata_ttl: 300\n", + accountAddress, + )) + if err := os.WriteFile(cfgWithAccountPath, cfgWithAccount, 0600); err != nil { + t.Fatalf("writing temp config with account_address: %v", err) + } + + stdout, stderr, code := runIntegrationHLGO(t, + "--config", cfgWithAccountPath, + "info", "open-orders", + ) + assertNoSecretLeak(t, stdout, stderr) + requireIntegrationExitCode(t, code, 0, stderr) + _ = parseJSONArray(t, stdout) + + cfgWithoutAccountPath := filepath.Join(t.TempDir(), "integration-config-without-account.yaml") + cfgWithoutAccount := []byte("private_key_env: HL_TEST_PRIVATE_KEY_NOT_SET\nmetadata_ttl: 300\n") + if err := os.WriteFile(cfgWithoutAccountPath, cfgWithoutAccount, 0600); err != nil { + t.Fatalf("writing temp config without account_address: %v", err) + } + + stdout, stderr, code = runIntegrationHLGO(t, + "--config", cfgWithoutAccountPath, + "info", "open-orders", + ) + assertNoSecretLeak(t, stdout, stderr) + requireIntegrationExitCode(t, code, 2, stderr) + errObj := requireErrorCode(t, stderr, "CONFIG_ERROR") + msg, ok := errObj["error"].(string) + if !ok { + t.Fatalf("missing error message: %#v", errObj["error"]) + } + if !strings.Contains(msg, "no address available") { + t.Fatalf("unexpected error message: %q", msg) + } +} + func TestIntegration_AccountDryRunNonceFreshness(t *testing.T) { ensurePrivateKeyForAccountDryRun(t) From c076c8255c1883aef430aec245c9832b73e445e5 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:07:42 +0000 Subject: [PATCH 7/7] cmd: enforce strict config account address format --- cmd/config.go | 6 ++++-- cmd/config_test.go | 32 +++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/cmd/config.go b/cmd/config.go index 8f67791..3f95c5f 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -6,9 +6,9 @@ import ( "fmt" "os" "path/filepath" + "regexp" "time" - "github.com/ethereum/go-ethereum/common" "github.com/spf13/cobra" "github.com/spf13/viper" "go.yaml.in/yaml/v3" @@ -47,6 +47,8 @@ type configFileData struct { MetadataTTL int `yaml:"metadata_ttl"` } +var strictEthAddrRegex = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) + // resolveConfigPath expands the default sentinel path to an absolute path. func resolveConfigPath(flagValue string) (string, error) { if flagValue != config.DefaultConfigPath { @@ -132,7 +134,7 @@ an existing config unless --force is passed.`, if accountAddress == "" { return nil } - if !common.IsHexAddress(accountAddress) { + if !strictEthAddrRegex.MatchString(accountAddress) { return fmt.Errorf("invalid --account-address: must be 0x-prefixed 40-hex address") } return nil diff --git a/cmd/config_test.go b/cmd/config_test.go index 9e639b3..9f23fac 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -252,17 +252,27 @@ func TestConfigTest_MissingEnvVar(t *testing.T) { } func TestConfigInit_InvalidAccountAddress(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.yaml") - - root := NewRootCommand(BuildInfo{Version: "test"}) - root.SetArgs([]string{ - "config", "init", - "--config", cfgPath, - "--account-address", "not-an-address", - }) + tests := []string{ + "not-an-address", + "1111111111111111111111111111111111111111", // missing 0x prefix + "0X1111111111111111111111111111111111111111", // uppercase 0X prefix + } - if err := root.Execute(); err == nil { - t.Fatal("expected error for invalid --account-address") + for _, accountAddress := range tests { + t.Run(accountAddress, func(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + + root := NewRootCommand(BuildInfo{Version: "test"}) + root.SetArgs([]string{ + "config", "init", + "--config", cfgPath, + "--account-address", accountAddress, + }) + + if err := root.Execute(); err == nil { + t.Fatalf("expected error for invalid --account-address %q", accountAddress) + } + }) } }