Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quick-start text still says “Export your agent private key”, but the env var is now HL_PRIVATE_KEY and the PR moves to a single-key model. Updating the wording here would prevent confusion about which key to export.

Suggested change
# 2. Export your agent private key
# 2. Export your Hyperliquid private key as HL_PRIVATE_KEY

Copilot uses AI. Check for mistakes.
export HL_AGENT_KEY=0xYOUR_PRIVATE_KEY
export HL_PRIVATE_KEY=0xYOUR_PRIVATE_KEY

# 3. Verify connectivity
hlgo config test --testnet
Expand Down
4 changes: 2 additions & 2 deletions SOUL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion cmd/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand Down
4 changes: 2 additions & 2 deletions cmd/account_approve_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/account_class_transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func newAccountClassTransferCmd() *cobra.Command {
WithDetails("value", amountStr)
}

exec, err := buildMasterExecutor(cfg)
exec, err := buildExecutor(cfg)
if err != nil {
return err
}
Expand Down
32 changes: 1 addition & 31 deletions cmd/account_helpers.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/account_send_asset.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func newAccountSendAssetCmd() *cobra.Command {
WithDetails("value", amountStr)
}

exec, err := buildMasterExecutor(cfg)
exec, err := buildExecutor(cfg)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/account_set_abstraction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
21 changes: 17 additions & 4 deletions cmd/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")

Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/account_transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func newAccountTransferCmd() *cobra.Command {
WithDetails("value", amountStr)
}

exec, err := buildMasterExecutor(cfg)
exec, err := buildExecutor(cfg)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/account_withdraw.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func newAccountWithdrawCmd() *cobra.Command {
WithDetails("value", amountStr)
}

exec, err := buildMasterExecutor(cfg)
exec, err := buildExecutor(cfg)
if err != nil {
return err
}
Expand Down
8 changes: 0 additions & 8 deletions cmd/agent_bracket.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -151,7 +145,6 @@ func newAgentBracketCmd() *cobra.Command {
SlTrigger: &slTrigger,
Builder: builder,
ExpiresAfter: expiresAfter,
VaultAddr: vault,
DryRun: cfg.DryRun,
})
if err != nil {
Expand All @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion cmd/agent_pnl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/agent_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading