Signer & authority

One local key, one signing lane, no ungated path to the exchange.

T3 Trade signs Hyperliquid L1 actions with a local secp256k1 key. Before submitting an action, it must resolve the signer, persist the execution record, assign and sign with a nonce inside one serialized lane, and then send the request. Any failed check stops the action before submission.

domain: Exchange / chainId 1337 lane: semaphore of 1 key: ~/.t3trade/secrets/
resolveSigner fail-closed gate execution record persisted before signing signInNonceLane semaphore of one POST /exchange hyperliquid-testnet refused signer_not_configured

The green corridor is the only route a signed action can take: apps/server/src/trading/HyperliquidExecutionService.ts:2-15 fixes the order of operations (persist the record and risk reservation before signing, sign in the serialized lane, POST) and calls itself "the only code path that spends testnet capital". The red branch is the unarmed gate: no resolved signer means the action is refused as signer_not_configured (HyperliquidExecutionService.ts:503-519). The record is written before the signature because idempotency is entirely local: the exchange does not deduplicate resubmitted marketable IOCs, a behavior verified live in packages/hyperliquid/src/executionLive.test.ts (HyperliquidExecutionService.ts:17-23).

1337chainId of Hyperliquid's EIP-712 signing domain. It is not Arbitrum's 42161 or 421614.
1permit in the serialized signing lane. Every signature is taken inside it.
65bytes per signature: 32 for r, 32 for s, 1 for the recovery id.
0o600the only acceptable key file mode. Any group or other bit is refused.

What the signature actually covers.

Hyperliquid L1 actions (placing an order, canceling one) are not signed as EIP-712 typed data of the action struct. The action is first encoded with msgpack, a compact binary format, and hashed together with its nonce and vault binding. That hash then plays the role of connectionId inside a small Agent struct, which is what the EIP-712 signature actually covers. Hyperliquid calls this a phantom agent because no real on-chain agent contract exists; the domain is a set of fixed constants (packages/hyperliquid/src/Signing.ts:5-22).

The domain is { name: "Exchange", version: "1", chainId: 1337, verifyingContract: 0x0..0 }, pinned as HYPERLIQUID_EIP712_DOMAIN at Signing.ts:36-41. The chainId is 1337, never Arbitrum's 42161 or 421614, because this signature never touches a chain; it only has to match what the exchange verifies (Signing.ts:24). The source field of the Agent struct is "b" on testnet and "a" on mainnet (Signing.ts:21, :211).

One subtlety carries real weight: msgpack preserves the insertion order of the action's keys, so the hash depends on key order. Reordering fields in an action record changes the bytes being hashed and breaks the signature. The module doc is explicit that the implementation was "verified byte-for-byte against the reference algorithm in @nktkas/hyperliquid" (Signing.ts:2-3), and the construction is pinned by that reference, not by intuition.

What comes back is a 65-byte ECDSA signature: 32 bytes of r, 32 of s, and one recovery id. Internally the id is the raw 0 or 1; on the wire it is biased to 27 or 28, the Ethereum convention the exchange deserializes (Signing.ts:50-57, :266-276). The signer address is derived the standard way, the last 20 bytes of the keccak256 of the uncompressed public key, and is recorded as the signer of record on every execution (Signing.ts:231-237).

action hash construction · spec §15.6
# packages/hyperliquid/src/Signing.ts:9-22
actionHash = keccak256(
  msgpack(action)   # insertion-order keys
  uint64BE(nonce)   # 8 bytes, big-endian
  vault marker      # 0x01 + address, or lone 0x00
  expiresAfter      # 0x00 + uint64BE, or nothing
)

domain = { name: "Exchange", version: "1",
           chainId: 1337,
           verifyingContract: 0x00..00 }

agent  = { source: "b" # testnet ("a" mainnet)
         , connectionId: actionHash }

digest = keccak256(0x1901, domainSeparator,
                   structHash(agent))

The Agent type hash is keccak256("Agent(string source,bytes32 connectionId)") and the final digest follows EIP-712 exactly (Signing.ts:117-119, :190-192). Nothing here is T3's invention; every constant is matched against the reference SDK so the exchange accepts the bytes.

One lane, one nonce, one sign site.

A nonce in this system is a number attached to every signed action, which the exchange uses to reject stale submissions. T3 Trade treats nonce assignment as a critical section, not a footnote. Every action signed by the execution wallet passes through one serialized lane. The full "assign nonce, sign, submit" sequence runs under one permit, so two actions requested by the agent runtime, called the harness in code, cannot receive the same nonce (packages/hyperliquid/src/NonceCoordinator.ts:3-18).

Semaphore.make(1)
NonceCoordinator.ts:77

The lane is a semaphore with exactly one permit. One instance exists per execution wallet. Concurrency exists everywhere else in the server; at the moment a signature is taken, it does not.

next = now > last ? now : last + 1
NonceCoordinator.ts:82-90

Nonces are strictly monotonic, never duplicated. Each issue either takes the current wall clock in milliseconds or increments past the last value, whichever is greater. Both branches are strictly greater than the last committed nonce.

no persisted nonce state
NonceCoordinator.ts:8-12, 70-74

Because the lane fast-forwards to current Unix milliseconds, a restart needs no persisted state. The module doc says it plainly: "real time has always moved past anything signed before it". The coordinator is in-memory only; a fresh process's first nonce is strictly greater than anything a previous process signed, so there is nothing to rehydrate.

commit on success
NonceCoordinator.ts:60-63, 95-103

The nonce is committed only if the caller's effect succeeds, so a failed submission does not burn a gap. Gaps would be harmless to the exchange anyway; the exchange accepts a current-or-future ms nonce and rejects stale ones (NonceCoordinator.ts:8-10).

signInNonceLane
HyperliquidExecutionService.ts:521-552

This is the single place in the execution service a signature is taken: runWithNonce wraps signL1ActionForWire together with the isTestnet flag. The comment states the rule: "a second copy of this block would be a second chance to sign outside the lane". All six wire paths, submitOrder, submitCancel, submitProtectiveStop, submitWorkingEntry, submitManualOrder, and submitReduceOnlyIoc, go through it (HyperliquidExecutionService.ts:129-244).

nextNonce (peek only)
NonceCoordinator.ts:53-56

Previews and dry runs can peek the next nonce without consuming it. The value is monotonic relative to the last committed nonce but is not reserved, so a preview can never quietly spend the lane.

Key resolution, fail-closed.

The signing path needs the private key of a Hyperliquid wallet authorized to trade for the account. For the POC that is the master account's own key: a master wallet can sign for its own account directly, so no separate agent-approval step is required (apps/server/src/trading/InterimSignerConfig.ts:3-8). Resolving that key is a strict order of attempts, and every dead end ends in refusal, never in a guess.

first source · env

T3_TRADES_INTERIM_SIGNER_KEY

A 0x-prefixed 32-byte hex private key. An optional T3_TRADES_INTERIM_SIGNER_ADDRESS must equal the address derived from the key, or the resolve fails with address_mismatch (InterimSignerConfig.ts:12-17, :161-168).

second source · file

The shared secret file

~/.t3trade/secrets/hyperliquid-interim-signer-key.bin, base dir overridable via T3TRADE_HOME. Every consumer, the dev server, the packaged desktop app, and the live smoke tests, resolves through one module, so "a machine has exactly one place to arm" (packages/hyperliquid/src/KeyLocation.ts:3-15, :29-42).

permission gate

Owner-only, or refuse

Any group or other permission bit is refused with insecure_key_permissions: "a key the rest of the machine can read is a different problem from no key, and the fix is chmod 600". The check is mode & 0o077; on Windows the mode means nothing, so it reports null there and the check is skipped rather than faked (InterimSignerConfig.ts:34-37, :72-75, :217-220).

neither source · unarmed

Option.none(), refuse everything

If neither source provides a key, resolution returns Option.none(). Every signable action is then rejected with interim_signer_not_configured. This is the only path that spends testnet capital, so it remains closed until the owner explicitly configures a key (InterimSignerConfig.ts:29-32).

what never happens

Memory only, never stored

The key is held as raw bytes in memory and never persisted by the module. It never touches trading_accounts.master_wallet_json, whose schema is key-less and Privy-bound by design. Under vitest the file source is disabled entirely, so no test run ambiently arms live execution with the developer's real key; a test that wants an armed gate must set the env var itself (InterimSignerConfig.ts:26-27, :39-41, :247-258).

An earlier implementation broke this fail-closed behavior. It read the key file with Effect.promise. A rejected promise became an Effect defect and bypassed the orElseSucceed fallback that treats a missing file as an unarmed signer. In the module's own words, "With promise the fail-closed fallback never fired", and a missing file killed the caller instead. It is tryPromise now, and the real reader is exported so the regression test drives the actual code, not a fake that would let the defect through again (InterimSignerConfig.ts:63-77).

two apps, two homes · packages/shared/src/forkPaths.ts
# upstream T3 Code          # T3 Trade (this fork)
~/.t3                          ~/.t3trade
  state.sqlite                   userdata/   # runtime state
                                 dev/        # dev-build state
                                 secrets/
                                   hyperliquid-interim-signer-key.bin

T3CODE_HOME                    T3TRADE_HOME
# application-state override  # signer base only

Constants: T3_HOME_DIR_NAME = ".t3trade", DESKTOP_USER_DATA_DIR_NAME = "t3trade", and its dev twin "t3trade-dev" (forkPaths.ts:20-26). The secrets directory predates the rest of the state; the home grew around the key, not the other way round (forkPaths.ts:15-18).

Why ~/.t3trade and not ~/.t3.

T3 Trade ships as a separate application a user may install next to upstream T3 Code. The fork's own header comment explains why the paths must differ: two applications sharing ~/.t3 share one state.sqlite, and two writers on one SQLite database corrupt each other's results; two sharing an Electron user-data directory share the single-instance lock that lives inside it, so "the second launch is handed to the first app". Both failures are silent, and both hit a new user on their first run (forkPaths.ts:4-8).

So only the directory names fork. T3CODE_HOME keeps its name because it is opt-in: anyone who sets it means it, and renaming it would touch every call site to buy nothing (forkPaths.ts:10-13). T3TRADE_HOME is the separate signer-base override; it resolves the interim key beneath secrets and is not the general application-state override (KeyLocation.ts:29-33).

Within one machine the fork's instances cooperate instead of colliding: any number of T3 Trade runs, dev servers, worktrees, the packaged app, share the same read-only key file without conflicting, and a running T3 Code instance never reads or writes here (KeyLocation.ts:11-15).

The authority stack that says yes or no.

The signature is the last step of the corridor. Before it, a stack of checks has to pass, and each one is a different kind of gate: an operator-tunable ceiling, a market-conflict rule, a loss-budget exhaustion, a database-enforced decision lease, and a filesystem-enforced single-writer lease. None of them live in prompt prose; each is deterministic code with a citation.

Absolute ceilings, env-tunable

Four environment knobs adjust the mandate a new mission receives: T3_TRADES_AUTHORITY_MAX_LEVERAGE, T3_TRADES_AUTHORITY_MAX_GROSS_NOTIONAL_USD, T3_TRADES_AUTHORITY_MAX_CUMULATIVE_LOSS_USD, and T3_TRADES_AUTHORITY_MAX_PLANNED_RISK_USD. All four are absolute values, not multiples of capital: an operator raising a ceiling is thinking in dollars about a specific account (apps/server/src/trading/TestnetAuthority.ts:13-20).

A junk value, anything that is not a positive finite number, falls back to the documented default rather than failing, because "a typo'd risk ceiling must not be the thing that stops a mission from being created" (TestnetAuthority.ts:8-11, :31-35). And the knobs only shrink or grow size: "Nothing here can grant direction reversal, switch margin mode, or touch the risk policy" (TestnetAuthority.ts:21-23).

20x leverage8xC gross notional35%C cumulative loss7%C risk per position

Testnet defaults for a $100 account: $800 gross, $35 loss budget, $7 planned risk. The 20x is T3's ceiling; ETH on Hyperliquid testnet allows 25x (packages/trading-contracts/src/authority.ts:158-189). The percentages are computed as (C * 35) / 100 because the decimal forms land on 35.000000000000004, and a mandate the user reads should not carry a float artifact.

Market exclusivity

Authority on a market is taken by bindThreadToMarket, which returns a value rather than failing: a held market is an answer the model relays, not an error it retries (apps/server/src/trading/TradingAuthorityBinding.ts:272-278).

Two conflicts refuse the bind. An already-active mission yields TradingMissionAlreadyActiveError, and the refusal names the holding chat's title. Manual exposure yields TradingMarketManualExposureError, with the words: "An agent may not take a market its owner is already trading, so nothing was placed here." (TradingAuthorityBinding.ts:153, :357-374)

Once a mission is bound, §10.2 fixes its provider for the mission's lifetime. The service reads the provider identity from the MCP credential instance instead of inferring it. This matters because the binding cannot be changed later (TradingAuthorityBinding.ts:306-323).

Loss exhaustion

When the cumulative-loss budget is exhausted (remainingCumulativeLossUsd <= 0), §16.4 mandates: cancel position-increasing orders, block new entries, scale-ins, reversals, and re-entry, and preserve valid reduce-only protection. The mission transitions to blocked with reason cumulative_loss_limit (apps/server/src/trading/TradingExecutionGuard.ts:2-9).

The harness's trading_resume_mission is rejected while blocked. Only an explicit user resume, after revalidation, clears it. The loss budget itself is traced on Risk control.

The decision lease

Only one harness run may own a mission's decision lease at a time. The lock is not a lock in application code; it is a unique partial index in SQLite, so the database itself is the arbiter (apps/server/src/trading/TradingTurnCoordinator.ts:5-11):

migration 035 · §11.2 single decision lease
CREATE UNIQUE INDEX IF NOT EXISTS
  idx_trading_harness_runs_one_active_per_mission
ON trading_harness_runs (mission_id)
WHERE status NOT IN ('completed', 'failed');

A concurrent second insert becomes a unique violation, which the coordinator catches and reports as queued_behind_active_run rather than an error (apps/server/src/persistence/Migrations/035_TradingDomain.ts:133-139, TradingTurnCoordinator.ts:8-11). Every execution check asks which run owns the lease, and a chat turn nobody dispatched owns nothing until it is adopted (TradingAuthorityBinding.ts:403-407).

The single-writer runtime lease

Booting the trading layer runs destructive housekeeping: the mission sweep deletes missions whose thread row is gone, and the watch evaluator sweeps every two seconds. A second process booting the same layer against the same database runs that housekeeping twice against the same rows, and the module doc records that this "has already killed a live soak". The lease exists to make that collision loud instead of silent (apps/server/src/trading/TradingRuntimeLease.ts:1-10).

The mechanism is a lock file next to the database, <dbPath>.trading.lock, holding { pid, host, heartbeat } and created with the exclusive wx flag so acquisition is atomic on every platform shipped. A lock file was chosen over spending a migration on a lease table: the file gives the same atomicity via the filesystem and self-cleans with the temp directory (TradingRuntimeLease.ts:12-19). While the lease is refused, the rest of the server boots normally; only the destructive trading runtime stays down, and a refused process does not retry (TradingRuntimeLease.ts:28-32).

Takeover never unlinks the lock path directly. It renames the lock to a private temp path, which atomically captures whatever the file held, then deletes only an exact match to the stale record it judged; a mismatch means the file changed hands, so the capturer restores it and retries against the new contents. A takeover can never delete a lock it did not judge stale (TradingRuntimeLease.ts:34-46).

