The one path that spends capital
Most of T3 Trade records decisions and state. Capital moves only when one service signs an order and sends it to the exchange. Its header states the boundary plainly: "This is the only code path that spends testnet capital." This page follows that path from an agent request to a recorded outcome.
Sources:
apps/server/src/trading/HyperliquidExecutionService.ts:129-273,
apps/server/src/trading/HyperliquidExecutionService.ts:5-15,
apps/server/src/trading/HyperliquidExecutionService.ts:301-305,
apps/server/src/trading/TradingExecutionOutcome.ts:43-47.
Seven entry points share one submission path
The module doc of HyperliquidExecutionService spends its
first line on scope: "This is the only code path that spends testnet capital"
apps/server/src/trading/HyperliquidExecutionService.ts:4.
Seven public submit functions live behind that claim: the harness entry, the cancel,
the protective stop, the deterministic exit, the working-order replacement, and two
manual-lane siblings
apps/server/src/trading/HyperliquidExecutionService.ts:129-273.
Every entry point uses the same final submission sequence. One signer resolution,
fail-closed when no interim signer is armed
apps/server/src/trading/HyperliquidExecutionService.ts:503-519. One serialized nonce lane, and the comment is blunt about why the block exists
once: "a second copy of this block would be a second chance to sign outside the lane"
apps/server/src/trading/HyperliquidExecutionService.ts:521-552. One response inspector that reads every per-order status
apps/server/src/trading/HyperliquidExecutionService.ts:440-489.
This shared sequence replaces duplicated signer logic. The signer resolution "used to
be pasted at six call sites"
apps/server/src/trading/HyperliquidExecutionService.ts:503-507. Cancels and protective stops now take the same lane as orders explicitly, "so
cancels serialize with orders and never race a nonce"
apps/server/src/trading/HyperliquidExecutionService.ts:972-975, and protection "never races an order for a nonce"
apps/server/src/trading/HyperliquidExecutionService.ts:1014-1016. The signing material itself is covered on
Signer & authority.
The signature domain is derived from the configured endpoints, not from a second flag,
"so the signature domain can never disagree with the exchange the signed action is
sent to"
apps/server/src/trading/HyperliquidExecutionService.ts:499-501. Testnet is the only execution target the repository wires.
apps/server/src/trading/HyperliquidExecutionService.ts:129-273,
apps/server/src/trading/HyperliquidExecutionService.ts:140-153,
apps/server/src/trading/HyperliquidExecutionService.ts:97-107.
One order, from request to recorded result
This is the full lifecycle of one entry in code order. Steps 1 through 6 prepare and validate the request. Steps 7 through 15 submit it. The final steps update local state from the exchange and return the recorded result to the agent runtime.
The harness names a trade, not an order
Inside a harness turn, the trading_enter MCP tool receives
what a language model can actually state: a market, a side, a stop price, and
optionally a size or notional. Urgency is the model's vocabulary, never a wire
concept: "now crosses, patient rests"
apps/server/src/trading/TradingEntryService.ts:286-290.
apps/server/src/trading/TradingEntryService.ts:69-83
The server derives eight values the model cannot know
The execution request once required eight values that the model could not reliably
provide. They include current strategy and authority versions, the run that holds
the decision lease, an execution sequence, a marketable limit price, a size within
four limits, the planned loss, and exchange-valid precision. "Every one of them is a
refusal when it is wrong, and all eight are things the server already knows"
apps/server/src/trading/TradingEntryService.ts:5-11.
TradingEntryService.prepare reads the mission, the lease,
the book, the account and the budget, derives a feasible size against the ceilings
apps/server/src/trading/TradingEntryService.ts:362-399,
and runs the same checklist the submit will run
apps/server/src/trading/TradingEntryService.ts:423-428.
apps/server/src/trading/TradingEntryService.ts:2-24
The tool waits for a recorded execution result
The tool handler dispatches a
trading.execution.requested command onto the orchestration
spine, then blocks on the outcome service instead of answering itself. The comment
at the dispatch site states the rule: "The dispatch is a question; the reactor
answers it on its own worker", because a harness told "submitted" for a request that
was refused at preview "goes on to manage a position that does not exist"
apps/server/src/mcp/toolkits/trading/handlers.ts:856-872.
The wait is bounded at 20 seconds; past it, the answer is that the execution is
still in flight
apps/server/src/trading/TradingExecutionOutcome.ts:43-47.
apps/server/src/mcp/toolkits/trading/handlers.ts:849-888
apps/server/src/trading/TradingExecutionReceipts.ts:2-19
The mission moves to executing, then reconciles
TradingMissionReactor.processExecutionRequested first
advances the mission from waiting or
position_open to executing,
before preview, "so preview reads the status this request just established"
apps/server/src/trading/TradingMissionReactor.ts:1871-1883. Then it reconciles canonical exchange state with the
before_execution trigger, "so preview and the budget gate
see reconciled truth, not a stale local cache"
apps/server/src/trading/TradingMissionReactor.ts:1890-1895. The event spine that carries the request is covered on
Event spine; the status machine those mission
states belong to is on Missions watches wakes.
apps/server/src/trading/TradingMissionReactor.ts:1865-1895
Exhaustion blocks increases before a nonce is spent
With the loss budget evaluated from reconciled state, the §16.4 guard runs:
guard.guardAction blocks position-increasing actions when
the budget is exhausted, "before the submit sequence spends a nonce.
Cancel/reduce/close pass through"
apps/server/src/trading/TradingMissionReactor.ts:1951-1953. What the budget counts and how it exhausts is the
Risk control story.
apps/server/src/trading/TradingMissionReactor.ts:1897-1953
The action type selects one of five write paths
close goes to the guard's reduce-only close and
reduce to its sized variant; both are forced reduce-only
on the wire and sized against the canonical position, which is what stops a reduce
from crossing through flat into an unprotected reversal.
cancel and modify_stop are
deterministic paths answered directly. Everything else, the entries and scale-ins,
reaches execution.submitOrder
apps/server/src/trading/TradingMissionReactor.ts:1983-2008.
apps/server/src/trading/TradingMissionReactor.ts:1989-2008
Resolve the signer, fail-closed
submitOrder begins at step 1 of the module's own numbered
list: resolve the interim signer, and if none is armed the sequence stops with
signer_not_configured rather than a defect
apps/server/src/trading/HyperliquidExecutionService.ts:870-871. No signer, no signed action, no capital spent.
apps/server/src/trading/HyperliquidExecutionService.ts:5-15
apps/server/src/trading/HyperliquidExecutionService.ts:503-519
The checklist runs against the live book
Market metadata and a fresh best bid and offer are resolved next
apps/server/src/trading/HyperliquidExecutionService.ts:873-879, and the §16.3 preview runs with that book in context
apps/server/src/trading/HyperliquidExecutionService.ts:881-896. The checklist is 14 ordered items for entries, from
mission_active through leverage, gross notional, the
per-position planned-loss ceiling, reservations plus the proposal within budget, and
a valid stop
apps/server/src/trading/TradingPreviewService.ts:39-66.
apps/server/src/trading/TradingPreviewService.ts:39-66
Intent becomes a wire order
Inside submitIntent,
mapOrder turns the intent into the exchange's shape: IOC
or GTC, priced off the fresh BBO with the slippage allowance, sized to the
exchange's precision, and stamped with the deterministic cloid
apps/server/src/trading/HyperliquidExecutionService.ts:577-603. The manual path overrides the cloid with one derived from the account instead of
the mission
apps/server/src/trading/HyperliquidExecutionService.ts:584-594. The typed shapes that travel this boundary, intent to record, are covered on
Contracts.
apps/server/src/trading/HyperliquidExecutionService.ts:562-603
The stop is re-checked at the price going on the wire
Preview already ran the stop check against the limit the harness requested. This
gate runs the same checkStopInformation against the price
actually going on the wire, which for a marketable IOC is the BBO-derived limit, not
the requested one. An increase whose stop stopped making sense between preview and
mapping "is refused here rather than signed. Nothing has been persisted and no nonce
has been spent at this point"
apps/server/src/trading/HyperliquidExecutionService.ts:605-624.
apps/server/src/trading/HyperliquidExecutionService.ts:605-624
An increase ships with its stop in one grouped action
When the action increases a position and carries a stop, the entry and a reduce-only
trigger child go out together in one normalTpsl grouped
action
apps/server/src/trading/HyperliquidExecutionService.ts:626-666, with the child's cloid derived from the same triple plus the suffix
_protect
apps/server/src/trading/HyperliquidExecutionService.ts:276-281. The child is sized to the requested size because no fill has happened yet, and
the code is explicit that submitting it "proves nothing about whether the child is
live"; reconciliation against canonical state is what confirms protection.
apps/server/src/trading/HyperliquidExecutionService.ts:626-666
The record and the reservation exist before the signature does
Step 5 of the module's list: persist the execution record and the risk reservation
before signing. The record is inserted with status
reserved
apps/server/src/trading/HyperliquidExecutionService.ts:668-732, keyed by an idempotency key minted here, and the insert upserts on conflict
apps/server/src/trading/HyperliquidExecutionService.ts:343. The reservation row follows
apps/server/src/trading/HyperliquidExecutionService.ts:774-784. If the process dies between these writes and the POST, the system still knows an
order may be in flight.
apps/server/src/trading/HyperliquidExecutionService.ts:320-354
apps/server/src/trading/HyperliquidExecutionService.ts:356-384
The row is read back, and only three statuses may proceed
Immediately after the insert, the record is selected back by idempotency key. If its
status is anything other than previewed,
reserved or signed, the
persisted record is returned and nothing is submitted
apps/server/src/trading/HyperliquidExecutionService.ts:734-772. That read-back is the whole retry mechanism, and it is local: the exchange is
never asked whether it saw this action before.
apps/server/src/trading/HyperliquidExecutionService.ts:290-305
One nonce lane, then the wire
The action, single order or grouped pair, is signed inside
signInNonceLane
apps/server/src/trading/HyperliquidExecutionService.ts:786-798. The record is marked submitted
apps/server/src/trading/HyperliquidExecutionService.ts:801, and only then is the signed action POSTed to /exchange
apps/server/src/trading/HyperliquidExecutionService.ts:803. The lane keeps nonces strictly monotonic and serializes the whole assign, sign
and submit section
packages/hyperliquid/src/NonceCoordinator.ts:4-18.
apps/server/src/trading/HyperliquidExecutionService.ts:798-811
Every per-order status, attributed by position
The response is inspected leg by leg. The exchange returns one status per order in
submission order, so each row is attributed to the leg sent at that index, because
"a filled row does not reliably echo the cloid back"
apps/server/src/trading/HyperliquidExecutionService.ts:440-452. An action-level rejection that carries no rows at all is reported as a failure,
not an empty success
apps/server/src/trading/HyperliquidExecutionService.ts:467-472. The entry leg, not "some leg succeeded", decides the record's final status of
filled, accepted or rejected, and a rejection releases the risk reservation
apps/server/src/trading/HyperliquidExecutionService.ts:816-844.
apps/server/src/trading/HyperliquidExecutionService.ts:440-489
Reconcile, protect, re-read the budget, open the latch
Back in the reactor, the after_submission reconcile
converges local state to the exchange
apps/server/src/trading/TradingMissionReactor.ts:2018-2023, then protectIncrease confirms protection for the actual
canonical position, because the grouped child that went out with the entry proves
nothing
apps/server/src/trading/TradingMissionReactor.ts:2025-2032. The budget is re-read, and if it is now exhausted the mission is blocked and told
so
apps/server/src/trading/TradingMissionReactor.ts:2034-2070. Finally the receipt latch opens and the waiting tool reads the durable record
once
apps/server/src/trading/TradingExecutionReceipts.ts:10-19.
What reconciliation repairs along the way is the
Reconciliation page.
apps/server/src/trading/TradingMissionReactor.ts:2018-2070
The record exists before the signature does
Most systems sign first and log after. This one writes the execution record and the
risk reservation first, then signs
apps/server/src/trading/HyperliquidExecutionService.ts:668-784. The ordering is the crash story: a process that dies between the INSERT and the
POST leaves a record still inside the retry window, and any later attempt of the same
action reads that row back and refuses to submit again
apps/server/src/trading/HyperliquidExecutionService.ts:734-772. The unknown is contained by a durable fact instead of a hope.
The statuses carry exact meaning. filled is written when the
exchange says filled, because an IOC reports its own outcome in the submit response
and "there is no later reconciliation question to leave open"
apps/server/src/trading/HyperliquidExecutionService.ts:824-832. accepted now "means only what it says", acknowledged and
resting on the book. rejected releases the reservation the
same instant
apps/server/src/trading/HyperliquidExecutionService.ts:842-844, so a refused order never keeps claiming budget.
The design deliberately prevents retries after the status becomes submitted. After the signed action is POSTed and before the response is read, the record says submitted and nothing more. A crash there is indistinguishable from success at the exchange, so the record is treated as spent.
submitted is deliberately absent from
PRE_SUBMISSION_STATUSES, even though its outcome is
unknown: "An unresolved submission is exactly the case that duplicates a position if
it is retried blind, so the retry returns the unresolved record and leaves it to
reconciliation to settle"
apps/server/src/trading/HyperliquidExecutionService.ts:296-305.
The three cyan states are the retry window
apps/server/src/trading/HyperliquidExecutionService.ts:301-305. In practice the record is written as reserved
apps/server/src/trading/HyperliquidExecutionService.ts:725
and jumps to submitted after signing
apps/server/src/trading/HyperliquidExecutionService.ts:801;
the entry leg decides the final state
apps/server/src/trading/HyperliquidExecutionService.ts:833-841.
Idempotency is local, and it is a row in SQLite
The exchange does not deduplicate marketable orders, so every guard against double execution lives in this repository. Six mechanisms carry that load together.
The deterministic idempotency key, built as idem_ plus
the mission id, the per-mission monotonic execution sequence, and the action type
apps/server/src/trading/HyperliquidExecutionService.ts:673-676. The manual lane mints the same shape under
idem_manual_ with the account as owner. Retries of one
action compute the same key and land on the same row.
The record insert carries
ON CONFLICT(idempotency_key) DO UPDATE SET updated_at
apps/server/src/trading/HyperliquidExecutionService.ts:343, and the reservation insert is idempotent on the execution id
apps/server/src/trading/HyperliquidExecutionService.ts:373. A second attempt of the same action touches the same rows rather than growing
new ones.
Right after persisting, the row is selected by key and its status decides
everything: only previewed,
reserved or signed may proceed
to the wire
apps/server/src/trading/HyperliquidExecutionService.ts:734-772. The module doc states the reason in one line: "The exchange itself does NOT
deduplicate"
apps/server/src/trading/HyperliquidExecutionService.ts:17-23.
A client order ID, or cloid, links an exchange order to its local execution
record. It is "a correlation id, not an idempotency key". Hyperliquid enforces
cloid uniqueness only among resting orders, and a marketable IOC never rests, so a
resubmitted one "is verified live to open a second order that fills again". What
the stable cloid buys is reconciliation: the Info API echoes it on the order and
its fills, so a fill joins the execution record that caused it
packages/hyperliquid/src/Cloid.ts:8-15. The derivation
is SHA-256 over the mission, sequence and action type, first 16 bytes, hex with
the 0x prefix packages/hyperliquid/src/Cloid.ts:17-20.
The prefix is not decoration. A prior version returned a bare 32-character hex
string; the live entry it produced was accepted by the exchange, but reading it
back showed it had been stored as "cloid": null, "so
retry-deduplication rested entirely on local idempotency-key convergence, never on
exchange-side cloid match"
packages/hyperliquid/src/Cloid.ts:22-29. The 0x form is
now applied at the single boundary where cloids are made, so one representation
flows to the wire, the database, the reconciler's join and the UI.
Idempotency stops double execution of the same action; the nonce lane stops two
different actions from racing one signature slot. Nonces are strictly monotonic,
fast-forwarded to current Unix milliseconds when the wall clock has moved past the
last issued value, and the whole assign, sign, submit section runs under a single
permit
packages/hyperliquid/src/NonceCoordinator.ts:4-18. A
restart needs no persisted nonce state: "real time has always moved past anything
signed before it"
packages/hyperliquid/src/NonceCoordinator.ts:8-13.
No market order exists, so price is policy
Hyperliquid has no market order type. Every "market" fill is a limit order priced to cross, and the crossing distance is a number somebody chose. The repository keeps those choices visible, and owns two further lanes beyond the immediate entry.
Crossing is a policy number
"There is no market order on Hyperliquid. Every 'market' fill is a limit order
priced to cross, and the crossing distance is a policy number: too tight and the
order rests unfilled, too wide and a thin book fills it somewhere the loss model
never anticipated"
apps/server/src/trading/IocSlippageConfig.ts:4-7. The two
allowances live in one module so the two paths are "visibly two different policies
rather than two accidents", tunable per testnet run without a rebuild.
apps/server/src/trading/IocSlippageConfig.ts:1-29
The exit is twice as wide
The deterministic exit may cross 100 bps against the entry's 50, and the asymmetry
is deliberate: "An entry that misses can be retried at leisure; an exit that misses
leaves the exposure it was placed to escape"
apps/server/src/trading/IocSlippageConfig.ts:18-19.
Defaults 50 and 100
apps/server/src/trading/IocSlippageConfig.ts:26-29; a knob
that fails to parse falls back rather than refusing to trade
apps/server/src/trading/IocSlippageConfig.ts:53-56.
Working orders: patience with an owner
A patient entry is a post-only ALO at the near side, and a post-only order only
fills when the market comes to it.
TradingWorkingOrderService owns the wait and gives every
resting entry one of four answers per pass: nothing while it is young, a re-price at
the current near side, a cross once the stated max wait is up, or an abandon
apps/server/src/trading/TradingWorkingOrderService.ts:4-19. Entries cancel first and place after, because overlapping entries are double
exposure, and every replacement re-places the entry with its grouped stop child
together
apps/server/src/trading/TradingWorkingOrderService.ts:49-69.
The manual lane: the user is the strategy
The manual ticket is the mirror of the agent's prepare with the mission machinery
removed: no mandate, no lease, no plan target. What stays is everything about the
order, and one rule the doc sets in bold: "every manual entry carries a stop",
refused at preview without one
apps/server/src/trading/TradingManualEntryService.ts:5-16.
The stop field's own comment: "Mandatory. A ticket without a stop is refused, never
defaulted"
apps/server/src/trading/TradingManualEntryService.ts:63-65. Manual rows persist mission_id NULL with the account as
owner
apps/server/src/trading/HyperliquidExecutionService.ts:97-107, under D4 exclusivity where a market held by a mission refuses the ticket with
market_owned_by_mission
apps/server/src/trading/TradingManualEntryService.ts:13-16.
Grouped legs, read positionally
A grouped submission sends the entry and its stop child in one action, and the
response returns one status per order "in submission order". Each row is
attributed to the leg sent at that index, and the code prefers the leg T3 sent
over any echoed cloid, "because a mismatch would mean the positional contract
broke, which the caller must not paper over with a blank cloid"
apps/server/src/trading/HyperliquidExecutionService.ts:440-489.
Two failure shapes are refused outright: an action-level error such as
insufficient margin, and a response with zero per-order rows, which "is reported
as a failure rather than an empty success"
apps/server/src/trading/HyperliquidExecutionService.ts:461-472.
The bug log still quotes itself
Two old failures are preserved as comments where the current code answers them. Both are about the same sin: reporting an outcome the system did not have.
The trading_enter tool used to return a synthesized status: "submitted" the moment the dispatch landed, before preview, before signing, before the exchange had been asked anything. Downstream refusals were logged server-side and never reached the harness, so a mission whose every entry was being rejected read, to the harness, as a mission that had entered.
"That is the worst possible failure mode for a trading agent: it believes it holds a position it does not hold." apps/server/src/trading/TradingExecutionOutcome.ts:4-11
The fix is the outcome service this page has already walked: the tool blocks on a latch
per mission and sequence, opened only when the reactor reaches a durable conclusion, and
reads the persisted record once
apps/server/src/trading/TradingExecutionReceipts.ts:2-19. The
waiting itself used to be its own small scandal, re-reading the database every 250
milliseconds for twenty seconds: "up to eighty queries per execution, each one a guess".
A response that says filled used to be recorded as
accepted. Since nothing in the system ever wrote
filled, the record stayed in flight forever, its risk
reservation stayed reserved, and preview's pending-execution item "refused every
subsequent intent for the mission"
apps/server/src/trading/HyperliquidExecutionService.ts:824-832. The reactor's side of the same wound: counting a resting
accepted order as a blocking pending execution "is what made
one filled entry permanently lock the mission out of every subsequent write"
apps/server/src/trading/TradingMissionReactor.ts:1922-1928.
Filled is now terminal and written at once; accepted means only "acknowledged and
resting on the book". More of these live on
Failure stories.
Go deeper by reading
The submit sequence
-
apps/server/src/trading/HyperliquidExecutionService.ts:1-26the module doc: the nine ordered steps and the no-dedupe finding -
apps/server/src/trading/HyperliquidExecutionService.ts:562-863submitIntent, the shared tail from mapping to the final status -
apps/server/src/trading/HyperliquidExecutionService.ts:865-907submitOrder: signer, fresh book, preview, then the tail -
apps/server/src/trading/HyperliquidExecutionService.ts:440-489inspectOrderStatuses: positional leg attribution
Identity and idempotency
-
packages/hyperliquid/src/Cloid.ts:1-32the cloid contract, the correlation-id rule, the 0x prefix finding -
packages/hyperliquid/src/NonceCoordinator.ts:1-21the serialized lane and the millisecond fast-forward -
apps/server/src/trading/HyperliquidExecutionService.ts:290-384PRE_SUBMISSION_STATUSES and the two idempotent inserts -
apps/server/src/trading/TradingExecutionReceipts.ts:1-22the latch that replaced eighty polls
Policy and lanes
-
apps/server/src/trading/IocSlippageConfig.ts:1-29the two crossing allowances and why they differ -
apps/server/src/trading/TradingWorkingOrderService.ts:1-75the working loop: four answers, cancel-first ordering -
apps/server/src/trading/TradingManualEntryService.ts:1-19the manual ticket and its one rule without exceptions -
apps/server/src/trading/TradingPreviewService.ts:39-66the 14-item entry checklist plus the exit-only rows
The reactor side
-
apps/server/src/trading/TradingMissionReactor.ts:1865-2071processExecutionRequested, intake through blocked -
apps/server/src/trading/TradingExecutionOutcome.ts:1-60the bounded waiter and its fallback sweep -
apps/server/src/mcp/toolkits/trading/handlers.ts:849-888executeIntent: dispatch the question, await the answer