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 1337lane: semaphore of 1key: ~/.t3trade/secrets/
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).
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
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.