One race remains: a holder suspended past the stale window can resume between its ownership check and its lock-file write, then overwrite the new holder's lock. For at most one heartbeat interval, about 10 seconds, both processes believe they hold; the dispossessed holder's next tick detects the loss and stands down, and a stood-down process never becomes a writer again without a full re-acquire. Exclusivity is eventually preserved, with one bounded transient dual-belief in this corner (TradingRuntimeLease.ts:51-59).

10s heartbeat interval45s stale after~10s max dual-belief windowwx atomic create

Constants at TradingRuntimeLease.ts:101 (HEARTBEAT_INTERVAL_MS = 10_000) and :108 (STALE_AFTER_MS = 45_000). An in-memory database is private to the process by construction and needs no lease (TradingRuntimeLease.ts:24-26).

Testnet by construction.

The signing domain follows the resolved endpoints instead of a separate testnet flag: isTestnetEndpoints checks whether the resolved exchange URL contains hyperliquid-testnet, so the source byte in the L1 action hash and the exchange the signed action is sent to are read from the same source of truth. The module doc's invariant: the signature domain "can never disagree with the exchange the signed action is sent to" (packages/hyperliquid/src/config.ts:38-44, HyperliquidExecutionService.ts:499-501).

Endpoints are values, not knobs

The default endpoint pair is fixed: api.hyperliquid-testnet.xyz for info and exchange, wss://api.hyperliquid-testnet.xyz/ws for the socket, and the module doc states "Mainnet is out of scope for the POC" (config.ts:4-18). Overriding endpoints is a dev and test affordance for pointing fixtures at a local recorder, not a runtime choice. The signing code can express the mainnet source byte "a", but nothing resolves mainnet endpoints, so no implicit mainnet path exists (Signing.ts:21).

Go deeper.

The signature

  • Spec §15.6 and the byte-for-byte reference match: packages/hyperliquid/src/Signing.ts:1-30
  • Domain and Agent constants: Signing.ts:36-47
  • The action-hash bytes: Signing.ts:88-109
  • Wire shape with v biased to 27/28: Signing.ts:243-276
  • Address derivation, signer of record: Signing.ts:231-237
  • Cloids are correlation ids, not idempotency keys: packages/hyperliquid/src/Cloid.ts:8-29

The lane and the key

  • Lane construction, semaphore, fast-forward: packages/hyperliquid/src/NonceCoordinator.ts:75-105
  • The single shared key location: packages/hyperliquid/src/KeyLocation.ts:1-42
  • The tryPromise regression story: apps/server/src/trading/InterimSignerConfig.ts:63-86
  • Env parsing and address cross-check: InterimSignerConfig.ts:125-172
  • Resolution order and the vitest guard: InterimSignerConfig.ts:199-259
  • The single sign site: apps/server/src/trading/HyperliquidExecutionService.ts:503-552

The gates

  • Ceiling resolution over an env bag: apps/server/src/trading/TestnetAuthority.ts:41-61
  • Bind, conflict, freeze: apps/server/src/trading/TradingAuthorityBinding.ts:279-420
  • Exhaustion enforcement: apps/server/src/trading/TradingExecutionGuard.ts:1-14
  • Decision lease and queueing: apps/server/src/trading/TradingTurnCoordinator.ts:1-29
  • Single-writer lock, takeover, the corner: apps/server/src/trading/TradingRuntimeLease.ts:1-60
  • Fork paths and silent collisions: packages/shared/src/forkPaths.ts:1-26

Where this connects. The nine-step submit sequence that ends at this lane is traced on Execution. The loss budget whose exhaustion blocks signing is Risk control. The typed intents that reach the corridor are Contracts & types. The decision lease exists to serialize Missions, watches, wakes. Everything on this page refuses safely without a signer, which is why Research mode works key-less. Remote sessions reach this machine through Relay & environments. The guarantees rest on the invariants collected in Safety invariants.