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/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..10e5bd2 100644 --- a/cmd/account_approve_agent.go +++ b/cmd/account_approve_agent.go @@ -15,7 +15,7 @@ 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 { @@ -47,7 +47,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 } diff --git a/cmd/account_class_transfer.go b/cmd/account_class_transfer.go index 1f4cfd4..8f5ed15 100644 --- a/cmd/account_class_transfer.go +++ b/cmd/account_class_transfer.go @@ -29,7 +29,7 @@ func newAccountClassTransferCmd() *cobra.Command { WithDetails("value", amountStr) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } 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..57739a0 100644 --- a/cmd/account_send_asset.go +++ b/cmd/account_send_asset.go @@ -42,7 +42,7 @@ func newAccountSendAssetCmd() *cobra.Command { WithDetails("value", amountStr) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } diff --git a/cmd/account_set_abstraction.go b/cmd/account_set_abstraction.go index 6e1842d..367b02e 100644 --- a/cmd/account_set_abstraction.go +++ b/cmd/account_set_abstraction.go @@ -40,7 +40,7 @@ func newAccountSetAbstractionCmd() *cobra.Command { WithDetails("allowed", []string{"unifiedAccount", "portfolioMargin", "disabled"}) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } 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..873882c 100644 --- a/cmd/account_transfer.go +++ b/cmd/account_transfer.go @@ -29,7 +29,7 @@ func newAccountTransferCmd() *cobra.Command { WithDetails("value", amountStr) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } diff --git a/cmd/account_withdraw.go b/cmd/account_withdraw.go index 6089e74..9ad826c 100644 --- a/cmd/account_withdraw.go +++ b/cmd/account_withdraw.go @@ -38,7 +38,7 @@ func newAccountWithdrawCmd() *cobra.Command { WithDetails("value", amountStr) } - exec, err := buildMasterExecutor(cfg) + exec, err := buildExecutor(cfg) if err != nil { return err } diff --git a/cmd/agent_bracket.go b/cmd/agent_bracket.go index 3c3e589..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 - vault, _ := cmd.Flags().GetString("vault") //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 vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) - } - 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, - VaultAddr: vault, 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("vault", "", "vault address") 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..3f95c5f 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "time" "github.com/spf13/cobra" @@ -21,7 +22,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,12 +41,14 @@ 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"` + AccountAddress string `yaml:"account_address"` + DefaultDex string `yaml:"default_dex"` + 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 { @@ -60,11 +63,11 @@ func resolveConfigPath(flagValue string) (string, error) { func newConfigInitCmd() *cobra.Command { var ( - agentKeyEnv string - masterKeyEnv string - defaultDex string - metadataTTL int - force bool + privateKeyEnv string + accountAddress string + defaultDex string + metadataTTL int + force bool ) cmd := &cobra.Command{ @@ -87,10 +90,10 @@ an existing config unless --force is passed.`, } data := configFileData{ - AgentKeyEnv: agentKeyEnv, - MasterKeyEnv: masterKeyEnv, - DefaultDex: defaultDex, - MetadataTTL: metadataTTL, + PrivateKeyEnv: privateKeyEnv, + AccountAddress: accountAddress, + DefaultDex: defaultDex, + MetadataTTL: metadataTTL, } out, err := yaml.Marshal(data) @@ -106,11 +109,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,12 +124,22 @@ 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(&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 !strictEthAddrRegex.MatchString(accountAddress) { + return fmt.Errorf("invalid --account-address: must be 0x-prefixed 40-hex address") + } + return nil + } + return cmd } @@ -144,27 +155,22 @@ 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 != "", + "account_address": cfg.AccountAddress, + "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 +194,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 +213,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 +250,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..9f23fac 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,8 @@ 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", + "--account-address", "0x1111111111111111111111111111111111111111", "--default-dex", "xyz", "--metadata-ttl", "60", }) @@ -100,11 +100,11 @@ 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_KEY") { + t.Error("custom private key env not written") } - if !strings.Contains(content, "CUSTOM_MASTER") { - t.Error("custom master key env not written") + if !strings.Contains(content, "account_address: 0x1111111111111111111111111111111111111111") { + t.Error("account_address not written") } } @@ -128,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("agent_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"}) @@ -145,11 +145,14 @@ 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") + } + if out["account_address"] != "0x1111111111111111111111111111111111111111" { + t.Errorf("account_address = %v, want configured address", out["account_address"]) } - 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 +172,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 +191,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 +216,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 +238,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 +247,32 @@ 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") + } +} + +func TestConfigInit_InvalidAccountAddress(t *testing.T) { + tests := []string{ + "not-an-address", + "1111111111111111111111111111111111111111", // missing 0x prefix + "0X1111111111111111111111111111111111111111", // uppercase 0X prefix + } + + 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) + } + }) } } diff --git a/cmd/info_test.go b/cmd/info_test.go index 9507a0a..8afb7f8 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) @@ -226,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/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..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 - vault, _ := cmd.Flags().GetString("vault") //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 vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) - } - 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, vault, 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("vault", "", "vault address") 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..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 - vault, _ := cmd.Flags().GetString("vault") //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 vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) - } - 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}, - }, vault, 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}, - }, vault, 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("vault", "", "vault address") cmd.Flags().String("expires-after", "", "expiry timestamp (Unix ms or ISO 8601)") //nolint:errcheck // MarkFlagRequired on known flags never fails @@ -113,13 +106,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 +147,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 +183,7 @@ func newOrderCancelAllCmd() *cobra.Command { }), nil) } - result, err := exec.CancelOrders(cmd.Context(), cancels, vault, cfg.DryRun, expiresAfter) + result, err := exec.CancelOrders(cmd.Context(), cancels, cfg.DryRun, expiresAfter) if err != nil { return err } @@ -195,7 +192,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..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 - vault, _ := cmd.Flags().GetString("vault") //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 vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) - } - 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, - VaultAddr: vault, 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("vault", "", "vault address") 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..c2ea09c 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,6 @@ func newOrderModifyCmd() *cobra.Command { Tif: wireTif, ReduceOnly: reduce, ExpiresAfter: expiresAfter, - VaultAddr: vault, DryRun: cfg.DryRun, }) if err != nil { @@ -128,7 +127,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 +138,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..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 - vault, _ := cmd.Flags().GetString("vault") //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 vault != "" && !common.IsHexAddress(vault) { - return output.NewCLIError(output.ErrValidation, "invalid vault address"). - WithDetails("vault", vault) - } - 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, - VaultAddr: vault, 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("vault", "", "vault address") 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 +153,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_test.go b/cmd/order_test.go index fec6fef..2150c4a 100644 --- a/cmd/order_test.go +++ b/cmd/order_test.go @@ -308,16 +308,16 @@ 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", "--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..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" @@ -20,7 +19,6 @@ func newPositionLeverageCmd() *cobra.Command { 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 mode = strings.ToLower(mode) if mode != "cross" && mode != "isolated" { @@ -33,22 +31,16 @@ func newPositionLeverageCmd() *cobra.Command { WithDetails("value", leverage) } - 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 } 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, + 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("vault", "", "vault address") 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..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" @@ -21,7 +20,6 @@ func newPositionMarginCmd() *cobra.Command { 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 side = strings.ToLower(side) if side != "buy" && side != "sell" { @@ -35,22 +33,16 @@ func newPositionMarginCmd() *cobra.Command { WithDetails("value", amountStr) } - 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 } 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, + 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("vault", "", "vault address") 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..c82f6c2 100644 --- a/cmd/position_test.go +++ b/cmd/position_test.go @@ -66,15 +66,15 @@ 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", "--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") } } @@ -117,15 +117,15 @@ 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", "--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..b0ef332 100644 --- a/e2e/agent_simulation_test.go +++ b/e2e/agent_simulation_test.go @@ -13,19 +13,18 @@ import ( "time" "github.com/shopspring/decimal" + "github.com/timbrinded/hlgo/pkg/wire" ) 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) { @@ -188,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/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..fc047ea 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 @@ -38,7 +40,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,19 +185,73 @@ 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_PRIVATE_KEY")) == "" { + t.Setenv("HL_TEST_PRIVATE_KEY", integrationTestPrivateKey) + } +} + +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() - if strings.TrimSpace(os.Getenv("HL_TEST_MASTER_KEY")) == "" { - t.Setenv("HL_TEST_MASTER_KEY", integrationTestPrivateKey) + + 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{ 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 +266,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 +386,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) @@ -471,8 +527,152 @@ 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, + ) + 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, 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, "unknown flag") { + 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, 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, "unknown flag") { + t.Fatalf("unexpected error message: %q", msg) + } +} + func TestIntegration_AccountDryRunPayloadContracts(t *testing.T) { - ensureMasterKeyForAccountDryRun(t) + ensurePrivateKeyForAccountDryRun(t) const mixedAddr = "0xABABABABABABABABABABABABABABABABABABABAB" const lowerAddr = "0xabababababababababababababababababababab" @@ -615,7 +815,7 @@ func TestIntegration_AccountDryRunPayloadContracts(t *testing.T) { } func TestIntegration_AccountValidationContracts(t *testing.T) { - ensureMasterKeyForAccountDryRun(t) + ensurePrivateKeyForAccountDryRun(t) const validAddr = "0x1111111111111111111111111111111111111111" tests := []struct { @@ -750,11 +950,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 +969,61 @@ 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_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) { - ensureMasterKeyForAccountDryRun(t) + ensurePrivateKeyForAccountDryRun(t) stdout1, stderr1, code1 := runIntegrationHLGO(t, "account", "transfer", @@ -811,8 +1055,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 +1190,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..ce9b549 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,10 +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) - 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"` + 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,8 +81,8 @@ 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("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 db89381..7558260 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -16,8 +16,8 @@ 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"}, + {"account_address", ""}, {"default_dex", ""}, {"metadata_ttl", 300}, } @@ -45,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("agent_key_env: MY_KEY\nmaster_key_env: MY_MASTER\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) } @@ -59,15 +59,15 @@ 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") } + 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) } @@ -86,8 +86,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 +146,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 +155,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..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 - VaultAddr 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 + 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 + DryRun bool } // ModifyOrderInput holds parameters for modifying an existing order. @@ -95,7 +92,6 @@ type ModifyOrderInput struct { ReduceOnly bool Cloid *string ExpiresAfter *int64 - VaultAddr string DryRun bool } @@ -115,7 +111,6 @@ type PlaceMarketOrderInput struct { Builder *BuilderInfo // ExpiresAfter, when set, causes the action to be rejected after this Unix ms timestamp. ExpiresAfter *int64 - VaultAddr string DryRun bool } @@ -305,7 +300,6 @@ func (e *Executor) PlaceMarketOrder(ctx context.Context, input PlaceMarketOrderI Tif: "Ioc", Builder: input.Builder, ExpiresAfter: input.ExpiresAfter, - VaultAddr: input.VaultAddr, DryRun: input.DryRun, }) } @@ -439,19 +433,15 @@ 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 - } - - sig, err := e.signer.SignL1Action(action, nonce, vaultAddr, 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.VaultAddr, input.ExpiresAfter) + resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", input.ExpiresAfter) if err != nil { return nil, err } @@ -464,7 +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, vaultAddr string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { +func (e *Executor) CancelOrders(ctx context.Context, cancels []CancelWire, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { action := BuildCancelAction(cancels) if dryRun { @@ -473,22 +463,16 @@ 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 - } - - sig, err := e.signer.SignL1Action(action, nonce, vault, expiresAfter, e.mainnet) + 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), vaultAddr, 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, vaultAddr string, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { +func (e *Executor) CancelByCloid(ctx context.Context, cancels []CancelByCloidWire, dryRun bool, expiresAfter *int64) (json.RawMessage, error) { action := BuildCancelByCloidAction(cancels) if dryRun { @@ -497,18 +481,12 @@ 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 - } - - sig, err := e.signer.SignL1Action(action, nonce, vault, expiresAfter, e.mainnet) + 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), vaultAddr, expiresAfter) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) } // UpdateLeverage sets leverage and margin mode for a coin. @@ -531,18 +509,13 @@ 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 - } - - sig, err := e.signer.SignL1Action(action, nonce, vault, 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.VaultAddr, nil) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", nil) } // UpdateIsolatedMargin adjusts isolated margin for a position. @@ -572,18 +545,13 @@ 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 - } - - sig, err := e.signer.SignL1Action(action, nonce, vault, 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.VaultAddr, nil) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", nil) } // ModifyOrder modifies an existing order. @@ -639,18 +607,13 @@ 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 - } - - sig, err := e.signer.SignL1Action(action, nonce, vault, 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.VaultAddr, input.ExpiresAfter) + resp, err := e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", input.ExpiresAfter) if err != nil { return nil, err } @@ -662,21 +625,15 @@ 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, expiresAfter *int64) (json.RawMessage, error) { nonce := time.Now().UnixMilli() - var vault *common.Address - if vaultAddr != "" { - a := common.HexToAddress(vaultAddr) - vault = &a - } - - sig, err := e.signer.SignL1Action(action, nonce, vault, expiresAfter, e.mainnet) + 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), vaultAddr, expiresAfter) + return e.client.PostExchange(ctx, action, nonce, sigToWire(sig), "", expiresAfter) } // ScheduleCancel sets or clears the dead man's switch for order cancellation. @@ -689,8 +646,6 @@ func (e *Executor) ScheduleCancel(ctx context.Context, input ScheduleCancelInput nonce := time.Now().UnixMilli() - // ScheduleCancel does not support vault addresses — 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 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/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..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" @@ -16,7 +17,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 +29,22 @@ func ResolveUserAddress(explicitAddr string, cfg *config.Config) (string, error) return explicitAddr, nil } - keyHex := os.Getenv(cfg.AgentKeyEnv) + 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.AgentKeyEnv). - WithDetails("agent_key_env", cfg.AgentKeyEnv) + return "", output.NewCLIError(output.ErrConfig, "no address available: provide --address, set account_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..9c74fff 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) @@ -56,10 +56,44 @@ func TestResolveUserAddress_FromAgentKey(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_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..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,14 +53,14 @@ 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: -- 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. @@ -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/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..6cf20b2 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 @@ -67,6 +67,7 @@ Pick the wrong wallet and signing fails silently or with `SIGNING_ERROR`. When i - 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 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 @@ -85,6 +86,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/agent-workflows.md b/skill/hlgo/references/agent-workflows.md index 18d7592..0cc5030 100644 --- a/skill/hlgo/references/agent-workflows.md +++ b/skill/hlgo/references/agent-workflows.md @@ -99,6 +99,39 @@ 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 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 +ACCOUNT_ADDRESS="0x1234567890abcdef1234567890abcdef12345678" + +# 0. Persist default account context for reads/lookups +hlgo config init --account-address "$ACCOUNT_ADDRESS" --force + +# 1. Check the delegated account's positions +hlgo info state --address "$ACCOUNT_ADDRESS" --testnet --format json + +# 2. Place/adjust orders normally (agent authorization determines execution identity) +hlgo order place --coin ETH --side buy --price 3000 --size 0.1 \ + --dry-run --testnet --format json + +# 3. Place live +hlgo order place --coin ETH --side buy --price 3000 --size 0.1 \ + --testnet --format json + +# 4. Verify — open-orders uses account context reads +hlgo info open-orders --address "$ACCOUNT_ADDRESS" --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` (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 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 350a9bd..7880630 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`, `--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` | @@ -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`, `--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`, `--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: `--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` | `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,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 do not expose `--on-behalf-of`. User-signed actions are scoped to the configured signer/account context. + ## 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 67b3853..2eafb2c 100644 --- a/skill/hlgo/references/contracts-and-safety.md +++ b/skill/hlgo/references/contracts-and-safety.md @@ -26,10 +26,30 @@ | 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` | 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 | +## Delegated Account Context (`--on-behalf-of`) + +`--on-behalf-of
` is an account-context override used only where commands must read open-order state before acting. + +**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` +- `order schedule-cancel` +- all `account` commands (transfer, withdraw, send-asset, approve-agent, set-abstraction, class-transfer) + +**Behaviour when set:** +- `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 - Use decimal-safe strings for all prices, sizes, and amounts.