Compare commits

...
Author SHA1 Message Date
Michael ShimelesandClaude Opus 5.5 a12b5d14e4 feat(hl): built-in Turnkey wallets on testnet (#38)
* docs: design for Hyperliquid trading (crypto + HIP-3 stock perps)

Design only, no feature code. Covers custody (client-side API wallet
signing, recommended), agent approve/revoke flow, asset routing for
core/spot/HIP-3, order ticket, user streams for POS/ORD/ACTV, safety
rails, testnet-only test plan, phases, and open questions including the
Hyperliquid ToS restriction on Ontario.

* docs(hl-trading): plan the market key so HL perp rows reprice and trade

Greptile: market_key() returns None for Future and live_price/row
actions depend on it. Add Position.market and the portfolio_view work.

* docs(hl-trading): keep testnet and mainnet quotes in separate maps

Greptile: same market keys on both networks would let one overwrite the
other in the shared quote map. Plan a network-aware live_price and
mainnet-only gateway requests, with interleaved-update tests.

* feat(hl): add tr-hl, a testnet-only Hyperliquid signing and trading crate

tr-hl signs and sends Hyperliquid actions from the device. It covers
L1 phantom-agent signing and user-signed approveAgent, the order,
cancel, cancelByCloid, modify, updateLeverage, scheduleCancel and noop
actions, asset ids read from perpDexs and meta, tick and lot rounding,
info and exchange clients, a user-stream reducer and a nonce manager.

Only testnet exists in this crate. The endpoints are the testnet
hosts, the phantom agent source is always "b", hyperliquidChain is
always "Testnet", and Network has one variant. A test greps the crate
for mainnet hosts.

The caps are constants and the trader enforces them before it signs.
Orders are capped at $100 notional, leverage at 3x and market slippage
at 1%. The builder field is never sent. Signatures match the Python
SDK's published vectors, plus an approveAgent vector generated with
SDK 0.24.0. The crate builds for wasm32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat: Hyperliquid testnet trading in the app

Hyperliquid joins LIVE next to SnapTrade, with its own opt-in in
Settings > Trading > Hyperliquid and an amber TESTNET badge on the
toolbar, the account, the ticket and every confirmation.

- Connect an address read-only. The account's positions, orders,
  fills and funding then show in PORT, POS, ORD and ACTV, from
  Hyperliquid's websocket and never through Terminal's server.
- Approve an agent on the desktop by pasting a throwaway testnet main
  key once. Terminal wipes it after signing and never stores it. In
  the browser the wallet signs instead (EIP-1193). The agent key lives
  in the macOS Keychain or the Linux Secret Service, and in browser
  memory or, by opt-in, IndexedDB under a non-extractable WebCrypto
  key. There is no file fallback.
- The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit
  (GTC, IOC, post-only), stop loss and take profit, reduce-only and
  "Set 3x" leverage. HIP-3 rows read "PERP · HL xyz (perpetual, not a
  share)".
- Every order, cancel, cancel-all and leverage change asks for one
  confirmation. The worker re-reads the testnet mid and leverage and
  checks the caps again right before it signs.
- KILL and a button in ORD cancel every open order and turn
  Hyperliquid orders off. The dead man's switch is opt-in.
- Testnet rows price only from testnet mids. Position.market now
  carries the exact key, and live_price picks the map by network.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): mask addresses in Hyperliquid messages, toasts and logs

Hyperliquid's errors name full addresses ("User or API Wallet 0x… does
not exist", "Must deposit… User: 0x…"). mask_addresses turns each one
into 0x1234…abcd. Exchange summaries, the worker's logs and every app
message that shows a Hyperliquid error now go through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): keep Hyperliquid out of SnapTrade's settings sections

The offscreen render showed the Hyperliquid account under SnapTrade's
Brokerage access, with SnapTrade's "reconnect with trading access" advice.
That section and the default-account picker now list SnapTrade accounts
only. Hyperliquid has its own section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(hl-trading): record the v1 decisions and add screenshots and a wasm CI check

Section 15 of the design now records Micky's twelve decisions as
"Decided (2026-11-02)". The sections they change are updated: testnet
only, Secret Service with no file fallback on Linux, a pasted throwaway
key on the desktop for now, hard caps, an opt-in dead man's switch and
the kill switch. A new section 16 lists what v1 builds and what is still
missing.

docs/img/hl-*.png holds the before (origin/master) and after
screenshots. CI now builds and lints tr-hl for wasm32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): read the agent key from the store once per account

When the store had no key for the approved agent, the app asked again
on every frame, which meant a Keychain or Secret Service read per frame.
It now remembers the account it read and asks again only after a
connect, an approval or a revoke.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): fix the kill switch, uncertain sends, approvals and cap valuation

Fixes from Greptile's first review of #37:

- Totals value a margined position (a perp) by its P&L, not its
  notional. Hyperliquid's cash is its collateral (equity less
  unrealised P&L), so a reprice moves equity only by the P&L change.
- KILL reads the open orders fresh on every dex, cancels them, and
  reads again (three rounds at most) before it reports what is left.
  Signed actions now run one at a time, and an order confirmed before
  a KILL that hasn't been signed yet is refused.
- An approval answer applies only to the account that asked for it,
  and Remove is disabled while one is in flight. The app also never
  signs with a key that isn't the approved agent's.
- An uncertain send blocks new orders until orderStatus finds its
  cloid, or until its 30 s expiresAfter window has passed without it.
- Every order needs a current mid before it is valued against the $100
  cap. Prices that round to zero are refused, and rounding zero no
  longer overflows.
- The keyring check runs in the worker, not on the UI thread.
- A cancel-all skips a market the registry doesn't know instead of
  failing, and the pasted key field's undo history is wiped once the
  key has signed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): keep the dead man's switch armed while an order is unresolved

From Greptile's second review of #37: an order with no clear answer
blocked new sends through the same guard the dead man's switch reads,
so the switch cleared its scheduled cancel just when a possibly
accepted order needed it.

The guard is now split. Whether this device can sign arms the switch,
and allows cancels and leverage changes. Only new orders and modifies
also wait for an unresolved order. KILL always goes ahead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): size dollar amounts at the mid and let KILL skip stalled requests

Fixes from Greptile's fourth review of #37 (at f636b7c):

- A dollar amount on the Hyperliquid ticket (BUY xyz:INTC $10) became
  that many contracts. The ticket now sizes it at the testnet mid,
  rounded down to the market's size step. With no mid yet, or under one
  step, it stays a dollar amount and the ticket can't send it. Changing
  the market sizes the amount again.
- Every /info and /exchange request has its own 10 s deadline. The
  browser's fetch had none, so a stalled request could hold the
  worker's only turn forever.
- KILL no longer waits for that turn. It cancels at once, and an order
  confirmed before it gives up, even one still reading the market. If
  an order was already being sent, KILL cancels again once its answer
  comes, or once its expiresAfter has passed if the send got no clear
  answer. Single cancels and the dead man's switch don't wait for
  orders either. Orders, modifies and leverage changes still go one at
  a time, and an order with no clear answer still blocks new ones until
  it is found or expires.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): size dollars for the market named now and keep timed cancels in order

Fixes from Greptile's review of f1ff1b3:

- The ticket reads its market at the start of a frame, before an edit
  to the market field. A dollar amount could then be sized at the old
  market's mid ($10 at a $2 mid is 5 units, worth $20 on a $4 market).
  settle_size now sizes only with the market the ticket names, so the
  next frame sizes it for the new one. The ticket's estimate also uses
  that market.
- Dead man's switch changes ran side by side, so a slow clear could
  land after a newer schedule and remove it. They now go out one at a
  time in the order the app made them, on a lock of their own that
  never waits for orders. A change that reaches the worker after a
  newer one is dropped. After a change with no clear answer, the next
  waits until it can no longer land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): give each dead man's switch refresh a fresh deadline

From Greptile's review of 2f7cab6: after a change with no clear answer,
every later change waited until that one could no longer land. Refreshes
queue every 30 s, so with lost replies they went out every 35 s or so,
each with the deadline it got when queued, and the timer could fire
while Terminal was still running.

- ScheduleCancel now carries how far ahead to cancel. The worker adds
  it to the time of signing, so a refresh that waited still gets a
  fresh deadline.
- A newer change replaces one still waiting for its turn, so a backlog
  collapses into the newest.
- Only a change of the other kind waits out a lost one: a timer after a
  clear, or a clear after a timer. A refresh that lands late only moves
  the timer back by one refresh, so refreshes don't wait for each other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): make every dead man's switch change wait out a lost one

From Greptile's review of 73b71b8: letting a refresh go out before an
earlier timer with a lost reply could no longer land was not safe. That
timer could land last (the clocks may differ by seconds) and pull the
deadline in, so the switch could fire while Terminal was running.

Every change now waits until a lost earlier change can no longer land,
whichever kind either one is. That is safe now that each change is
signed, with its deadline, when it goes out, and a newer change
replaces any still waiting, so a backlog can't build up stale
deadlines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(hl): sign approveAgent through a built-in Turnkey wallet, testnet only

Add tr-turnkey, the client side of Terminal's built-in wallets. The device
makes a P-256 key, and every Turnkey request carries a stamp made with it.
The session token Turnkey issues for that key lasts 15 minutes, and Terminal
accepts it only if it is bound to its own key. The client reads whoami and
the wallet's Ethereum account, and signs one digest with the request
alloy-signer-turnkey sends (SIGN_RAW_PAYLOAD_V2, hex payload, no hashing).
On the desktop the browser returns the session to a listener on 127.0.0.1
that checks a 256-bit state. A mock feature adds a local stand-in for
Turnkey's API and sign-in page for tests and screenshots.

tr-hl gets sign_approve_agent_with, the only way a remote wallet's
signature enters the crate. The signer sees only the digest of a testnet
approveAgent, and the answer is used only if it recovers the wallet.
approve_agent_hash now also refuses every signatureChainId except 0x66eee.

The mock wallet holds the Python SDK's public test key and reproduces the
SDK's approveAgent vector. Mainnet and 0xa4b1 approvals never reach the
wallet, and the new code names no mainnet host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(hl): design built-in Turnkey wallets for testnet trading

Record Micky's decision of 2026-11-04 in §15: built-in wallets only, no
browser wallet and no pasted key. The new §4.5 covers who holds which key,
the browser sign-in that hands a session to a key made on the device, why
a loopback redirect beats a terminal:// link, what Turnkey signs and how
often, and the export through Turnkey's iframe. It explains why no
Terminal server needs a Turnkey key (the Auth Proxy holds the one that
creates sub-organizations) and sketches a scoped signup function as an
unbuilt fallback. Agents now last 180 days by default, since each approval
is a paid Turnkey signature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(hl): replace the browser wallet and pasted key with the built-in wallet

Settings › Trading › Hyperliquid now starts with "Create Wallet / Sign In".
On the desktop it opens Terminal's sign-in page in the system browser and
waits on a 127.0.0.1 loopback for the session; the web build opens the page
in a popup and takes its postMessage. Terminal reads the wallet's address
from Turnkey with requests stamped by its own key, keeps the 15-minute
session in the OS secret store (memory on the web) and deletes it once it
expires.

"Approve Trading Key" makes the agent key on the device and has the wallet
sign approveAgent through Turnkey, signing in first if there is no session.
Revoke goes the same way. Agents now last 180 days by default, since each
approval is a paid Turnkey signature. Orders are still signed only by the
agent key.

Deposit shows the address in full with a QR code and a copy button, the
testnet perp account value and withdrawable from the stream, and spot USDC
(new spotClearinghouseState read). Back Up Wallet warns, then opens the
page's export mode.

The web's EIP-1193 Connect Wallet and the desktop's pasted testnet key are
gone. MainKey is left for tests and the testnet_check example. Watching an
address read-only stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(site): add the wallet sign-in and backup page

/wallet/ signs the user in with an email code or a passkey through
@turnkey/core and Turnkey's Auth Proxy. A new user gets a sub-organization
with one Ethereum wallet. The page then calls stamp_login for the app's
public key, so the app gets a 15-minute session only its own key can use,
and logs itself out. Desktop answers go to the loopback port, web answers
to the opener with postMessage on the same origin.

Its parameters come from the URL fragment, which never reaches a server,
analytics or a Referer, and the page clears it on load. In export mode
the page shows the recovery phrase in Turnkey's export iframe, so the
phrase never touches the page's code or any server.

It needs two public IDs in the Vercel project env and says so until they
are set. @turnkey/core is pinned to 2.13.1 and @turnkey/iframe-stamper to
2.11.1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci: build and lint tr-turnkey for wasm32

The browser build uses the wallet crate too, so check it the way tr-hl is
checked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): keep the trading key when the wallet doesn't sign a revoke

Revoke used to forget the agent before Turnkey signed anything, so a failed
or expired sign-in left the app without its agent while the message said
nothing changed. Orders now turn off at once, and the agent is forgotten
only when the revoke goes through. An approval the wallet didn't sign now
says so instead of reading as Hyperliquid's refusal. The mock-URL warnings
no longer print the URL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): link the sign-in page at /wallet, its canonical URL

The site serves the page at /wallet and redirects /wallet/ there. Browsers
keep the fragment across that redirect, but there is no reason to rely on
it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(site): only sign in, never sign up, when backing up a wallet

Export mode hid nothing: it offered "Create with a passkey", and the email
code path would create a new wallet for an unknown address. Backing up now
logs in to an existing wallet only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: add before and after screenshots for built-in wallets

Desktop shots are offscreen renders of the running app against a local
Turnkey stand-in. Web shots come from headless Chromium on a local build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): keep sign-in answers and wallet actions on the right wallet

From Greptile on #38:

- Removing the wallet reset the request counter, so a cancelled sign-in's
  late answer could carry the next sign-in's id and end it. Ids now keep
  counting, and removing the wallet cancels a sign-in in progress.
- A revoke or approval that needed a sign-in acted on whichever wallet the
  user signed in to. The sign-in now remembers the wallet the action is
  for, and refuses another one without changing anything.
- The browser build's sign-in never timed out. Both builds now give up
  after 10 minutes, the limit the waiting line shows.
- The sign-in page stayed on "Connecting your wallet" if Turnkey didn't
  issue the device's session. It now goes back to the sign-in controls
  with the error, so the user can retry or cancel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 08:38:08 -05:00
Michael ShimelesandClaude Opus 5.5 08f54643d9 feat: Hyperliquid trading on testnet (v1) (#37)
* docs: design for Hyperliquid trading (crypto + HIP-3 stock perps)

Design only, no feature code. Covers custody (client-side API wallet
signing, recommended), agent approve/revoke flow, asset routing for
core/spot/HIP-3, order ticket, user streams for POS/ORD/ACTV, safety
rails, testnet-only test plan, phases, and open questions including the
Hyperliquid ToS restriction on Ontario.

* docs(hl-trading): plan the market key so HL perp rows reprice and trade

Greptile: market_key() returns None for Future and live_price/row
actions depend on it. Add Position.market and the portfolio_view work.

* docs(hl-trading): keep testnet and mainnet quotes in separate maps

Greptile: same market keys on both networks would let one overwrite the
other in the shared quote map. Plan a network-aware live_price and
mainnet-only gateway requests, with interleaved-update tests.

* feat(hl): add tr-hl, a testnet-only Hyperliquid signing and trading crate

tr-hl signs and sends Hyperliquid actions from the device. It covers
L1 phantom-agent signing and user-signed approveAgent, the order,
cancel, cancelByCloid, modify, updateLeverage, scheduleCancel and noop
actions, asset ids read from perpDexs and meta, tick and lot rounding,
info and exchange clients, a user-stream reducer and a nonce manager.

Only testnet exists in this crate. The endpoints are the testnet
hosts, the phantom agent source is always "b", hyperliquidChain is
always "Testnet", and Network has one variant. A test greps the crate
for mainnet hosts.

The caps are constants and the trader enforces them before it signs.
Orders are capped at $100 notional, leverage at 3x and market slippage
at 1%. The builder field is never sent. Signatures match the Python
SDK's published vectors, plus an approveAgent vector generated with
SDK 0.24.0. The crate builds for wasm32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat: Hyperliquid testnet trading in the app

Hyperliquid joins LIVE next to SnapTrade, with its own opt-in in
Settings > Trading > Hyperliquid and an amber TESTNET badge on the
toolbar, the account, the ticket and every confirmation.

- Connect an address read-only. The account's positions, orders,
  fills and funding then show in PORT, POS, ORD and ACTV, from
  Hyperliquid's websocket and never through Terminal's server.
- Approve an agent on the desktop by pasting a throwaway testnet main
  key once. Terminal wipes it after signing and never stores it. In
  the browser the wallet signs instead (EIP-1193). The agent key lives
  in the macOS Keychain or the Linux Secret Service, and in browser
  memory or, by opt-in, IndexedDB under a non-extractable WebCrypto
  key. There is no file fallback.
- The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit
  (GTC, IOC, post-only), stop loss and take profit, reduce-only and
  "Set 3x" leverage. HIP-3 rows read "PERP · HL xyz (perpetual, not a
  share)".
- Every order, cancel, cancel-all and leverage change asks for one
  confirmation. The worker re-reads the testnet mid and leverage and
  checks the caps again right before it signs.
- KILL and a button in ORD cancel every open order and turn
  Hyperliquid orders off. The dead man's switch is opt-in.
- Testnet rows price only from testnet mids. Position.market now
  carries the exact key, and live_price picks the map by network.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): mask addresses in Hyperliquid messages, toasts and logs

Hyperliquid's errors name full addresses ("User or API Wallet 0x… does
not exist", "Must deposit… User: 0x…"). mask_addresses turns each one
into 0x1234…abcd. Exchange summaries, the worker's logs and every app
message that shows a Hyperliquid error now go through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): keep Hyperliquid out of SnapTrade's settings sections

The offscreen render showed the Hyperliquid account under SnapTrade's
Brokerage access, with SnapTrade's "reconnect with trading access" advice.
That section and the default-account picker now list SnapTrade accounts
only. Hyperliquid has its own section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(hl-trading): record the v1 decisions and add screenshots and a wasm CI check

Section 15 of the design now records Micky's twelve decisions as
"Decided (2026-11-02)". The sections they change are updated: testnet
only, Secret Service with no file fallback on Linux, a pasted throwaway
key on the desktop for now, hard caps, an opt-in dead man's switch and
the kill switch. A new section 16 lists what v1 builds and what is still
missing.

docs/img/hl-*.png holds the before (origin/master) and after
screenshots. CI now builds and lints tr-hl for wasm32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): read the agent key from the store once per account

When the store had no key for the approved agent, the app asked again
on every frame, which meant a Keychain or Secret Service read per frame.
It now remembers the account it read and asks again only after a
connect, an approval or a revoke.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): fix the kill switch, uncertain sends, approvals and cap valuation

Fixes from Greptile's first review of #37:

- Totals value a margined position (a perp) by its P&L, not its
  notional. Hyperliquid's cash is its collateral (equity less
  unrealised P&L), so a reprice moves equity only by the P&L change.
- KILL reads the open orders fresh on every dex, cancels them, and
  reads again (three rounds at most) before it reports what is left.
  Signed actions now run one at a time, and an order confirmed before
  a KILL that hasn't been signed yet is refused.
- An approval answer applies only to the account that asked for it,
  and Remove is disabled while one is in flight. The app also never
  signs with a key that isn't the approved agent's.
- An uncertain send blocks new orders until orderStatus finds its
  cloid, or until its 30 s expiresAfter window has passed without it.
- Every order needs a current mid before it is valued against the $100
  cap. Prices that round to zero are refused, and rounding zero no
  longer overflows.
- The keyring check runs in the worker, not on the UI thread.
- A cancel-all skips a market the registry doesn't know instead of
  failing, and the pasted key field's undo history is wiped once the
  key has signed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): keep the dead man's switch armed while an order is unresolved

From Greptile's second review of #37: an order with no clear answer
blocked new sends through the same guard the dead man's switch reads,
so the switch cleared its scheduled cancel just when a possibly
accepted order needed it.

The guard is now split. Whether this device can sign arms the switch,
and allows cancels and leverage changes. Only new orders and modifies
also wait for an unresolved order. KILL always goes ahead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): size dollar amounts at the mid and let KILL skip stalled requests

Fixes from Greptile's fourth review of #37 (at f636b7c):

- A dollar amount on the Hyperliquid ticket (BUY xyz:INTC $10) became
  that many contracts. The ticket now sizes it at the testnet mid,
  rounded down to the market's size step. With no mid yet, or under one
  step, it stays a dollar amount and the ticket can't send it. Changing
  the market sizes the amount again.
- Every /info and /exchange request has its own 10 s deadline. The
  browser's fetch had none, so a stalled request could hold the
  worker's only turn forever.
- KILL no longer waits for that turn. It cancels at once, and an order
  confirmed before it gives up, even one still reading the market. If
  an order was already being sent, KILL cancels again once its answer
  comes, or once its expiresAfter has passed if the send got no clear
  answer. Single cancels and the dead man's switch don't wait for
  orders either. Orders, modifies and leverage changes still go one at
  a time, and an order with no clear answer still blocks new ones until
  it is found or expires.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): size dollars for the market named now and keep timed cancels in order

Fixes from Greptile's review of f1ff1b3:

- The ticket reads its market at the start of a frame, before an edit
  to the market field. A dollar amount could then be sized at the old
  market's mid ($10 at a $2 mid is 5 units, worth $20 on a $4 market).
  settle_size now sizes only with the market the ticket names, so the
  next frame sizes it for the new one. The ticket's estimate also uses
  that market.
- Dead man's switch changes ran side by side, so a slow clear could
  land after a newer schedule and remove it. They now go out one at a
  time in the order the app made them, on a lock of their own that
  never waits for orders. A change that reaches the worker after a
  newer one is dropped. After a change with no clear answer, the next
  waits until it can no longer land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): give each dead man's switch refresh a fresh deadline

From Greptile's review of 2f7cab6: after a change with no clear answer,
every later change waited until that one could no longer land. Refreshes
queue every 30 s, so with lost replies they went out every 35 s or so,
each with the deadline it got when queued, and the timer could fire
while Terminal was still running.

- ScheduleCancel now carries how far ahead to cancel. The worker adds
  it to the time of signing, so a refresh that waited still gets a
  fresh deadline.
- A newer change replaces one still waiting for its turn, so a backlog
  collapses into the newest.
- Only a change of the other kind waits out a lost one: a timer after a
  clear, or a clear after a timer. A refresh that lands late only moves
  the timer back by one refresh, so refreshes don't wait for each other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hl): make every dead man's switch change wait out a lost one

From Greptile's review of 73b71b8: letting a refresh go out before an
earlier timer with a lost reply could no longer land was not safe. That
timer could land last (the clocks may differ by seconds) and pull the
deadline in, so the switch could fire while Terminal was running.

Every change now waits until a lost earlier change can no longer land,
whichever kind either one is. That is safe now that each change is
signed, with its deadline, when it goes out, and a newer change
replaces any still waiting, so a backlog can't build up stale
deadlines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 12:06:27 -05:00
Michael Shimeles b7efcf5a3a docs: design for Hyperliquid trading (crypto + HIP-3 stock perps) (#36)
* docs: design for Hyperliquid trading (crypto + HIP-3 stock perps)

Design only, no feature code. Covers custody (client-side API wallet
signing, recommended), agent approve/revoke flow, asset routing for
core/spot/HIP-3, order ticket, user streams for POS/ORD/ACTV, safety
rails, testnet-only test plan, phases, and open questions including the
Hyperliquid ToS restriction on Ontario.

* docs(hl-trading): plan the market key so HL perp rows reprice and trade

Greptile: market_key() returns None for Future and live_price/row
actions depend on it. Add Position.market and the portfolio_view work.

* docs(hl-trading): keep testnet and mainnet quotes in separate maps

Greptile: same market keys on both networks would let one overwrite the
other in the shared quote map. Plan a network-aware live_price and
mainnet-only gateway requests, with interleaved-update tests.
2026-11-03 09:45:18 -05:00
Michael Shimeles 5bcf3a50bc Refresh order books about every 0.5 s instead of every 5 s (#34)
* Refresh order books about every 0.5 s instead of every 5 s

Hyperliquid's default l2Book arrives about every 5.4 s. The fast mode
arrives about every 0.5 s but has only 5 levels a side. A book now
subscribes to both, and the gateway puts the fast levels on top of the
default book's deeper ones. If the subscription budget only has room
for the default feed, the book keeps its old 5 s cadence.

The app now asks for a visible book every 400 ms instead of every
second, with one request out at a time.

Measured for BTC over 60 s:
- gateway updates: p50 5353 ms, p95 6283 ms before (live server);
  p50 532 ms, p95 704 ms after (local build on the public feed)
- what a book pane gets: p50 5014 ms before; p50 503 ms, p95 1006 ms
  after

The cli's watch command now prints how far apart a topic's updates are.

* Drop a stalled fast book, and match book answers to asks

- A fast book older than 3 s no longer sits on top of a newer default
  book; the default book is shown alone until fast updates resume.
- Each book request carries a number, and only the answer to the
  latest one clears the pane's pending flag, so a late answer for an
  old symbol can't start overlapping requests.
- cli: update intervals are measured per subscription, so `load` with
  several clients reports each client's cadence.

* Tell the two book feeds apart by Hyperliquid's fast flag, not by depth

A thin market's default book can be 5 levels a side. Classed as fast, it
left the default feed's older, deeper book cached, so removed levels stayed
on the merged book. Fast messages carry "fast":true and default ones leave
it out (checked on the live feed for BTC and xyz:AAPL), so parse reads that,
and the pool no longer remaps thin books.
2026-11-03 09:44:44 -05:00
Michael Shimeles 0690de8c6c Harden the gateway: optional token for /stats and /metrics, per-address cap, flood close, sandboxed unit (#33)
* Gate /stats and /metrics behind an optional token and harden the gateway

- TR_METRICS_TOKEN: when set, /stats and /metrics answer 404 unless the
  request sends it as a bearer token. /healthz and /readyz stay public.
  The token never shows in Debug output or errors.
- TR_MAX_CLIENTS_PER_IP caps connections per address (IPv6 per /64), off
  by default. TR_CLIENT_IP_HEADER takes the address from the edge's
  header. The first connection logs whether x-forwarded-for was there,
  never its value, so the cap can be checked behind Freestyle's edge.
- A client that keeps sending after a budget's worth of RateLimited
  replies is closed with 1008 instead of being answered forever.
- cli stats sends TR_METRICS_TOKEN when set.
- vm-setup.sh: optional /etc/tr-gateway/env, stop timeout, memory and
  task caps, and systemd sandboxing (security score 8.3 to 3.1).

* Let the site's server URL come from a repository variable

deploy-site.yml passes the TR_SERVER_URL repository variable to
build-web.sh when it's set, so moving the site to the gateway's custom
domain is a variable and a redeploy, not a code change.

* Accept the bearer scheme in any case

HTTP auth scheme names are case-insensitive, so "bearer <token>" now
works too. The token itself is still compared exactly.
2026-11-02 15:39:07 -05:00
Michael ShimelesandClaude Opus 5.5 1b7ab3400d Add Hyperliquid HIP-3 perps, from the Terminal data server (#35)
* Add Hyperliquid HIP-3 perps as their own instruments

A HIP-3 perp keeps Hyperliquid's coin name as its market key (xyz:AAPL,
typed in any case) and a new Perp asset class. AAPL US Equity stays the
listed stock. A reviewed table in tr-core links 27 app instruments to the
perp on the same underlying, checked against trade.xyz's spec index, live
meta and price. XYZ100, JP225 and HO stay unlinked because the docs don't
name the index or tie the coin to the product.

The gateway stops serving delisted markets. Hyperliquid keeps publishing
mids for every delisted perp (56 core, 58 HIP-3 on 2026-11-02), so the
market list, key resolution, subscriptions and history now check meta's
isDelisted. A dex with nothing live gets no mids feed, as before.

Perps come only from the Terminal server. Yahoo, the browser sources,
sample mode and the Fallback wrapper refuse them instead of filling in.

In the app, `AAPL PERP` opens the linked perp, and `PERP` or Shift+P
switches the focused pane back and forth. Charts, books, tabs, the
watchlist, the palette and SECF label perps "PERP · HL xyz". A chart on a
linked instrument shows a switch chip once the server lists its perp as
live, and a stock's simulated book offers the perp's real book, titled as
the perp's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Show a perp's trades beside its book, and record the HIP-3 decision

The order book pane now lists recent trades when its book comes from
the Terminal server. They go beside the ladder in a wide pane and below
it in a tall one, and a perp's list is titled with its venue. They come
from the server's trades topic through MarketData::trades, which no
other source fills in.

Fixes from reviewing the first commit:

- With no server configured, perp_markets fails instead of returning an
  empty list. AAPL PERP then opens the perp, and its pane says why it
  can't load, instead of calling the perp not live.
- The chart's switch chip draws only where it fits beside the bar count.
- The PERP switching rule moved into tr-core (perps::switch_target),
  with a test.
- The browser's source chip says "Crypto + perps live" when a server is
  in front, and its tooltip mentions HIP-3 perps.

docs/architecture/server.md section 7 now records the decision and
closes open question 9: the 27 links, the perps left unlinked and why,
and how delisted markets are handled. The READMEs say where perps come
from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Say when a chart's cached bars couldn't refresh, and add the screenshots

A chart paints from the disk cache, then refreshes from the network. If
the refresh failed, it used to keep the cached bars and drop the error,
so a perp whose server was down looked live. The range row now reads
"5000 cached bars · the Terminal server is not reachable" in red. A
failed page of older bars leaves the shown ones as they are.

The book and trades say "the Terminal server is not reachable" when the
connection is down, instead of a timeout's "deadline has elapsed".

docs/img gets before and after shots for the desktop (an equity and a
commodity with their perps, and the server down) and for the web build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep delistings through failed refreshes, and stop showing old perp data

From Greptile's review of #35:

- A dex whose meta call fails now keeps what the last refresh listed.
  Before, its entries vanished from the registry, so its delisted
  markets resolved again.
- Delisted markets' mids never enter the mids table. A market delisted
  while clients watch it loses its book, trades and bars streams: each
  subscriber gets Unavailable "... is delisted", and the upstream
  subscription goes.
- A perp chart whose load failed asks the server again every 10 s while
  the pane shows it. Nothing else would reload it, since no live feed
  starts after a failed load.
- The watchlist drops a perp's old quote when an answer leaves it out,
  instead of showing it as current.
- A book or trades feed whose server lost Hyperliquid says "the
  Terminal server lost its Hyperliquid feed" until fresh data arrives.
  Crypto books fall back to Binance and Coinbase meanwhile, as they do
  when the server itself is away.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Update the delisted set and the mids table under one lock

From Greptile's second review of #35: set_delisted dropped a market's
mid, released the table, and only then recorded the market as delisted.
A mids message in between could put the market back, and quote
subscribers would keep getting its price. Both now change under the
table's lock, taken in the same order as apply. apply also drops any
delisted market it finds in the table, so the next message from the
venue clears one that got in anyway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 15:33:34 -05:00
Michael Shimeles 94bb329d68 Let a chart page again after it reloads from another source (#32) 2026-10-31 22:14:51 -04:00
Michael ShimelesandClaude Opus 5.5 dcf11c5755 Get the web build's crypto markets from the Terminal data server (#31)
* Get the web build's crypto markets from the Terminal data server

The browser build now gets BTCUSD Curncy and the other crypto keys from
the Terminal server, the same Hyperliquid perps the desktop gets with
TR_SERVER_URL: history, live bars, quotes and the order book. While the
server is unreachable, Binance and Coinbase serve them as before.

- ws.rs carries binary frames, so the browser's WebSocket speaks postcard.
- rt.rs gets timeout(), and the browser's sleep() is Send, so remote.rs
  runs on the page's event loop as well as on tokio.
- for_browser() puts RemoteMarkets in front of the browser's sources when
  the page has a server URL. scripts/build-web.sh builds in TR_SERVER_URL,
  wss://terminal-gateway.style.dev/ws unless set (empty for none). A page
  can turn it off with ?server=off or use a server on the same machine
  with ?server=ws://localhost:8787/ws; other hosts are refused.
- When the connection drops, the order book falls back instead of showing
  the server's last book until it comes back. The desktop had this bug too.
- The status chip's tooltip names the Terminal server in the browser.
- build-web.sh reads the wasm from CARGO_TARGET_DIR when it is set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hand charts off to the fallback while the server is away, and clear browser timers

A chart loaded from the Terminal server froze when the server went away:
its live feed stayed open and waited for the server. Now the feed ends
once the connection has been down for 10 s, and the worker reloads the
chart, which comes from Binance or Coinbase while the server is
unreachable. A chart on the fallback for a server-routed market reloads
from the server once the connection is back up. The worker waits at
least 5 s between a feed starting and a reload. Shorter outages keep the
server's chart and backfill the gap as before.

The browser's sleep() now sets its setTimeout on the first poll and
clears it if dropped early, so a finished timeout() no longer leaves its
deadline timer pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Move charts back to the server on any newer connection, and drop stale pages

A chart could stay on the fallback after the server returned: if the
connection came back while the fallback's history was loading, the
relay saw the server already up and waited for another change.
RemoteMarkets now records which connection was up when a chart fell
back (none, or the one that failed the request), and the fallback's
feed ends as soon as a newer connection is up, including one that came
up before the feed started. ConnState::Up counts the client's
connections for this, since server session ids restart with the server.

A page of older bars requested before a source switch could finish
after the reload and join the old source's bars to the new one's,
because the reload keeps the pane's generation. The worker now counts
source switches per pane and drops a page that spans one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Skip paging while a chart reloads for another source

A page of older bars requested while a source-change reload was running
read the count the reload had already bumped, so it could still prepend
the old source's bars to the new source's history. The count now goes
up when the reload starts and again when it's done. MoreHistory skips
paging while the count is odd, and re-checks it under the series lock
before it merges a page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-31 21:02:36 -04:00
Michael ShimelesandClaude Opus 5.5 75ffb50a4a Deploy the data server on Freestyle, and keep subscriptions made while it starts (#30)
* deploy(server): run the data server on a Freestyle VM

deploy/freestyle/vm-setup.sh builds tr-gateway from a git ref on the VM
and runs it under systemd on 0.0.0.0:8080, behind Freestyle's TLS edge.
deploy.sh creates the VM and the TLS rule if they're missing, runs
vm-setup.sh and waits for /readyz through the public URL.

Grok Bot wrote both and deployed the running VM with them. This version
waits for /healthz and /readyz instead of one check after 3 s, keeps
Rust on the latest stable like CI, never stops restarting the service,
and pins the Freestyle CLI to 0.2.21.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(server): keep subscriptions made while the gateway starts

A client that reconnected while the gateway was still loading its
instrument list and mids got Unavailable { Upstream } on every
subscription after 10 s, and the client never asked again. Live testing
on the Freestyle VM hit this after a reboot.

- The hub is ready once the first instrument list is in and every venue
  on it has sent mids or been refused, or 10 s after that list with the
  mids it has. /readyz uses this.
- A subscription made before then gets Stale, waits, then gets Live and
  its snapshot. A request waits up to 10 s, then fails with Upstream
  rather than UnknownMarket.
- The client subscribes again, with backoff from 1 s to 30 s, to
  anything a server ended with Unavailable { Upstream }, so it also
  recovers from older gateways.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(server): don't call markets on a late venue unknown

Greptile review. If a venue outlasts venue_grace, the hub goes ready
without it, and its markets used to come back as UnknownMarket, which
clients never retry.

- Until every venue from the first instrument list has reported, a key
  that doesn't resolve fails as Upstream, for subscriptions and requests
  alike, so the client retries it.
- A quotes subscription takes in keys that resolve once their venue
  reports, with their first delta.
- The health probes in deploy.sh and vm-setup.sh time out each request
  and stop at an overall deadline instead of hanging on a server that
  accepts and never answers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(server): let subscriptions wait for a late venue

Greptile review. Ending a subscription with Upstream while its venue
was late left recovery to the client's retry, so an older client still
lost it for good.

A subscription now waits, marked stale, while the hub isn't ready or its
market's venue is still loading, the same as one made during startup,
and goes live when the market is known. Requests still fail as Upstream
while a venue is loading.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-31 19:20:37 -04:00
Michael ShimelesandClaude Opus 5.5 e012597059 Add the Terminal data server, on Hyperliquid's public feed for now (#29)
* Add the Terminal data server on Hyperliquid's public feed

First build step of docs/architecture/server.md, on the interim source
Micky approved while a node box costs too much: Hyperliquid's public
websocket and info endpoint.

- crates/proto (tr-proto): the client protocol from section 4. postcard
  by default, JSON when a client asks. Builds for wasm32, and CI now
  checks that.
- server/ (tr-gateway): the section 3.1 Provider trait, HlPublicProvider,
  the hot store, client sessions, /healthz, /readyz, /metrics and /stats,
  a CLI example, and tests against a mock upstream.
- tr-data::remote: the client, and RemoteMarkets behind TR_SERVER_URL so
  the desktop app can take crypto markets from the server.
- The design doc gets a short "Interim: public feed" section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Page from the source a chart loaded from, and rate-limit clients

- RemoteMarkets sends older pages to the server only for charts whose
  history came from it. A chart that fell back to Binance no longer stops
  paging at the server's first empty page.
- The gateway ignores client messages past 200 per 10 s on a connection
  and answers with a new RateLimited error code.
- Before and after screenshots of the order book pane, for the PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Fix the six issues from the first Greptile review

- Quotes track delivery per requested key, so BTC and BTCUSD Curncy in
  one subscription both keep updating.
- The mids table keeps each venue's feed state. Quote subscribers get
  Stale during an outage, then Live, Resync and a fresh Snapshot.
- Requests run in a per-connection set: at most 8 at once, 30 s each,
  aborted when the connection closes.
- After a Resync or reconnect, the desktop's live chart feed fetches the
  bars it missed before taking new ones. History for a market with a live
  stream skips cached bars older than the stream's last restart.
- Gateway::shutdown ends the registry loop and closes every session and
  upstream connection.
- A coin missing from its venue's allMids message (each one carries the
  full set) leaves the mids table, so delisted markets stop resolving.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Retry chart backfill until it works, without holding live bars

Greptile's second review: when the history request that fills an outage
gap failed, the bars in the gap were never fetched again.

- RemoteMarkets sends live bars on at once and fetches the gap, from the
  last bar before the outage up to the first one after, in a background
  task that retries with backoff until it works or the feed is dropped.
- Series::apply_live now slots a bar into a missing period, or completes
  an older bar it already holds, instead of ignoring anything older than
  the last bar. Bars older than the whole series are still ignored.
- The backfill test fails the first history request and checks that the
  live bar arrives first and the chart ends up with no hole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* One backfill per chart feed, and refresh compare lines after a gap fill

From Greptile's third review.

- RemoteMarkets keeps one coordinator per live feed: outstanding gaps
  merge into one range, one history request runs at a time, failures
  retry with backoff, and it all stops when the worker drops the feed.
  Before, each outage started its own retry loop.
- When a live bar lands before the latest one (a gap being filled), the
  worker sends that update as a refresh, so the chart rebuilds its
  compare line and indicators instead of keeping index-aligned values
  that the insert shifted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-30 00:55:50 -04:00
Michael Shimeles f4643de354 docs(architecture): design the Terminal data server (#28)
* docs(architecture): design the Terminal data server

* docs(architecture): address review on bar closes, SnapTrade modes, doc-op idempotency

* docs(architecture): ingest from our own non-validator node from day one

Live data now comes only from our Tokyo hl-node outputs and local info server: own L4/L2 book builder, candles at every interval with no cap, mids/marks/oracle/funding sourcing, node ops (spec, logs, monitoring, catch-up, failover, cost). Public API is limited to a one-time backfill.

* docs(architecture): Tokyo bare-metal hosting options, rule out Freestyle, colocate gateway with node

* docs(architecture): single-writer gateway failover with synchronous ack and epoch fence
2026-10-29 14:44:39 -04:00
173 changed files with 27443 additions and 263 deletions

No files matched your search

+7
View File
@@ -84,12 +84,19 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- run: sudo apt-get update && sudo apt-get install -y libxkbcommon-x11-0 libxcb-xkb1 mesa-vulkan-drivers xvfb
- run: cargo fmt --all -- --check
- run: cargo build --release --workspace --all-targets
- run: cargo test --release --workspace
- run: cargo clippy --release --workspace --all-targets -- -D warnings
- name: The protocol crate builds for the browser
run: cargo build -p tr-proto --target wasm32-unknown-unknown
- name: The Hyperliquid crate builds for the browser
run: cargo build -p tr-hl --target wasm32-unknown-unknown && cargo clippy -p tr-hl --target wasm32-unknown-unknown -- -D warnings
- name: The wallet crate builds for the browser
run: cargo build -p tr-turnkey --target wasm32-unknown-unknown && cargo clippy -p tr-turnkey --target wasm32-unknown-unknown -- -D warnings
- name: Run the app (smoke test)
env:
TR_PROVIDER: sample
+8 -1
View File
@@ -50,7 +50,14 @@ jobs:
echo "$SHA256 $RUNNER_TEMP/binaryen.tar.gz" | sha256sum -c -
tar xzf "$RUNNER_TEMP/binaryen.tar.gz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/binaryen-$BINARYEN/bin" >> "$GITHUB_PATH"
- run: scripts/build-web.sh
# The repository variable TR_SERVER_URL, when set, picks the server the site connects to,
# for example after the gateway moves to a custom domain. Unset, build-web.sh's default holds.
- name: Build the browser app
env:
SERVER_URL: ${{ vars.TR_SERVER_URL }}
run: |
if [ -n "$SERVER_URL" ]; then export TR_SERVER_URL="$SERVER_URL"; fi
scripts/build-web.sh
- uses: actions/upload-artifact@v7
with:
name: web-app
Generated
+2377 -28
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "crates/chart", "crates/macos", "app"]
members = ["crates/core", "crates/broker", "crates/hl", "crates/turnkey", "crates/congress", "crates/data", "crates/chart", "crates/macos", "crates/proto", "app", "server"]
default-members = ["app"]
[workspace.package]
@@ -11,10 +11,13 @@ rust-version = "1.85"
[workspace.dependencies]
tr-core = { path = "crates/core" }
tr-broker = { path = "crates/broker" }
tr-hl = { path = "crates/hl" }
tr-turnkey = { path = "crates/turnkey" }
tr-congress = { path = "crates/congress" }
tr-data = { path = "crates/data" }
tr-chart = { path = "crates/chart" }
tr-macos = { path = "crates/macos" }
tr-proto = { path = "crates/proto" }
anyhow = "1"
thiserror = "2"
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
+7
View File
@@ -84,6 +84,11 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
| US Treasuries | Treasury.gov daily par yield curve CSV (FRED fallback); Yahoo `^IRX/^FVX/^TNX/^TYX` intraday | Treasury.gov | — |
| FX, indices, futures | Yahoo Finance (`EURUSD=X`, `^GSPC`, `CL=F`) | Yahoo spark (batched) | — |
| Stocks, ETFs | Alpaca when `APCA_API_KEY_ID`/`APCA_API_SECRET_KEY` are set, else Yahoo | Yahoo spark | — |
| Hyperliquid HIP-3 perps (`xyz:AAPL`, `xyz:GOLD`, `xyz:EUR`) | The Terminal data server only (`TR_SERVER_URL`) | Server mids | Server candles, book and trades |
A HIP-3 perp is its own instrument, labelled `PERP · HL xyz` everywhere. `AAPL US Equity` stays the
listed stock. `AAPL PERP` opens the perp linked to it, and `PERP` or ⇧P switches back and forth.
A perp has no other source, so it never gets sample data; without the server it shows why.
No keys are required. `TR_PROVIDER=sample` runs fully offline on deterministic, class-aware sample
data (BTCUSD near 65,000, EURUSD near 1.09, US10Y near 4.2%) **with simulated live ticks**, and
@@ -227,6 +232,8 @@ What the browser build can and can't do yet:
books and websocket bars all come from them. Yahoo, Treasury.gov, Alpaca and CoinDesk don't allow
them, so every other market shows sample data and the toolbar says "Crypto live". A same-origin
proxy can fix that later.
- HIP-3 perps (`xyz:AAPL`) are live through the Terminal data server the page is built with. They
never fall back to sample data: while the server is unreachable they say so.
- Congress disclosures, SnapTrade accounts and live order entry need the desktop app. The paper
trading demo account works.
- Layouts, settings, themes and the demo account are saved in `localStorage` under `terminal/`.
+10
View File
@@ -13,6 +13,8 @@ webgl = ["eframe/wgpu"]
[dependencies]
tr-core.workspace = true
tr-broker.workspace = true
tr-hl.workspace = true
tr-turnkey.workspace = true
tr-congress.workspace = true
tr-data.workspace = true
tr-chart.workspace = true
@@ -23,10 +25,13 @@ serde_json.workspace = true
toml.workspace = true
tracing.workspace = true
futures = "0.3"
zeroize = "1"
egui = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false, features = ["wgpu_no_default_features", "wayland", "x11"] }
tr-macos.workspace = true
web-time = "1"
# The Deposit window's QR code (no image dependencies).
qrcode = { version = "0.14", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts"] }
@@ -60,6 +65,11 @@ web-sys = { version = "0.3", features = [
"Response",
"Storage",
"Url",
"UrlSearchParams",
"Window",
"console",
] }
[dev-dependencies]
async-trait = "0.1"
tr-turnkey = { workspace = true, features = ["mock"] }
+162 -14
View File
@@ -7,6 +7,11 @@
mod broker;
mod chrome;
mod demo;
mod hl;
pub(crate) use hl::store_account as hl_store_account;
mod hl_view;
#[cfg(target_arch = "wasm32")]
mod hl_web;
mod hotkeys_view;
mod options_view;
mod palette_view;
@@ -15,6 +20,8 @@ mod portfolio_view;
mod settings_view;
mod theming;
mod trading;
mod wallet;
mod wallet_view;
mod workspace;
pub use broker::PosSort;
@@ -45,6 +52,9 @@ use crate::worker::{Profile, Request, Response, Worker, data_dir};
use chrome::{SIDEBAR_W, STATUS_H, SidebarCmd};
use settings_view::SettingsTab;
/// How often a visible order book pane asks for its book.
const BOOK_POLL: Duration = Duration::from_millis(400);
pub enum PaneKind {
Chart(Box<ChartState>),
Feed {
@@ -155,18 +165,50 @@ pub struct BookPane {
pub book: Option<OrderBook>,
pub error: Option<String>,
pub fetched: Option<Instant>,
/// A request is out. A REST book can take longer than [`BOOK_POLL`], and requests run
/// concurrently, so the pane waits for the answer before it asks again.
pub pending: bool,
/// Numbers the pane's asks; only the answer to the latest one clears `pending`.
pub asked: u64,
/// The latest trades, oldest first, when the book comes from the Terminal server.
pub trades: Vec<tr_data::TradePrint>,
}
impl BookPane {
pub fn new(symbol: &str, depth: usize, group: f64) -> Self {
Self { symbol: symbol.into(), depth, group, book: None, error: None, fetched: None }
Self {
symbol: symbol.into(),
depth,
group,
book: None,
error: None,
fetched: None,
pending: false,
asked: 0,
trades: Vec::new(),
}
}
/// Time to ask for the book again: [`BOOK_POLL`] since the last ask, once it was answered (or
/// went unanswered for 10 s).
fn due(&self) -> bool {
self.fetched.is_none_or(|t| t.elapsed() > BOOK_POLL && (!self.pending || t.elapsed() > Duration::from_secs(10)))
}
/// Whether the book came through the Terminal server, which also has the market's trades.
pub fn served_by_server(&self) -> bool {
self.book.as_ref().is_some_and(|b| b.source.ends_with("Terminal server"))
}
}
impl PaneKind {
/// Short tab title.
fn tab_title(&self) -> String {
let short = |s: &str| tr_data::security(s).ticker;
// A perp's tab says so: `xyz:AAPL PERP`.
let short = |s: &str| {
let sec = tr_data::security(s);
if sec.class == tr_core::AssetClass::Perp { format!("{} PERP", sec.ticker) } else { sec.ticker }
};
match self {
PaneKind::Chart(c) => short(&c.symbol),
PaneKind::Feed { .. } => "Congress".into(),
@@ -464,6 +506,10 @@ pub struct TerminalApp {
wei_updated: Option<String>,
/// Latest network search results and the query they answer.
net_hits: (String, Vec<SearchHit>),
/// HIP-3 perps the Terminal server lists as live (`None` until it has answered once), and
/// when they were last asked for.
live_perps: Option<Vec<String>>,
last_perps: Option<(Instant, bool)>,
/// Query typed in the command line awaiting a debounced network search.
search_pending: Option<(String, Instant)>,
suggestion: Option<usize>,
@@ -514,11 +560,26 @@ pub struct TerminalApp {
trading: trading::TradingState,
/// The paper-trading demo account (selected by default).
demo: demo::DemoState,
/// Hyperliquid (testnet): the account, the agent key and its confirmations.
hl: hl::HlState,
/// ⌘K command palette.
palette: crate::palette::Palette,
}
/// Resolve a run of words (`BTCUSD CRNCY AAPL US EQUITY MSFT`) into market keys.
/// Lay fresh quotes over the ones shown, and return the keys that got one. A perp that was
/// asked for and left out has no other source, so its old quote goes rather than pass for current.
fn merge_quotes(shown: &mut HashMap<String, Quote>, asked: &[String], quotes: Vec<Quote>) -> Vec<String> {
let keys: Vec<String> = quotes.iter().map(|q| q.symbol.clone()).collect();
for gone in asked.iter().filter(|k| tr_data::is_perp(k) && !keys.contains(k)) {
shown.remove(gone);
}
for q in quotes {
shown.insert(q.symbol.clone(), q);
}
keys
}
fn keys_from_words(words: &[String]) -> Vec<String> {
let tokens: Vec<&str> = words.iter().map(String::as_str).collect();
let mut out = Vec::new();
@@ -576,6 +637,7 @@ impl TerminalApp {
broker,
trading: Default::default(),
demo,
hl: hl::HlState::platform(),
worker,
provider,
panes: Vec::new(),
@@ -612,6 +674,8 @@ impl TerminalApp {
last_curve: None,
wei_updated: None,
net_hits: (String::new(), Vec::new()),
live_perps: None,
last_perps: None,
search_pending: None,
suggestion: None,
cmd: String::new(),
@@ -673,6 +737,11 @@ impl TerminalApp {
app.save_layout();
}
app.refresh_layout_names();
#[cfg(target_arch = "wasm32")]
{
app.hl.ctx = Some(ctx.clone());
}
app.hl_start();
app.worker.send(Request::Latest { limit: 2_000 });
app.worker.send(Request::SearchPoliticians(String::new()));
app.worker.send(Request::Alerts);
@@ -778,6 +847,7 @@ impl TerminalApp {
self.status = format!("{s}: option chain");
}
Command::SymbolFunction(s, Function::Portfolio(view, arg)) => self.portfolio_command(view, &arg, Some(s)),
Command::SymbolFunction(s, Function::Perp) => self.open_perp(Some(s)),
Command::SymbolFunction(s, Function::Trade(mut t)) => {
t.symbol = Some(s);
self.open_ticket(t, None);
@@ -932,6 +1002,11 @@ impl TerminalApp {
}
self.set_account_mode(mode, false);
}
Function::Kill => self.hl_kill(),
Function::Perp => {
let key = symbol.or_else(|| self.focused_symbol());
self.open_perp(key);
}
}
}
@@ -957,6 +1032,7 @@ impl TerminalApp {
prepended,
from_cache,
exhausted,
full,
live,
} => {
let worker = &self.worker;
@@ -964,7 +1040,7 @@ impl TerminalApp {
&& c.symbol == symbol
&& c.tf == tf
{
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, live, worker);
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, full, live, worker);
}
}
Response::HistoryError { pane, generation, error } => {
@@ -992,14 +1068,11 @@ impl TerminalApp {
c.on_markers(&symbol, trades);
}
}
Response::Quotes(q) => {
if q.len() > 20 {
Response::Quotes { asked, quotes } => {
if quotes.len() > 20 {
self.wei_updated = Some(format!("updated {}", chrono::Local::now().format("%H:%M:%S")));
}
let keys: Vec<String> = q.iter().map(|x| x.symbol.clone()).collect();
for x in q {
self.quotes.insert(x.symbol.clone(), x);
}
let keys = merge_quotes(&mut self.quotes, &asked, quotes);
self.demo_on_quotes(&keys);
}
Response::Latest(t) => self.latest = t,
@@ -1052,12 +1125,15 @@ impl TerminalApp {
}
}
}
Response::OrderBook { pane, symbol, book } => {
Response::OrderBook { pane, symbol, ask, book } => {
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
&& let PaneKind::OrderBook(b) = &mut p.kind
&& b.symbol == symbol
{
if ask == b.asked {
b.pending = false;
}
match book {
_ if b.symbol != symbol => {}
Ok(book) => {
b.book = Some(book);
b.error = None;
@@ -1066,6 +1142,15 @@ impl TerminalApp {
}
}
}
Response::Trades { pane, symbol, trades } => {
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
&& let PaneKind::OrderBook(b) = &mut p.kind
&& b.symbol == symbol
{
// A failed fetch clears the list rather than leave old trades up.
b.trades = trades.unwrap_or_default();
}
}
Response::Portfolio(result) => {
let result = self.keep_simulated_trades(result);
self.on_portfolio(result)
@@ -1085,7 +1170,19 @@ impl TerminalApp {
Response::Status(s) => self.status = s,
Response::Curves(c) => self.curves = Some(c),
Response::SearchHits { query, hits } => self.net_hits = (query, hits),
Response::PerpMarkets(result) => {
let ok = result.is_ok();
match result {
Ok(perps) => self.live_perps = Some(perps),
// Keep what the server said last; links to perps it listed stay usable.
Err(e) => tracing::debug!("live perps: {e}"),
}
if let Some((_, answered)) = &mut self.last_perps {
*answered = ok;
}
}
Response::Trade(r) => self.on_trade(*r),
Response::Hl(r) => self.on_hl(*r),
Response::CongressRefreshed(summary) => {
self.congress_status = summary.to_string();
self.congress_checked = Some(chrono::Local::now());
@@ -1288,6 +1385,8 @@ impl TerminalApp {
| A::ResetView
| A::LogScale
| A::Magnet => self.focused_is_chart(),
// Otherwise ⇧P types a P into the command line.
A::TogglePerp => self.focused_symbol().is_some_and(|s| self.switch_target(&s).is_some()),
a => a.layout_slot().is_none_or(|i| i < self.layout_names.len()),
}
}
@@ -1307,6 +1406,7 @@ impl TerminalApp {
A::Orders => self.apply(Function::Portfolio(command::PortView::Orders, String::new())),
A::Buy => self.quick_trade(true),
A::Sell => self.quick_trade(false),
A::TogglePerp => self.apply(Function::Perp),
A::NextPane => self.cycle_focus(1),
A::PrevPane => self.cycle_focus(-1),
A::ToggleSidebar => self.sidebar_open = !self.sidebar_open,
@@ -1358,13 +1458,20 @@ impl TerminalApp {
self.worker.send(Request::Quotes(markets_view::overview_keys()));
self.last_wei = Some(Instant::now());
}
// Which perps are live: every 5 minutes, or every minute while the server isn't answering.
let every = |ok: bool| Duration::from_secs(if ok { 300 } else { 60 });
if self.last_perps.is_none_or(|(t, ok)| t.elapsed() > every(ok)) {
self.worker.send(Request::PerpMarkets);
self.last_perps = Some((Instant::now(), self.last_perps.is_none_or(|(_, ok)| ok)));
}
if shown(&|k| matches!(k, PaneKind::Curve))
&& self.last_curve.is_none_or(|t| t.elapsed() > Duration::from_secs(600))
{
self.worker.send(Request::YieldCurves);
self.last_curve = Some(Instant::now());
}
// News every 5 minutes and order books every second, while visible.
// News every 5 minutes and order books about twice a second, while visible. The Terminal
// server's books change about every 0.5 s; 400 ms puts a poll on every 500 ms repaint tick.
for &i in &visible {
let p = &mut self.panes[i];
match &mut p.kind {
@@ -1380,13 +1487,24 @@ impl TerminalApp {
expiration: o.expiration,
});
}
PaneKind::OrderBook(b) if b.fetched.is_none_or(|t| t.elapsed() > Duration::from_millis(1000)) => {
PaneKind::OrderBook(b) if b.due() => {
b.fetched = Some(Instant::now());
self.worker.send(Request::OrderBook { pane: p.id, symbol: b.symbol.clone(), depth: b.depth });
b.pending = true;
b.asked += 1;
self.worker.send(Request::OrderBook {
pane: p.id,
symbol: b.symbol.clone(),
depth: b.depth,
ask: b.asked,
});
if b.served_by_server() {
self.worker.send(Request::Trades { pane: p.id, symbol: b.symbol.clone() });
}
}
_ => {}
}
}
self.hl_periodic();
self.broker_periodic(&visible);
if let Some((q, at)) = &self.search_pending
&& at.elapsed() > Duration::from_millis(300)
@@ -1607,6 +1725,17 @@ impl eframe::App for TerminalApp {
mod tests {
use super::*;
#[test]
fn a_book_pane_asks_again_once_answered() {
let ago = |ms| Instant::now().checked_sub(Duration::from_millis(ms));
let pane = |fetched, pending| BookPane { fetched, pending, ..BookPane::new("BTCUSD Curncy", 20, 0.0) };
assert!(pane(None, false).due());
assert!(!pane(ago(0), false).due());
assert!(pane(ago(450), false).due(), "a poll on every 500 ms repaint tick");
assert!(!pane(ago(450), true).due(), "one request out at a time");
assert!(pane(ago(11_000), true).due(), "a lost answer doesn't stop the pane");
}
#[test]
fn legacy_settings_migrate_to_a_layout() {
let old = r#"{"layout":"MainRight2","watchlist":["AAPL","BTCUSD Curncy"],"sidebar":false,
@@ -1625,6 +1754,25 @@ mod tests {
assert_eq!(new.active_layout.as_deref(), Some("Desk"));
}
/// When the server can't quote a perp, the watchlist drops its old price instead of showing
/// it as current. Listed instruments keep theirs, as before.
#[test]
fn perps_left_out_of_an_answer_lose_their_old_quote() {
let q = |s: &str, last: f64| Quote { symbol: s.into(), last, prev_close: last, ts: 0, spark: vec![] };
let mut shown: HashMap<String, Quote> =
[q("xyz:AAPL", 336.0), q("xyz:GOLD", 3958.0), q("AAPL US Equity", 336.5)]
.map(|x| (x.symbol.clone(), x))
.into();
let asked: Vec<String> = ["xyz:AAPL", "xyz:GOLD", "AAPL US Equity"].map(String::from).to_vec();
let got = merge_quotes(&mut shown, &asked, vec![q("xyz:GOLD", 3960.0)]);
assert_eq!(got, ["xyz:GOLD"]);
assert!(!shown.contains_key("xyz:AAPL"), "the perp the server didn't quote");
assert_eq!(shown["xyz:GOLD"].last, 3960.0);
assert_eq!(shown["AAPL US Equity"].last, 336.5, "a listed instrument keeps its last quote");
merge_quotes(&mut shown, &["xyz:SP500".into()], vec![]);
assert!(shown.contains_key("xyz:GOLD"), "a perp nobody asked about this time stays");
}
#[test]
fn pane_settings_round_trip_through_kinds() {
let d = prefs::PanelDefaults::default();
+59 -8
View File
@@ -154,15 +154,27 @@ impl BrokerState {
BrokerMode::Setup
}
}
}
/// The terminal's own quote for a position, when it maps onto a market key (options are
/// never repriced from their underlying).
pub fn live_price(&self, quotes: &std::collections::HashMap<String, tr_data::Quote>, p: &Position) -> Option<f64> {
if p.kind == tr_broker::InstrumentKind::Option {
return None;
}
quotes.get(&p.market_key()?).map(|q| q.last).filter(|x| x.is_finite() && *x > 0.0)
/// The terminal's own price for a position, from the network the position is on: testnet
/// mids for Hyperliquid testnet rows, the Terminal server's (mainnet) quotes for everything
/// else. Both use the same market keys (`xyz:AAPL`), so a row never falls back to the other
/// map. Options are never repriced from their underlying.
pub fn live_price(
quotes: &std::collections::HashMap<String, tr_data::Quote>,
testnet: &std::collections::HashMap<String, f64>,
p: &Position,
) -> Option<f64> {
if p.kind == tr_broker::InstrumentKind::Option {
return None;
}
let key = p.market_key()?;
let px = if super::hl::is_testnet_account(&p.account_id) {
testnet.get(&key).copied()
} else {
quotes.get(&key).map(|q| q.last)
};
px.filter(|x| x.is_finite() && *x > 0.0)
}
/// Open a URL or path with the system handler.
@@ -229,10 +241,13 @@ impl TerminalApp {
if let Some(pf) = self.shown_portfolio()
&& self.broker.last_quotes.is_none_or(|t| t.elapsed() > Duration::from_secs(20))
{
// Testnet rows price from testnet mids (the Hyperliquid stream), never the server.
let mut keys: Vec<String> = pf
.positions
.iter()
.filter(|p| p.kind != tr_broker::InstrumentKind::Option)
.filter(|p| {
p.kind != tr_broker::InstrumentKind::Option && !super::hl::is_testnet_account(&p.account_id)
})
.filter_map(Position::market_key)
.collect();
keys.sort();
@@ -810,6 +825,42 @@ mod tests {
assert_ne!(a, b);
}
/// A Hyperliquid testnet row and a mainnet row on the same key (`xyz:AAPL`) with mainnet
/// and testnet updates arriving interleaved: each reads only its own network's price.
#[test]
fn testnet_rows_never_read_mainnet_quotes() {
let quote =
|last: f64| tr_data::Quote { symbol: "xyz:AAPL".into(), last, prev_close: last, ts: 0, spark: Vec::new() };
let row = |account: &str| Position {
account_id: account.into(),
symbol: "xyz:AAPL".into(),
description: String::new(),
kind: tr_broker::InstrumentKind::Future,
units: 1.0,
price: None,
cost_basis: Some(300.0),
multiplier: 1.0,
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: Some("xyz:AAPL".into()),
};
let testnet = row("hl:testnet:0x00000000000000000000000000000000000f107e");
let mainnet = row("mainnet-account");
let mut quotes = std::collections::HashMap::new();
let mut mids = std::collections::HashMap::new();
for (main, test) in [(230.0, 322.0), (231.0, 321.5), (229.5, 323.0)] {
quotes.insert("xyz:AAPL".to_string(), quote(main));
assert_eq!(live_price(&quotes, &mids, &testnet).is_some(), mids.contains_key("xyz:AAPL"));
mids.insert("xyz:AAPL".to_string(), test);
assert_eq!(live_price(&quotes, &mids, &testnet), Some(test), "the testnet row reads the testnet mid");
assert_eq!(live_price(&quotes, &mids, &mainnet), Some(main), "the mainnet row reads the gateway quote");
}
// With only the other network's price, neither row borrows it.
assert_eq!(live_price(&quotes, &Default::default(), &testnet), None);
assert_eq!(live_price(&Default::default(), &mids, &mainnet), None);
}
#[test]
fn pane_titles() {
assert_eq!(PortfolioPane::new(PortView::Summary).title(), "Portfolio");
+93 -30
View File
@@ -102,13 +102,13 @@ impl TerminalApp {
resp.context_menu(|ui| self.view_menu(ui));
// Data source chip (the window title itself is redundant in a compact toolbar).
let (label, live) = self.source_chip();
let (label, live) = source_chip(&self.provider);
let chip_text = p.layout_no_wrap(label.into(), ui_font(11.0), muted());
let chip = Rect::from_min_size(pos2(x, cy - 9.0), vec2(chip_text.size().x + 20.0, 18.0));
p.rect_filled(chip, 9.0, theme::control());
p.circle_filled(pos2(chip.left() + 8.0, cy), 3.0, if live { theme::up() } else { amber() });
p.galley(pos2(chip.left() + 14.0, cy - chip_text.size().y / 2.0), chip_text, muted());
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(self.provider_label());
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(provider_label(&self.provider));
x = chip.right() + 8.0;
// Which account trades: DEMO (paper) or LIVE (real money, in red). Click to switch.
@@ -134,6 +134,17 @@ impl TerminalApp {
p.hline(rect.x_range(), rect.bottom() - 1.0, Stroke::new(2.0, theme::down()));
}
x = badge.right() + 10.0;
// A Hyperliquid account in LIVE is on testnet: say so next to the badge.
if live_mode && self.hl_master().is_some() {
let t = p.layout_no_wrap("TESTNET".into(), mono(11.0), Color32::BLACK);
let pill = Rect::from_min_size(pos2(x - 4.0, cy - 10.0), vec2(t.size().x + 18.0, 20.0));
p.rect_filled(pill, 10.0, amber());
p.galley(pill.center() - t.size() / 2.0, t, Color32::BLACK);
ui.interact(pill, Id::new("hl-testnet-badge"), Sense::hover()).on_hover_text(
"The Hyperliquid account in LIVE is on testnet (test funds). This build cannot reach mainnet.",
);
x = pill.right() + 10.0;
}
// Right-hand cluster, laid out from the right edge: one-line clock (UTC on hover).
let now = chrono::Local::now();
@@ -282,30 +293,6 @@ impl TerminalApp {
}
}
/// The data-source chip's label, and whether any of the data is live.
fn source_chip(&self) -> (&'static str, bool) {
let n = self.provider.to_lowercase();
if n == "sample" {
("Offline", false)
} else if n.contains("sample") {
("Crypto live", true)
} else {
("Live", true)
}
}
fn provider_label(&self) -> String {
let n = self.provider.to_lowercase();
if n == "sample" {
"Offline · sample data".into()
} else if n.contains("sample") {
// The browser build (tr_data::web::WebMarkets).
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
} else {
format!("Live · {}", self.provider)
}
}
/// The Bloomberg command line, drawn as a rounded macOS search field.
fn command_field(&mut self, ui: &mut Ui, field: Rect) -> bool {
let suggestions = self.suggestions();
@@ -468,7 +455,7 @@ impl TerminalApp {
return Vec::new();
}
let net: &[SearchHit] = if self.net_hits.0 == q { &self.net_hits.1 } else { &[] };
let mut v = markets_view::merged_hits(q, net);
let mut v = markets_view::merged_hits(q, net, &self.perp_candidates());
v.truncate(8);
v
}
@@ -579,7 +566,9 @@ impl TerminalApp {
let resp = row_frame(ui, false);
let r = resp.rect;
let pt = ui.painter();
pt.text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.0), text());
let perp = sec.class == tr_core::AssetClass::Perp;
let tc = if perp { theme::perp() } else { text() };
pt.text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.0), tc);
if let Some(q) = self.quotes.get(key) {
let up = q.change() >= 0.0;
let c = if up { theme::up() } else { theme::down() };
@@ -607,7 +596,15 @@ impl TerminalApp {
if resp.clicked() {
out.push(SidebarCmd::Action(Action::OpenChart(key.clone())));
}
let resp = resp.on_hover_text(format!("{} — {}", sec.key(), sec.name().unwrap_or(&sec.ticker)));
let resp = resp.on_hover_text(match perp {
true => format!(
"{} — {} · {}",
sec.key(),
sec.name().unwrap_or("HIP-3 perp"),
tr_core::perps::badge(key)
),
false => format!("{} — {}", sec.key(), sec.name().unwrap_or(&sec.ticker)),
});
resp.context_menu(|ui| {
if ui.button("Open Chart").clicked() {
out.push(SidebarCmd::Action(Action::OpenChart(key.clone())));
@@ -667,7 +664,7 @@ impl TerminalApp {
});
let pf = self.shown_portfolio();
let value_badge = pf.filter(|p| !p.accounts.is_empty()).map(|p| {
let t = p.totals(None, |pos| self.broker.live_price(&self.quotes, pos));
let t = p.totals(None, |pos| super::broker::live_price(&self.quotes, &self.hl.mids, pos));
compact_money(t.value)
});
let open_orders = pf.map_or(0, |p| p.orders.iter().filter(|o| o.is_open()).count());
@@ -988,3 +985,69 @@ fn nav_row(ui: &mut Ui, icon: Icon, label: &str, trailing: Option<&str>, selecte
}
resp
}
/// What the data-source chip says on hover, from the provider's name.
/// The data-source chip's label, and whether any of the data is live.
fn source_chip(provider: &str) -> (&'static str, bool) {
let n = provider.to_lowercase();
if n == "sample" {
("Offline", false)
} else if n.contains("sample") && n.starts_with("server+") {
("Crypto + perps live", true)
} else if n.contains("sample") {
("Crypto live", true)
} else {
("Live", true)
}
}
fn provider_label(provider: &str) -> String {
let n = provider.to_lowercase();
if n == "sample" {
"Offline · sample data".into()
} else if let Some(rest) = provider.strip_prefix("server+") {
// A Terminal server (tr_data::remote::RemoteMarkets), in front of the browser's sources
// (tr_data::web::WebMarkets) or the desktop's.
if rest.contains("sample") {
"Live crypto from Hyperliquid perps through the Terminal server, or from Binance and Coinbase while \
it is unreachable, and HIP-3 perps (xyz:AAPL) from the server only; every other market is sample \
data in the browser"
.into()
} else {
format!(
"Live · crypto from Hyperliquid perps and HIP-3 perps (xyz:AAPL) through the Terminal server; other \
markets from {rest}"
)
}
} else if n.contains("sample") {
// The browser build (tr_data::web::WebMarkets).
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
} else {
format!("Live · {provider}")
}
}
#[cfg(test)]
mod tests {
use super::{provider_label, source_chip};
#[test]
fn the_source_tooltip_names_the_terminal_server() {
assert_eq!(provider_label("sample"), "Offline · sample data");
assert!(provider_label("binance+sample").starts_with("Live crypto from Binance"));
let web = provider_label("server+binance+sample");
assert!(web.contains("Terminal server") && web.contains("sample data in the browser"), "{web}");
let desktop = provider_label("server+markets");
assert!(desktop.contains("Terminal server") && desktop.ends_with("other markets from markets"), "{desktop}");
assert!(web.contains("HIP-3 perps") && desktop.contains("HIP-3 perps"), "{web} / {desktop}");
}
/// With a server in front, the browser build has live perps as well as crypto.
#[test]
fn the_source_chip_says_what_is_live() {
assert_eq!(source_chip("sample"), ("Offline", false));
assert_eq!(source_chip("binance+sample"), ("Crypto live", true));
assert_eq!(source_chip("server+binance+sample"), ("Crypto + perps live", true));
assert_eq!(source_chip("server+yahoo+binance+ust"), ("Live", true));
}
}
+1426
View File
File diff suppressed because it is too large. Load diff
+810
View File
@@ -0,0 +1,810 @@
//! Hyperliquid's windows: the ticket body for a Hyperliquid account, the confirmations,
//! the enable dialog and Settings › Trading › Hyperliquid. Every one shows the amber
//! TESTNET badge.
use egui::{Color32, RichText, Ui};
use tr_broker::trading::Side;
use tr_hl::actions::Tif;
use tr_hl::caps::{MAX_LEVERAGE, MAX_MARKET_SLIPPAGE, MAX_ORDER_NOTIONAL_USD};
use tr_hl::rounding::to_wire;
use tr_hl::trader::{EXPIRES_AFTER_MS, OrderKind};
use super::TerminalApp;
use super::hl::{HlConfirm, HlKind, intent, is_hl_account, market_edited, resolve_coin, settle_size};
use super::portfolio_view::{money, price, qty};
use super::settings_view::SettingsCmd;
use super::trading::TicketAct;
use crate::prefs::AccountMode;
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
use crate::widgets;
/// The amber TESTNET pill.
pub fn testnet_badge(ui: &mut Ui) -> egui::Response {
ui.add(
egui::Button::new(RichText::new("TESTNET").font(mono(11.0)).strong().color(Color32::BLACK))
.fill(amber())
.corner_radius(9.0)
.sense(egui::Sense::hover()),
)
.on_hover_text("Hyperliquid testnet: test funds only. This build cannot reach mainnet.")
}
pub(super) fn masked(addr: &str) -> String {
tr_hl::key::parse_address(addr).map(|a| tr_hl::mask_address(&a)).unwrap_or_else(|_| addr.to_string())
}
const HIP3_RISK: &str = "A leveraged perpetual future with funding, priced by the deployer's oracle. It can diverge from the \
listed stock (off-hours, opens, earnings, corporate actions), and the deployer can halt the market and settle it at mark.";
impl TerminalApp {
/// The ticket's body when a Hyperliquid account is selected.
pub(super) fn hl_ticket_ui(&mut self, ui: &mut Ui) -> Option<TicketAct> {
let accounts = self.trade_accounts_pub();
let blocker = self.hl_blocker();
let reg = self.hl.registry.clone();
let sending = self.hl.sending.is_some();
let coin = self
.trading
.ticket
.as_ref()
.and_then(|t| reg.as_ref().map(|r| resolve_coin(&t.symbol, r)))
.unwrap_or_else(|| Err("Loading Hyperliquid testnet markets…".into()));
let ctx = coin.as_ref().map(|c| self.hl.ctx(c)).unwrap_or_default();
let asset = coin.as_ref().ok().and_then(|c| reg.as_ref()?.get(c).cloned());
let t = self.trading.ticket.as_mut()?;
let mut act = None;
let mut review = false;
let mut leverage = None;
let mut settings = false;
ui.spacing_mut().item_spacing.y = dp(6.0);
ui.horizontal(|ui| {
if let Some(i) = widgets::segmented(ui, "ticket-mode", &["DEMO", "LIVE"], Some(1), 22.0)
&& i == 0
&& !sending
{
act = Some(TicketAct::Mode(AccountMode::Demo));
}
testnet_badge(ui);
ui.label(RichText::new("Hyperliquid · test funds only").font(ui_font(12.0)).color(amber()));
});
let current = t
.account
.as_ref()
.and_then(|id| accounts.iter().find(|a| &a.id == id))
.map_or("Choose an account…".to_string(), |a| format!("{} {}", a.label(), a.masked_number()));
ui.add_enabled_ui(!sending, |ui| {
egui::ComboBox::from_id_salt("hl-ticket-account")
.width(ui.available_width())
.selected_text(current)
.show_ui(ui, |ui| {
for a in &accounts {
let label = if is_hl_account(&a.id) {
format!("{} {} · TESTNET", a.label(), a.masked_number())
} else {
format!("{} · {} {}", a.label(), a.institution, a.masked_number())
};
ui.selectable_value(&mut t.account, Some(a.id.clone()), label);
}
});
});
if let Some(b) = &blocker {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(b).font(ui_font(11.5)).color(amber()));
if ui.small_button("Settings…").clicked() {
settings = true;
}
});
}
ui.horizontal(|ui| {
let w = (ui.available_width() - dp(6.0)) / 2.0;
for (side, color) in [(Side::Buy, theme::up()), (Side::Sell, theme::down())] {
let b = if t.side == side {
egui::Button::new(RichText::new(side.label()).strong().color(Color32::WHITE)).fill(color)
} else {
egui::Button::new(RichText::new(side.label()).color(color))
};
if ui.add_enabled(!sending, b.min_size(egui::vec2(w, dp(26.0)))).clicked() {
t.side = side;
}
}
});
let label = |ui: &mut Ui, s: &str| {
ui.add_sized([dp(64.0), dp(20.0)], egui::Label::new(RichText::new(s).font(ui_font(12.0)).color(muted())));
};
ui.horizontal(|ui| {
label(ui, "Market");
let r = ui.add_enabled(
!sending,
egui::TextEdit::singleline(&mut t.symbol)
.hint_text("BTC, xyz:AAPL")
.font(mono(13.0))
.desired_width(dp(96.0)),
);
if r.changed() {
t.error = None;
market_edited(t);
}
match ctx.mid {
Some(m) => {
ui.label(RichText::new(price(m)).font(mono(12.5)).color(text()))
.on_hover_text("Hyperliquid testnet mid (not the Terminal server's mainnet quote)");
}
None if coin.is_ok() => {
ui.label(RichText::new("testnet mid…").font(ui_font(11.5)).color(muted()));
}
None => {}
}
});
match (&coin, &asset) {
(Ok(c), Some(a)) => {
let kind = if a.is_hip3() { "perpetual, not a share" } else { "perpetual" };
ui.horizontal(|ui| {
ui.add_space(dp(70.0));
ui.label(
RichText::new(format!("{} ({kind})", a.label())).font(mono(11.5)).strong().color(theme::cyan()),
)
.on_hover_text(format!("{c}: asset {} on Hyperliquid testnet", a.id));
});
}
(Err(e), _) if !t.symbol.trim().is_empty() => {
ui.label(RichText::new(e).font(ui_font(11.5)).color(amber()));
}
_ => {}
}
let mut unsized_note = None;
ui.horizontal(|ui| {
label(ui, if t.dollars { "Amount" } else { "Size" });
let r = ui.add_enabled(
!sending,
egui::TextEdit::singleline(&mut t.qty)
.hint_text(if t.dollars { "$" } else { "units" })
.font(mono(13.0))
.desired_width(dp(84.0)),
);
if r.changed() {
t.hl.from_dollars = None;
}
if t.focus_qty > 0 {
t.focus_qty -= 1;
if !r.has_focus() {
r.request_focus();
}
}
// `BUY xyz:AAPL $50` gets its size from the testnet mid. It is never 50 contracts.
unsized_note = reg.as_ref().and_then(|r| settle_size(t, r, ctx.mid, asset.as_ref()));
if let (false, Some(m), Ok(q)) = (t.dollars, ctx.mid, t.qty.trim().parse::<f64>()) {
let from = t.hl.from_dollars.map_or(String::new(), |d| format!(" (from ${d}, at the testnet mid)"));
ui.label(RichText::new(format!("≈ {}{from}", money(q * m, "USD"))).font(mono(11.5)).color(muted()));
}
});
if let Some(n) = unsized_note {
ui.horizontal_wrapped(|ui| {
ui.add_space(dp(70.0));
ui.label(RichText::new(n).font(ui_font(11.5)).color(amber()));
});
}
ui.horizontal(|ui| {
label(ui, "Type");
let labels: Vec<&str> = HlKind::ALL.iter().map(|k| k.label()).collect();
let sel = HlKind::ALL.iter().position(|k| *k == t.hl.kind);
if let Some(i) = widgets::segmented(ui, "hl-ticket-type", &labels, sel, 20.0)
&& !sending
{
t.hl.kind = HlKind::ALL[i];
if t.hl.kind == HlKind::Limit
&& t.limit.trim().is_empty()
&& let Some(m) = ctx.mid
{
t.limit = to_wire(tr_hl::rounding::round_price(
m,
asset.as_ref().map_or(0, |a| a.sz_decimals),
tr_hl::rounding::Round::Nearest,
))
.unwrap_or_default();
}
}
});
match t.hl.kind {
HlKind::Limit => {
ui.horizontal(|ui| {
label(ui, "Limit");
ui.add_enabled(
!sending,
egui::TextEdit::singleline(&mut t.limit)
.hint_text("price")
.font(mono(13.0))
.desired_width(dp(84.0)),
);
if let Some(m) = ctx.mid
&& ui.small_button("Mid").on_hover_text("Use the testnet mid").clicked()
{
t.limit = format!("{m}");
}
});
ui.horizontal(|ui| {
label(ui, "Time");
let tifs = [Tif::Gtc, Tif::Ioc, Tif::Alo];
let labels: Vec<&str> = tifs.iter().map(|x| x.label()).collect();
if let Some(i) =
widgets::segmented(ui, "hl-ticket-tif", &labels, tifs.iter().position(|x| *x == t.hl.tif), 20.0)
&& !sending
{
t.hl.tif = tifs[i];
}
});
}
HlKind::StopLoss | HlKind::TakeProfit => {
ui.horizontal(|ui| {
label(ui, "Trigger");
ui.add_enabled(
!sending,
egui::TextEdit::singleline(&mut t.hl.trigger)
.hint_text("price")
.font(mono(13.0))
.desired_width(dp(84.0)),
);
ui.label(
RichText::new("market order when it trades, reduce-only").font(ui_font(11.0)).color(muted()),
);
});
}
HlKind::Market => {
ui.horizontal(|ui| {
ui.add_space(dp(70.0));
ui.label(
RichText::new(format!("IOC limit {:.0}% through the testnet mid", MAX_MARKET_SLIPPAGE * 100.0))
.font(ui_font(11.0))
.color(muted()),
);
});
}
}
ui.horizontal(|ui| {
ui.add_space(dp(70.0));
let forced = matches!(t.hl.kind, HlKind::StopLoss | HlKind::TakeProfit);
let mut ro = t.hl.reduce_only || forced;
if ui.add_enabled(!forced && !sending, egui::Checkbox::new(&mut ro, "Reduce only")).changed() {
t.hl.reduce_only = ro;
}
});
if let (Ok(c), Some(a)) = (&coin, &asset) {
ui.horizontal_wrapped(|ui| {
label(ui, "Leverage");
match ctx.leverage {
Some(l) => {
let over = l.value > MAX_LEVERAGE;
ui.label(
RichText::new(format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" }))
.font(mono(12.0))
.color(if over { amber() } else { text() }),
);
if over {
ui.label(
RichText::new(format!("over this build's {MAX_LEVERAGE}× limit"))
.font(ui_font(11.0))
.color(amber()),
);
}
}
None => {
ui.label(RichText::new("reading…").font(ui_font(11.5)).color(muted()));
}
}
let target = MAX_LEVERAGE.min(a.max_leverage);
if ctx.leverage.is_none_or(|l| l.value != target)
&& blocker.is_none()
&& ui
.add_enabled(!sending, egui::Button::new(format!("Set {target}×")))
.on_hover_text("Asks for confirmation, then signs updateLeverage")
.clicked()
{
leverage = Some((c.clone(), target));
}
});
}
ui.separator();
// Value it with the market `intent` resolved now, not the one read before an edit.
let hl = &self.hl;
let planned = reg.as_ref().map(|r| {
intent(t, r).and_then(|i| {
tr_hl::trader::plan_order(r, &i, &hl.ctx(&i.coin), &tr_hl::actions::cloid(1)).map_err(|e| e.to_string())
})
});
let row = |ui: &mut Ui, k: &str, v: String, strong: bool| {
ui.horizontal(|ui| {
ui.add_sized(
[dp(110.0), dp(16.0)],
egui::Label::new(RichText::new(k).font(ui_font(11.5)).color(muted())),
);
let rt = RichText::new(v).font(mono(if strong { 13.0 } else { 12.0 })).color(text());
ui.label(if strong { rt.strong() } else { rt });
});
};
if let Some(Ok(p)) = &planned {
row(ui, "Est. value", money(p.notional(), "USD"), true);
row(
ui,
"Sends",
format!("{} @ {}", to_wire(p.size()).unwrap_or_default(), to_wire(p.price()).unwrap_or_default()),
false,
);
}
row(
ui,
"Limits",
format!(
"${MAX_ORDER_NOTIONAL_USD:.0}/order · {MAX_LEVERAGE}× · {:.0}% slippage",
MAX_MARKET_SLIPPAGE * 100.0
),
false,
);
if asset.as_ref().is_some_and(|a| a.is_hip3()) {
let coin = asset.as_ref().map(|a| a.coin.clone()).unwrap_or_default();
let under = coin.split(':').nth(1).unwrap_or_default().to_string();
ui.label(
RichText::new(format!("⚠ Perpetual future on Hyperliquid (xyz). Not a share of {under}."))
.font(ui_font(11.5))
.strong()
.color(amber()),
);
}
if let Some(e) = &t.error {
ui.label(RichText::new(e).font(ui_font(12.0)).color(theme::down()));
} else if let Some(Err(e)) = &planned
&& !t.qty.trim().is_empty()
{
ui.label(RichText::new(e).font(ui_font(11.5)).color(amber()));
}
if let Some(d) = &t.done {
ui.label(RichText::new(d).font(ui_font(12.0)).color(theme::up()));
}
let can = blocker.is_none() && !sending && matches!(&planned, Some(Ok(_)));
let fill = if t.side == Side::Buy { theme::up() } else { theme::down() };
ui.horizontal(|ui| {
let label = format!("Review {}…", t.side.label());
let b = if can {
egui::Button::new(RichText::new(&label).strong().color(Color32::WHITE)).fill(fill)
} else {
egui::Button::new(RichText::new(&label).strong())
};
// Review stays clickable with a refusal, so the cap's reason shows in the ticket.
let clickable = blocker.is_none() && !sending;
if ui
.add_enabled(clickable, b.min_size(egui::vec2(ui.available_width() - dp(24.0), dp(30.0))))
.on_hover_text("Rounds the order, checks the limits, then asks you to confirm")
.clicked()
{
review = true;
}
if sending {
ui.add(egui::Spinner::new().size(14.0));
}
});
ui.label(
RichText::new(format!(
"Signed on this device by the agent key and sent to Hyperliquid testnet; valid {} s.",
EXPIRES_AFTER_MS / 1000
))
.font(ui_font(11.0))
.color(muted()),
);
if review {
self.hl_review();
}
if let Some((c, l)) = leverage {
self.hl_request_leverage(&c, l);
}
if settings {
self.settings_tab = super::settings_view::SettingsTab::Trading;
self.open_panel(super::PaneKind::Settings);
}
act
}
/// The one confirmation for every Hyperliquid action.
pub(super) fn hl_confirm_dialog(&mut self, ctx: &egui::Context) {
let Some(c) = self.hl.confirm.clone() else { return };
// Editing the ticket after Review invalidates the confirmation.
if let HlConfirm::Order { ticket, plan, .. } = &c {
let same = self
.trading
.ticket
.as_ref()
.filter(|t| t.id == *ticket)
.zip(self.hl.registry.as_ref())
.is_some_and(|(t, r)| intent(t, r).is_ok_and(|i| &i == plan.intent()));
if !same {
self.hl.confirm = None;
return;
}
}
let master = self.hl_master().map(|m| masked(&m)).unwrap_or_default();
let agent = self
.prefs
.trading
.hl
.agent
.as_ref()
.map(|a| tr_hl::trader::agent_base_name(&a.name).to_string())
.unwrap_or_default();
let mut choice = None;
let modal = egui::Modal::new(egui::Id::new("hl-confirm")).show(ctx, |ui| {
ui.set_width(dp(430.0));
let grid_row = |ui: &mut Ui, k: &str, v: RichText| {
ui.label(RichText::new(k).font(ui_font(12.0)).color(muted()));
ui.label(v);
ui.end_row();
};
let plain = |s: String| RichText::new(s).font(ui_font(12.5)).color(text());
let (title, go, go_color) = match &c {
HlConfirm::Order { plan, modify, .. } => {
let side = if plan.intent().is_buy { "buy" } else { "sell" };
let t =
if modify.is_some() { format!("Confirm modify ({side})") } else { format!("Confirm {side}") };
let color = if plan.intent().is_buy { theme::up() } else { theme::down() };
(
t,
format!(
"{} {} — Sign & send",
if plan.intent().is_buy { "Buy" } else { "Sell" },
plan.asset().coin
),
color,
)
}
HlConfirm::Cancel { .. } => {
("Cancel order?".to_string(), "Cancel order — Sign & send".to_string(), theme::down())
}
HlConfirm::Kill { .. } => {
("Kill switch".to_string(), "Cancel all and turn orders off".to_string(), theme::down())
}
HlConfirm::Leverage { coin, leverage, .. } => {
(format!("Set {coin} leverage?"), format!("Set {leverage}× — Sign & send"), theme::accent())
}
};
ui.horizontal(|ui| {
testnet_badge(ui);
ui.label(RichText::new(title).font(ui_font(16.0)).strong());
});
ui.add_space(dp(4.0));
egui::Grid::new("hl-confirm-grid").num_columns(2).spacing([dp(14.0), dp(5.0)]).show(ui, |ui| {
grid_row(
ui,
"Network",
RichText::new("Hyperliquid TESTNET (test funds)").font(ui_font(12.5)).strong().color(amber()),
);
grid_row(ui, "Account", plain(format!("{master} · agent {agent}")));
match &c {
HlConfirm::Order { plan, modify, .. } => {
let a = plan.asset();
let kind = if a.is_hip3() { "perpetual, not a share" } else { "perpetual" };
grid_row(
ui,
"Market",
RichText::new(format!("{} · {} ({kind})", a.coin, a.label()))
.font(mono(12.5))
.color(amber()),
);
if let Some(oid) = modify {
grid_row(ui, "Replaces", plain(format!("order #{oid}")));
}
let side = if plan.intent().is_buy { "BUY" } else { "SELL" };
let color = if plan.intent().is_buy { theme::up() } else { theme::down() };
grid_row(
ui,
"Order",
RichText::new(format!("{side} {} {}", qty(plan.size()), a.coin))
.font(mono(13.0))
.strong()
.color(color),
);
grid_row(ui, "Type", plain(plan.kind_label()));
let px = match plan.intent().kind {
OrderKind::Market => format!(
"IOC limit {} (at most {:.0}% from the mid)",
price(plan.price()),
MAX_MARKET_SLIPPAGE * 100.0
),
OrderKind::Trigger { .. } => format!(
"trigger {} · fills down to {}",
plan.trigger().map_or("—".into(), price),
price(plan.price())
),
OrderKind::Limit { .. } => price(plan.price()),
};
grid_row(ui, "Price", RichText::new(px).font(mono(12.5)).color(text()));
grid_row(
ui,
"Value",
RichText::new(money(plan.notional(), "USD")).font(mono(14.0)).strong().color(text()),
);
let lev = self.hl.ctx(&a.coin).leverage.map_or("unknown".into(), |l| {
format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" })
});
grid_row(ui, "Leverage", plain(lev));
grid_row(
ui,
"Reduce only",
plain(if plan.intent().reduce_only { "Yes".into() } else { "No".into() }),
);
grid_row(ui, "Fees", plain("Hyperliquid's maker/taker fee · no builder fee".into()));
grid_row(ui, "Expires", plain(format!("{} s after you confirm", EXPIRES_AFTER_MS / 1000)));
grid_row(ui, "Client id", RichText::new(plan.cloid()).font(mono(11.0)).color(muted()));
}
HlConfirm::Cancel { line, oid, .. } => {
grid_row(ui, "Order", RichText::new(line).font(mono(12.5)).color(text()));
grid_row(ui, "Order id", plain(format!("#{oid}")));
}
HlConfirm::Kill { orders } => {
grid_row(
ui,
"Cancels",
plain(format!("every open order ({} seen; read again before cancelling)", orders.len())),
);
grid_row(ui, "Then", plain("Hyperliquid orders turn off until you enable them again".into()));
}
HlConfirm::Leverage { coin, leverage, cross, current } => {
grid_row(ui, "Market", RichText::new(coin).font(mono(12.5)).color(amber()));
let now = current.map_or("unknown".into(), |l| {
format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" })
});
grid_row(ui, "Now", plain(now));
grid_row(
ui,
"New",
plain(format!("{leverage}× {}", if *cross { "cross" } else { "isolated" })),
);
}
}
});
ui.add_space(dp(6.0));
if let HlConfirm::Order { plan, .. } = &c {
if plan.asset().is_hip3() {
ui.label(RichText::new(HIP3_RISK).font(ui_font(11.5)).color(amber()));
}
ui.label(
RichText::new("Orders on Hyperliquid are on-chain and can't be undone once they fill.")
.font(ui_font(11.5))
.color(muted()),
);
}
if let HlConfirm::Kill { orders } = &c
&& orders.is_empty()
{
ui.label(RichText::new("No open orders seen right now.").font(ui_font(11.5)).color(muted()));
}
ui.add_space(dp(6.0));
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
choice = Some(false);
}
let b = egui::Button::new(RichText::new(go).strong().color(Color32::WHITE)).fill(go_color);
if ui.add(b).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
match choice {
Some(true) => self.hl_confirmed(),
Some(false) => self.hl.confirm = None,
None => {}
}
}
pub(super) fn hl_enable_dialog(&mut self, ctx: &egui::Context) {
let mut choice = None;
let modal = egui::Modal::new(egui::Id::new("hl-enable")).show(ctx, |ui| {
ui.set_width(dp(450.0));
ui.horizontal(|ui| {
testnet_badge(ui);
ui.label(RichText::new("Enable Hyperliquid orders?").font(ui_font(16.0)).strong());
});
ui.add_space(dp(4.0));
for line in [
"This build trades on Hyperliquid testnet only, with test funds. It cannot reach mainnet.",
"Orders are on-chain transactions: once they fill they can't be undone.",
"Perps are leveraged and can be liquidated. HIP-3 stock markets (xyz) are perpetual futures with funding, not shares.",
"Hyperliquid's Terms of Use restrict some regions, including Ontario, Canada and the United States. You are responsible for whether you may use it.",
"Fixed limits: $100 per order, 3× leverage, 1% market slippage. Every order, cancel and leverage change asks for confirmation.",
] {
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
}
ui.add_space(dp(6.0));
ui.checkbox(&mut self.hl.ack, "I am not a Restricted Person under Hyperliquid's Terms, and I understand the above");
ui.add_space(dp(6.0));
ui.horizontal(|ui| {
if ui.button("Keep Off").clicked() {
choice = Some(false);
}
let on = self.hl.ack;
let b = if on {
egui::Button::new(RichText::new("Enable Hyperliquid orders").strong().color(Color32::BLACK)).fill(amber())
} else {
egui::Button::new(RichText::new("Enable Hyperliquid orders").strong())
};
if ui.add_enabled(on, b).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
if let Some(on) = choice {
self.hl.enable_prompt = false;
self.hl.ack = false;
if on {
self.prefs.trading.hl.enabled = true;
if let Err(e) = self.prefs.save(&super::prefs_path()) {
self.status = format!("Could not save preferences: {e}");
}
self.hl.message = Some(("Hyperliquid testnet orders enabled.".into(), false));
}
}
}
/// Settings › Trading › Hyperliquid.
pub(super) fn hl_settings(&mut self, ui: &mut Ui, cmds: &mut Vec<SettingsCmd>) {
let note = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(ui_font(11.5)).color(muted()));
};
ui.add_space(dp(10.0));
ui.horizontal(|ui| {
ui.label(RichText::new("Hyperliquid").font(ui_font(13.5)).color(text()));
testnet_badge(ui);
});
ui.separator();
let h = self.prefs.trading.hl.clone();
let on = h.enabled && self.hl_signing_blocker().is_none();
ui.label(
RichText::new(match (h.master.is_some(), h.agent.is_some(), on) {
(false, _, _) => {
"No wallet yet. Create one to trade perps on the core dex and HIP-3 xyz (BTC, xyz:AAPL), testnet only."
.to_string()
}
(true, false, _) if h.wallet.is_some() => {
"Wallet ready. Approve a trading key to trade; until then PORT, POS and ORD are read-only.".into()
}
(true, false, _) => {
"Watching an address (read-only): positions and orders show in PORT, POS and ORD.".into()
}
(true, true, true) => {
"Orders are ON (testnet): confirmed orders are signed here and sent to Hyperliquid testnet.".into()
}
(true, true, false) => "Orders are OFF.".into(),
})
.font(ui_font(12.5))
.color(if on { amber() } else { theme::accent() }),
);
self.wallet_section(ui, &h);
if let Some(e) = &self.hl.store_error {
ui.label(
RichText::new(format!("Trading is off on this device: {e}")).font(ui_font(12.0)).color(theme::down()),
);
}
// Orders on or off.
ui.add_space(dp(6.0));
ui.horizontal(|ui| {
if h.enabled {
if ui.button("Turn Hyperliquid Orders Off").clicked() {
self.prefs.trading.hl.enabled = false;
self.hl.confirm = None;
cmds.push(SettingsCmd::SavePrefs);
}
} else if ui
.add_enabled(h.agent.is_some(), egui::Button::new(RichText::new("Enable Hyperliquid Orders…").strong()))
.on_disabled_hover_text("Approve a trading key first")
.clicked()
{
self.hl.enable_prompt = true;
self.hl.ack = false;
}
if h.master.is_some()
&& ui.button("KILL").on_hover_text("Cancel every open Hyperliquid order and turn orders off").clicked()
{
self.hl_kill();
}
});
note(
ui,
&format!(
"Fixed in this build: ${MAX_ORDER_NOTIONAL_USD:.0} per order, {MAX_LEVERAGE}× leverage, {:.0}% market slippage, no builder fee. KILL cancels every open order and turns orders off.",
MAX_MARKET_SLIPPAGE * 100.0
),
);
let mut dms = self.prefs.trading.hl.dead_mans_switch;
if ui
.checkbox(&mut dms, "Dead man's switch: cancel all open orders if Terminal stops for a minute")
.on_hover_text(
"Keeps a scheduleCancel about 60 s ahead while Terminal runs. It also cancels orders placed elsewhere.",
)
.changed()
{
self.prefs.trading.hl.dead_mans_switch = dms;
cmds.push(SettingsCmd::SavePrefs);
}
if cfg!(target_arch = "wasm32") {
let mut keep = self.prefs.trading.hl.remember_on_browser;
if ui
.checkbox(
&mut keep,
"Remember the agent key on this browser (encrypted with a key the page can't read)",
)
.on_hover_text(
"Off: the key lasts for this tab only, and each visit needs a new approval, which is one paid Turnkey signature.",
)
.changed()
{
self.prefs.trading.hl.remember_on_browser = keep;
cmds.push(SettingsCmd::SavePrefs);
#[cfg(target_arch = "wasm32")]
if keep {
self.hl_web_remember_key();
} else {
self.hl_web_forget_key();
}
}
if !self.prefs.trading.hl.remember_on_browser {
ui.label(
RichText::new("Each visit without it approves a new trading key: one Turnkey signature each time.")
.font(ui_font(11.5))
.color(muted()),
);
}
}
// Roles.
if h.master.is_some() {
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(!self.hl.roles_busy, egui::Button::new("Check on Hyperliquid"))
.on_hover_text("userRole and extraAgents")
.clicked()
{
self.hl_check_roles();
}
match &self.hl.roles {
Some(Ok(r)) => {
let agents: Vec<String> =
r.agents.iter().map(|a| format!("{} ({})", a.name, masked(&a.address))).collect();
note(
ui,
&format!(
"account role: {}{} · approved agents: {}",
r.master,
r.agent.as_ref().map_or(String::new(), |a| format!(" · agent role: {a}")),
if agents.is_empty() { "none".into() } else { agents.join(", ") }
),
);
}
Some(Err(e)) => {
ui.label(RichText::new(e).font(ui_font(11.5)).color(theme::down()));
}
None => {}
}
});
}
if let Some(b) = self.hl.busy {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(12.0));
note(ui, b);
});
}
if let Some((m, err)) = &self.hl.message {
ui.label(RichText::new(m).font(ui_font(12.0)).color(if *err { theme::down() } else { theme::up() }));
}
}
/// Accounts the ticket can choose from (as `trade_accounts`, for the HL body).
fn trade_accounts_pub(&self) -> Vec<tr_broker::Account> {
self.broker.portfolio.as_ref().map(|p| p.accounts.clone()).unwrap_or_default()
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Hyperliquid in the browser: the built-in wallet's sign-in popup, and the agent key, which
//! lives in memory for the session or, if the user opts in, in IndexedDB encrypted with a
//! non-extractable WebCrypto AES-GCM key (the page can use that key but never read it).
//!
//! Sign-in opens this site's `/wallet/` page in a popup with the session key's public half
//! and a `state`. The page answers with `postMessage`; only messages from this page's own
//! origin, carrying the same `state`, are taken (see `super::wallet`). Browser wallets
//! (EIP-1193) are not used.
use std::cell::RefCell;
use tr_hl::{AgentKey, SigningKey};
use wasm_bindgen::prelude::*;
use super::TerminalApp;
use super::hl::store_account;
#[wasm_bindgen(inline_js = r#"
const DB = 'terminal-hl', STORE = 'keys';
function open() {
return new Promise((res, rej) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore(STORE);
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
function tx(db, mode, f) {
return new Promise((res, rej) => {
const t = db.transaction(STORE, mode);
const req = f(t.objectStore(STORE));
t.oncomplete = () => res(req ? req.result : undefined);
t.onerror = () => rej(t.error);
});
}
// A non-extractable AES-GCM key, kept as a CryptoKey in IndexedDB: usable, never readable.
async function wrapping(db) {
let k = await tx(db, 'readonly', s => s.get('wrap'));
if (!k) {
k = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
await tx(db, 'readwrite', s => s.put(k, 'wrap'));
}
return k;
}
export async function hl_key_save(account, hex) {
const db = await open();
const k = await wrapping(db);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(hex)));
await tx(db, 'readwrite', s => s.put({ iv, ct }, 'agent:' + account));
}
export async function hl_key_load(account) {
const db = await open();
const rec = await tx(db, 'readonly', s => s.get('agent:' + account));
const k = await tx(db, 'readonly', s => s.get('wrap'));
if (!rec || !k) return null;
return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: rec.iv }, k, rec.ct));
}
export async function hl_key_delete(account) {
const db = await open();
await tx(db, 'readwrite', s => s.delete('agent:' + account));
}
// The sign-in page's answer, from this origin only. Kept until Rust takes it.
let walletInbox = null;
if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => {
if (e.origin !== window.location.origin) return;
const d = e.data;
if (!d || d.type !== 'terminal-wallet' || typeof d.state !== 'string') return;
walletInbox = JSON.stringify({ state: d.state, session: typeof d.session === 'string' ? d.session : null,
error: typeof d.error === 'string' ? d.error : null });
});
}
export function wallet_popup(url) {
const w = window.open(url, 'terminal-wallet', 'popup,width=480,height=760');
return !!w;
}
export function wallet_take() { const v = walletInbox; walletInbox = null; return v; }
export function page_origin() { return window.location.origin; }
"#)]
extern "C" {
#[wasm_bindgen(catch)]
async fn hl_key_save(account: &str, hex: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(catch)]
async fn hl_key_load(account: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(catch)]
async fn hl_key_delete(account: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(js_name = wallet_popup)]
fn js_wallet_popup(url: &str) -> bool;
fn wallet_take() -> Option<String>;
fn page_origin() -> String;
}
/// Open the sign-in page in a popup; `false` if the browser blocked it.
pub(super) fn wallet_popup(url: &str) -> bool {
js_wallet_popup(url)
}
/// This page's origin (`https://terminal.rasmic.xyz`).
pub(super) fn origin() -> String {
page_origin()
}
#[derive(serde::Deserialize)]
struct PageAnswer {
state: String,
session: Option<String>,
error: Option<String>,
}
/// What the page's promises came back with, for the next frame.
enum Event {
Restored { account: String, hex: Option<zeroize::Zeroizing<String>> },
Note(String, bool),
}
thread_local! {
static EVENTS: RefCell<Vec<Event>> = const { RefCell::new(Vec::new()) };
}
fn post(ctx: &egui::Context, e: Event) {
EVENTS.with(|v| v.borrow_mut().push(e));
ctx.request_repaint();
}
fn js_err(e: JsValue) -> String {
e.as_string()
.or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string()))
.unwrap_or_else(|| format!("{e:?}"))
}
impl TerminalApp {
/// Handle what the page's promises returned.
pub(super) fn hl_web_poll(&mut self) {
if let Some(a) = wallet_take().and_then(|t| serde_json::from_str::<PageAnswer>(&t).ok()) {
self.wallet_page_answered(&a.state, a.session, a.error);
}
let events = EVENTS.with(|v| std::mem::take(&mut *v.borrow_mut()));
for e in events {
match e {
Event::Restored { account, hex } => {
let Some(hex) = hex else { continue };
let expected = self.prefs.trading.hl.agent.as_ref().map(|a| a.address.clone());
if let Ok(k) = AgentKey::from_hex(&hex)
&& Some(tr_hl::key::address_hex(&k.address())) == expected
&& self.hl_master().is_some_and(|m| store_account(&m) == account)
{
let _ = self.hl.store.save(&account, &hex);
self.hl.key = Some(std::sync::Arc::new(k));
}
}
Event::Note(m, err) => {
self.hl.busy = None;
self.hl.message = Some((m, err));
}
}
}
}
/// After loading from memory finds nothing: try the encrypted copy, if the user opted in.
pub(super) fn hl_web_restore_key(&mut self) {
if self.hl.key.is_some() || !self.prefs.trading.hl.remember_on_browser || self.prefs.trading.hl.agent.is_none()
{
return;
}
let Some(account) = self.hl_master().map(|m| store_account(&m)) else { return };
let Some(ctx) = self.hl.ctx.clone() else { return };
wasm_bindgen_futures::spawn_local(async move {
let hex = match hl_key_load(&account).await {
Ok(v) => v.as_string().map(zeroize::Zeroizing::new),
Err(e) => {
tracing::warn!("hyperliquid: reading the remembered key: {}", js_err(e));
None
}
};
post(&ctx, Event::Restored { account, hex });
});
}
/// Keep an encrypted copy in this browser (opted in).
pub(super) fn hl_web_remember_key(&mut self) {
let (Some(key), Some(master)) = (self.hl.key.clone(), self.hl_master()) else { return };
if !self.prefs.trading.hl.remember_on_browser {
return;
}
let account = store_account(&master);
let Some(ctx) = self.hl.ctx.clone() else { return };
wasm_bindgen_futures::spawn_local(async move {
let hex = key.to_hex();
if let Err(e) = hl_key_save(&account, &hex).await {
post(&ctx, Event::Note(format!("Couldn't remember the key on this browser: {}", js_err(e)), true));
}
});
}
/// Delete the encrypted copy.
pub(super) fn hl_web_forget_key(&mut self) {
let Some(master) = self.hl_master() else { return };
let account = store_account(&master);
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = hl_key_delete(&account).await {
tracing::warn!("hyperliquid: deleting the remembered key: {}", js_err(e));
}
});
}
}
+4
View File
@@ -242,6 +242,10 @@ impl TerminalApp {
(&["BTCUSD CRNCY", "EURUSD CRNCY"], "Crypto (live) and FX"),
(&["US10Y GOVT"], "US Treasury yields"),
(&["CL1 CMDTY", "GC1 CMDTY"], "Futures and commodities"),
(
&["xyz:AAPL", "AAPL PERP", "PERP"],
"Hyperliquid HIP-3 perps (needs the Terminal server); PERP or ⇧P switches",
),
(&["SECF gold"], "Search securities"),
],
);
+34
View File
@@ -37,6 +37,7 @@ const COMMANDS: &[(&str, &str, &str, Option<Action>)] = &[
("WL", "", "watchlist monitor", Some(Action::Watchlist)),
("N", "", "news headlines", None),
("OB", "", "order book depth dom", None),
("PERP", "", "hip-3 perpetual hyperliquid xyz perps stock perp", Some(Action::TogglePerp)),
("WEI", "", "world equity indices markets", None),
("GC", "", "yield curve treasury rates", None),
("SECF", "Security search", "search find", None),
@@ -217,6 +218,19 @@ fn indicator_items(active: &[IndicatorSpec]) -> Vec<Item> {
}
fn ticker_item(key: &str) -> Item {
use tr_core::perps;
if perps::is_perp(key) {
// A perp's row says it is one, and finds it by the underlying's ticker and name too.
let link = perps::link_for(key);
let badge = perps::badge(key);
let sub = match link {
Some(l) => format!("{} · {badge}", l.name),
None => badge.clone(),
};
let under = link.and_then(|l| tr_core::Security::parse(l.underlying)).map(|s| s.ticker).unwrap_or_default();
let words = format!("{under} {} perp perpetual hyperliquid hip-3 {}", link.map_or("", |l| l.name), badge);
return Item::new(Kind::Ticker, key, sub, Target::Run(key.into())).keywords(words);
}
let name = tr_core::Security::parse(key).and_then(|s| s.name()).unwrap_or("");
Item::new(
Kind::Ticker,
@@ -278,6 +292,9 @@ impl TerminalApp {
for l in tr_core::catalog::search(q, 12) {
v.push(ticker_item(&l.key()));
}
for key in tr_core::perps::search(q, self.perp_candidates(), 8) {
v.push(ticker_item(key));
}
// Whatever the command line would do with the query, offered as-is.
match command::parse(q) {
// An indicator with custom parameters (`SMA 200`, `BB 20 3`).
@@ -654,6 +671,23 @@ mod tests {
assert!(b.contains(&"GC".to_string()), "{b:?}");
}
/// A perp's row is labelled as one and found by its underlying, after the listed stock.
#[test]
fn perp_rows_say_what_they_are() {
let perp = ticker_item("xyz:AAPL");
assert_eq!((perp.title.as_str(), perp.subtitle.as_str()), ("xyz:AAPL", "Apple Inc perp · PERP · HL xyz"));
assert_eq!(perp.target, Target::Run("xyz:AAPL".into()));
assert_eq!(command::parse("xyz:AAPL"), Some(command::Command::Symbol("xyz:AAPL".into())));
assert_eq!(ticker_item("xyz:SKHX").subtitle, "PERP · HL xyz");
let items = vec![ticker_item("AAPL US Equity"), perp];
let titles = |q: &str| rank(&items, &[], q).into_iter().map(|i| i.title).collect::<Vec<_>>();
assert_eq!(titles("aapl"), ["AAPL US Equity", "xyz:AAPL"], "the listed stock first");
assert_eq!(titles("apple perp"), ["xyz:AAPL"]);
assert_eq!(titles("hyperliquid"), ["xyz:AAPL"]);
let perp_cmd = static_items().into_iter().find(|i| i.title == "PERP").expect("PERP is offered");
assert_eq!(perp_cmd.hotkey, Some(Action::TogglePerp));
}
#[test]
fn indicators_are_found_by_code_name_and_category() {
let active = [IndicatorSpec::new("RSI", &[21.0]).unwrap()];
+107 -15
View File
@@ -18,8 +18,14 @@ impl TerminalApp {
let mut actions = Vec::new();
let id = self.panes[i].id;
let dragging = self.drag.is_some_and(|d| d.active);
let switch_to = self.panes[i].kind.symbol().and_then(|s| self.switch_target(s));
let perps: Vec<String> = match self.panes[i].kind {
PaneKind::Search { .. } => self.perp_candidates().into_iter().map(String::from).collect(),
_ => Vec::new(),
};
if let PaneKind::Chart(c) = &mut self.panes[i].kind {
c.hover_enabled = !dragging;
c.switch_to = switch_to;
let clicked = c.show(ui, rect, focused, &self.worker);
actions.append(&mut c.actions);
return (actions, clicked);
@@ -55,7 +61,8 @@ impl TerminalApp {
PaneKind::Curve => actions.extend(markets_view::curve(ui, self.curves.as_deref())),
PaneKind::Search { query } => {
let hits = if self.net_hits.0 == *query { self.net_hits.1.as_slice() } else { &[] };
let (a, changed) = markets_view::search(ui, query, hits);
let perps: Vec<&str> = perps.iter().map(String::as_str).collect();
let (a, changed) = markets_view::search(ui, query, hits, &perps);
actions.extend(a);
if changed && query.trim().len() >= 2 {
self.search_pending = Some((query.clone(), Instant::now()));
@@ -76,14 +83,14 @@ impl TerminalApp {
}
}
PaneKind::OrderBook(b) => {
if let Some(a) = order_book(ui, b) {
if let Some(a) = order_book(ui, b, switch_to) {
actions.push(a);
}
}
PaneKind::Portfolio(pp) => {
let demo =
(self.prefs.trading.account_mode == crate::prefs::AccountMode::Demo).then_some(&*self.demo.pf);
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, demo, &self.quotes) {
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, demo, &self.quotes, &self.hl.mids) {
use super::portfolio_view::PortAction as P;
match a {
P::Core(a) => actions.push(a),
@@ -103,6 +110,7 @@ impl TerminalApp {
),
P::CancelOrder { account, id } => self.request_cancel(&account, &id),
P::ClosePosition { account, key } => self.close_position(&account, &key),
P::Kill => self.hl_kill(),
P::TradingSettings => {
self.settings_tab = super::settings_view::SettingsTab::Trading;
self.open_panel(PaneKind::Settings);
@@ -149,14 +157,14 @@ impl TerminalApp {
let (r, resp) = ui.allocate_exact_size(vec2(w, row_h), Sense::click());
widgets::hover_wash(ui, r, &resp, false);
let p = ui.painter();
p.text(pos2(col(r, 0), r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.5), amber());
p.text(
pos2(col(r, 0) + 74.0, r.center().y),
Align2::LEFT_CENTER,
sec.class.yellow_key().label(),
ui_font(11.0),
muted(),
);
let tick = p.layout_no_wrap(sec.ticker.clone(), mono(12.5), amber());
let label_x = col(r, 0) + tick.size().x.max(66.0) + 8.0;
p.galley(pos2(col(r, 0), r.center().y - tick.size().y / 2.0), tick, amber());
let (label, lc) = match sec.class {
tr_core::AssetClass::Perp => (tr_core::perps::badge(s), theme::perp()),
c => (c.yellow_key().label().to_string(), muted()),
};
p.text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, label, ui_font(11.0), lc);
match self.quotes.get(s) {
Some(q) => {
let c = if q.change() >= 0.0 { theme::up() } else { theme::down() };
@@ -328,17 +336,30 @@ fn fmt_size(q: f64) -> String {
}
}
/// Price ladder: asks above, bids below, depth bars by cumulative size.
fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
/// Price ladder: asks above, bids below, depth bars by cumulative size. `perp` is the live
/// HIP-3 perp linked to the pane's security, whose book it can show instead of a simulated one.
fn order_book(ui: &mut Ui, b: &mut super::BookPane, perp: Option<&'static str>) -> Option<Action> {
let mut action = None;
let key = tr_data::security(&b.symbol);
let book: Option<OrderBook> = b.book.as_ref().map(|x| x.grouped(b.group));
let mut show_perp = false;
ui.horizontal_wrapped(|ui| {
let r = ui.label(RichText::new(&key.ticker).font(mono(13.0)).color(amber())).interact(Sense::click());
if r.clicked() {
action = Some(Action::OpenChart(b.symbol.clone()));
}
r.on_hover_text("Open chart");
if key.class == tr_core::AssetClass::Perp {
let tip = match tr_core::perps::link_for(&b.symbol) {
Some(l) => format!(
"The perp's own book on Hyperliquid. It references {}; it is not that market's book.",
l.reference
),
None => "The perp's own book on Hyperliquid.".into(),
};
ui.label(RichText::new(tr_core::perps::badge(&b.symbol)).font(ui_font(11.5)).color(theme::perp()))
.on_hover_text(tip);
}
if let Some(bk) = &book {
ui.label(RichText::new(&bk.source).font(ui_font(11.5)).color(muted()));
if bk.simulated {
@@ -349,6 +370,26 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
}
}
});
// A listed instrument with no public book, but a live perp on the same underlying.
if let Some(perp) = perp.filter(|p| tr_core::perps::is_perp(p) && book.as_ref().is_none_or(|bk| bk.simulated)) {
ui.horizontal_wrapped(|ui| {
let label = format!("Show the {perp} perp's book");
let tip = format!(
"Load the book of {perp} ({}) here. It is the perp's order book on Hyperliquid, not {}'s.",
tr_core::perps::badge(perp),
key.key()
);
show_perp = ui.small_button(RichText::new(label).color(theme::perp())).on_hover_text(tip).clicked();
});
}
if show_perp && let Some(perp) = perp {
b.symbol = perp.to_string();
b.book = None;
b.error = None;
b.fetched = None;
b.trades.clear();
return action;
}
ui.horizontal_wrapped(|ui| {
let depths = [10usize, 20, 50];
let dl = ["10", "20", "50"];
@@ -376,7 +417,20 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
ui.add(egui::Spinner::new());
return action;
};
let rect = ui.available_rect_before_wrap();
let full = ui.available_rect_before_wrap();
// The book's trades go beside the ladder in a wide pane, below it in a tall one.
let has_tape = !b.trades.is_empty();
let (rect, tape) = if has_tape && full.width() >= dp(560.0) {
let split = full.left() + (full.width() * 0.6).floor();
let tape = Rect::from_min_max(pos2(split + dp(10.0), full.top()), full.max);
(Rect::from_min_max(full.min, pos2(split, full.bottom())), Some(tape))
} else if has_tape && full.height() >= dp(280.0) {
let split = full.bottom() - (full.height() * 0.36).floor();
let tape = Rect::from_min_max(pos2(full.left(), split + dp(4.0)), full.max);
(Rect::from_min_max(full.min, pos2(full.right(), split)), Some(tape))
} else {
(full, None)
};
let d = key.decimals(book.mid().unwrap_or(1.0));
let row = dp(18.0);
let rows_each = (((rect.height() - row * 2.0) / row / 2.0).floor() as usize).max(1);
@@ -441,11 +495,49 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
for (k, level) in bids.iter().enumerate() {
draw((y + k as f32 * row) as f64, *level, true);
}
ui.allocate_rect(rect, Sense::hover());
if let Some(tape) = tape {
trades_tape(&ui.painter_at(tape), tape, &key, &b.symbol, &b.trades, d);
}
ui.allocate_rect(full, Sense::hover());
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
action
}
/// Recent trades next to the book, newest first: time, price coloured by the aggressor's side,
/// size. A perp's tape is titled with its venue.
fn trades_tape(
p: &egui::Painter,
rect: Rect,
key: &tr_core::Security,
symbol: &str,
trades: &[tr_data::TradePrint],
d: usize,
) {
let row = dp(18.0);
let x_price = rect.left() + (rect.width() * 0.62).clamp(dp(110.0), (rect.width() - dp(60.0)).max(dp(110.0)));
let x_size = rect.right() - 6.0;
let head = Rect::from_min_size(rect.min, vec2(rect.width(), row));
p.rect_filled(head, 3.0, theme::header());
let title = match key.class {
tr_core::AssetClass::Perp => format!("Trades · {} · {}", key.ticker, tr_core::perps::badge(symbol)),
_ => format!("Trades · {}", key.ticker),
};
let tc = if key.class == tr_core::AssetClass::Perp { theme::perp() } else { muted() };
p.text(pos2(rect.left() + 6.0, head.center().y), Align2::LEFT_CENTER, title, ui_font(11.0), tc);
let mut y = head.bottom();
for t in trades.iter().rev().take(((rect.bottom() - y) / row).floor().max(0.0) as usize) {
let c = if t.buy { theme::up() } else { theme::down() };
let when = chrono::DateTime::from_timestamp_millis(t.ts_ms)
.map(|x| x.with_timezone(&chrono::Local).format("%H:%M:%S").to_string())
.unwrap_or_default();
let cy = y + row / 2.0;
p.text(pos2(rect.left() + 6.0, cy), Align2::LEFT_CENTER, when, mono(11.5), muted());
p.text(pos2(x_price, cy), Align2::RIGHT_CENTER, format!("{:.*}", d, t.px), mono(12.0), c);
p.text(pos2(x_size, cy), Align2::RIGHT_CENTER, fmt_size(t.sz), mono(12.0), text());
y += row;
}
}
#[cfg(test)]
mod tests {
use super::*;
+45 -11
View File
@@ -43,6 +43,8 @@ pub enum PortAction {
account: String,
key: String,
},
/// The Hyperliquid kill switch (`KILL`).
Kill,
}
// ---- formatting ---------------------------------------------------------------------------
@@ -294,6 +296,7 @@ pub fn portfolio(
b: &BrokerState,
demo: Option<&Portfolio>,
quotes: &HashMap<String, Quote>,
testnet: &HashMap<String, f64>,
) -> Vec<PortAction> {
let mut out = Vec::new();
toolbar(ui, pane, b, demo, &mut out);
@@ -303,7 +306,8 @@ pub fn portfolio(
None => (b.mode(), b.portfolio.as_deref(), b.error.as_ref()),
};
match (mode, pf) {
(BrokerMode::Setup, _) => setup_screen(ui, b, &mut out),
// Without SnapTrade the panes still show a Hyperliquid account.
(BrokerMode::Setup, pf) if pf.is_none_or(|p| p.accounts.is_empty()) => setup_screen(ui, b, &mut out),
(_, None) if b.loading => {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
@@ -327,7 +331,7 @@ pub fn portfolio(
if pane.account.as_ref().is_some_and(|a| pf.account(a).is_none()) {
pane.account = None;
}
let price_of = |p: &Position| b.live_price(quotes, p);
let price_of = |p: &Position| super::broker::live_price(quotes, testnet, p);
match pane.view {
PortView::Summary => {
egui::ScrollArea::vertical()
@@ -729,16 +733,27 @@ fn position_row(
(None, _) => resp.on_hover_text("Not available as a terminal market key"),
};
if let Some(k) = &key {
let tradable = matches!(p.kind, tr_broker::InstrumentKind::Stock | tr_broker::InstrumentKind::Etf)
&& matches!(tr_data::security(k).class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf);
// Hyperliquid perps trade on their own account; a short closes too (reduce-only).
let perp = super::hl::is_hl_account(&p.account_id) && p.market.is_some();
let tradable = perp
|| (matches!(p.kind, tr_broker::InstrumentKind::Stock | tr_broker::InstrumentKind::Etf)
&& matches!(tr_data::security(k).class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf));
let mut pills = Vec::new();
if tradable {
pills.extend(widgets::buy_sell_pills());
if p.units > 0.0 {
if p.units > 0.0 || (perp && p.units != 0.0) {
pills.push(("Close", theme::control(), text()));
}
}
let tips = ["Buy more (⇧B)", "Sell (⇧S)", "Close: sell the whole position"];
let tips = if perp {
[
"Buy (Hyperliquid testnet)",
"Sell (Hyperliquid testnet)",
"Close: a reduce-only market order for all of it",
]
} else {
["Buy more (⇧B)", "Sell (⇧S)", "Close: sell the whole position"]
};
let pill = widgets::row_pills(ui, r, ("pos-pills", &p.account_id, &p.symbol), &pills, &tips);
match pill {
Some(0) => out.push(PortAction::Trade {
@@ -758,7 +773,13 @@ fn position_row(
None => {}
}
resp.context_menu(|ui| {
if p.kind != tr_broker::InstrumentKind::Option {
if perp {
if ui.button(format!("Close Position ({} {})…", qty(p.units), p.short_label())).clicked() {
out.push(PortAction::ClosePosition { account: p.account_id.clone(), key: k.clone() });
ui.close();
}
ui.separator();
} else if p.kind != tr_broker::InstrumentKind::Option {
trade_items(ui, k, Some(&p.account_id), Some(p.units), out);
if tradable && p.units > 0.0 {
if ui.button(format!("Close Position ({} {})…", qty(p.units), p.short_label())).clicked() {
@@ -944,6 +965,19 @@ fn orders(ui: &mut Ui, pane: &mut PortfolioPane, pf: &Portfolio, out: &mut Vec<P
if let Some(i) = widgets::segmented(ui, "port-orders", &["Open", "All"], sel, 20.0) {
pane.open_only = i == 0;
}
if pf.accounts.iter().any(|a| super::hl::is_hl_account(&a.id)) {
let b = egui::Button::new(RichText::new("KILL").font(mono(11.5)).strong().color(Color32::WHITE))
.fill(theme::down());
if ui
.add(b)
.on_hover_text(
"Kill switch: cancel every open Hyperliquid testnet order and turn Hyperliquid orders off",
)
.clicked()
{
out.push(PortAction::Kill);
}
}
if pf.demo {
note(ui, "Demo orders · hover an open order to cancel it");
} else {
@@ -1183,13 +1217,13 @@ mod tests {
for view in PortView::ALL {
let mut pane = PortfolioPane::new(view);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &HashMap::new());
});
out.textures_delta.clear();
pane.account = Some("sim-ira".into());
pane.symbol = Some("SPY US Equity".into());
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &HashMap::new());
});
out.textures_delta.clear();
}
@@ -1198,7 +1232,7 @@ mod tests {
for view in PortView::ALL {
let mut pane = PortfolioPane::new(view);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, Some(&demo), &quotes);
let _ = portfolio(ui, &mut pane, &b, Some(&demo), &quotes, &HashMap::new());
});
out.textures_delta.clear();
}
@@ -1206,7 +1240,7 @@ mod tests {
b.simulate = false;
let mut pane = PortfolioPane::new(PortView::Summary);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &HashMap::new());
});
out.textures_delta.clear();
}
+78 -11
View File
@@ -81,6 +81,8 @@ pub struct Ticket {
pub error: Option<String>,
/// Result of the last placement.
pub done: Option<String>,
/// Hyperliquid's own fields, used when a Hyperliquid account is chosen.
pub hl: super::hl::HlTicket,
}
fn parse_num(s: &str) -> Option<f64> {
@@ -116,6 +118,7 @@ impl Ticket {
placing: false,
error: None,
done: None,
hl: Default::default(),
}
}
@@ -127,6 +130,9 @@ impl Ticket {
if let Some(k) = &args.symbol {
self.symbol = broker_symbol(k);
}
if args.symbol.is_some() || args.units.is_some() || args.notional.is_some() {
self.hl.from_dollars = None;
}
if let Some(u) = args.units {
self.dollars = false;
self.qty = fmt_input(u);
@@ -152,6 +158,15 @@ impl Ticket {
if self.order_type.needs_stop() {
self.advanced = true;
}
self.hl.kind = match (args.stop, args.limit) {
(Some(_), _) => super::hl::HlKind::StopLoss,
(None, Some(_)) => super::hl::HlKind::Limit,
(None, None) if args.symbol.is_some() => super::hl::HlKind::Market,
_ => self.hl.kind,
};
if let Some(s) = args.stop {
self.hl.trigger = fmt_input(s);
}
self.preview = None;
self.review = false;
self.error = None;
@@ -303,6 +318,12 @@ impl TerminalApp {
if account == demo::ACCOUNT_ID {
return Some(everything("Demo account".into()));
}
if super::hl::is_hl_account(account) {
return Some(Capability {
connection_trading: self.hl_signing_blocker().is_none(),
..everything("Hyperliquid testnet".into())
});
}
match self.broker.mode() {
BrokerMode::Simulated => Some(everything(
self.broker
@@ -464,8 +485,14 @@ impl TerminalApp {
ticket.apply(&args);
let accounts = self.trade_accounts();
let known = |id: &str| accounts.iter().any(|a| a.id == id);
// A Hyperliquid perp only trades on Hyperliquid.
let hl_account = self
.hl_master()
.map(|m| super::hl::account_id(&m))
.filter(|id| known(id) && args.symbol.as_deref().is_some_and(tr_data::is_perp));
ticket.account = account
.filter(|a| known(a))
.or(hl_account)
.or(ticket.account.filter(|a| known(a)))
.or(self.prefs.trading.default_account.clone().filter(|a| known(a)))
.or((accounts.len() == 1).then(|| accounts[0].id.clone()));
@@ -541,6 +568,9 @@ impl TerminalApp {
if account == demo::ACCOUNT_ID {
return self.demo_close(&symbol);
}
if super::hl::is_hl_account(account) {
return self.hl_close_position(account, key);
}
self.open_ticket(
TradeArgs { buy: Some(false), symbol: Some(key.into()), all: true, ..Default::default() },
Some(account.into()),
@@ -592,6 +622,9 @@ impl TerminalApp {
}
self.trading.last_submit = Some(Instant::now());
let Some(t) = self.trading.ticket.as_ref() else { return };
if t.account.as_deref().is_some_and(super::hl::is_hl_account) {
return self.hl_review();
}
let quote = self.ticket_quote(t);
let req = match t.request() {
Ok(r) => r,
@@ -747,6 +780,9 @@ impl TerminalApp {
if account == demo::ACCOUNT_ID {
return self.demo_cancel(order_id);
}
if super::hl::is_hl_account(account) {
return self.hl_request_cancel(order_id);
}
if !self.trading_enabled() {
self.status = "Live orders are off: enable them in Settings › Trading to cancel orders here".into();
return;
@@ -926,18 +962,36 @@ impl TerminalApp {
if self.trading.live_prompt {
self.live_dialog(ctx);
}
if self.hl.confirm.is_some() {
self.hl_confirm_dialog(ctx);
}
if self.hl.enable_prompt {
self.hl_enable_dialog(ctx);
}
if self.hl.wallet.deposit || self.hl.wallet.backup_prompt {
self.wallet_windows(ctx);
}
}
fn ticket_window(&mut self, ctx: &egui::Context) {
let mut open = true;
let live = !self.demo_mode();
let title = if live { "Order Ticket · LIVE" } else { "Order Ticket · Demo" };
let hl = live
&& self.trading.ticket.as_ref().and_then(|t| t.account.as_deref()).is_some_and(super::hl::is_hl_account);
let title = match (live, hl) {
(true, true) => "Order Ticket · Hyperliquid TESTNET",
(true, false) => "Order Ticket · LIVE",
_ => "Order Ticket · Demo",
};
let color = match (live, hl) {
(true, true) => amber(),
(true, false) => theme::down(),
_ => theme::border(),
};
let mut act = None;
let frame = egui::Frame::window(&ctx.global_style()).stroke(egui::Stroke::new(
if live { 2.0 } else { 1.0 },
if live { theme::down() } else { theme::border() },
));
egui::Window::new(RichText::new(title).font(ui_font(13.0)).color(if live { theme::down() } else { text() }))
let frame =
egui::Frame::window(&ctx.global_style()).stroke(egui::Stroke::new(if live { 2.0 } else { 1.0 }, color));
egui::Window::new(RichText::new(title).font(ui_font(13.0)).color(if live { color } else { text() }))
.id(egui::Id::new("order-ticket"))
.frame(frame)
.open(&mut open)
@@ -945,11 +999,12 @@ impl TerminalApp {
.resizable(false)
.default_width(dp(320.0))
.default_pos(ctx.content_rect().center() - egui::vec2(dp(160.0), dp(220.0)))
.show(ctx, |ui| act = self.ticket_ui(ui));
.show(ctx, |ui| act = if hl { self.hl_ticket_ui(ui) } else { self.ticket_ui(ui) });
self.sync_sell_all();
if !open
|| (ctx.input(|i| i.key_pressed(egui::Key::Escape))
&& !self.trading.ticket.as_ref().is_some_and(|t| t.confirming))
&& !self.trading.ticket.as_ref().is_some_and(|t| t.confirming)
&& self.hl.confirm.is_none())
{
self.trading.ticket = None;
return;
@@ -1836,7 +1891,9 @@ impl TerminalApp {
}
});
section(ui, "Safety");
self.hl_settings(ui, cmds);
section(ui, "Safety (SnapTrade)");
ui.horizontal(|ui| {
let mut limited = self.prefs.trading.max_order_value.is_some();
if ui.checkbox(&mut limited, "Maximum live order value").changed() {
@@ -1851,7 +1908,13 @@ impl TerminalApp {
}
});
note(ui, "Live orders whose estimated value is above the limit are refused before they are sent.");
let accounts: Vec<Account> = self.broker.portfolio.as_ref().map(|p| p.accounts.clone()).unwrap_or_default();
// SnapTrade's accounts only: Hyperliquid has its own section above.
let accounts: Vec<Account> = self
.broker
.portfolio
.as_ref()
.map(|p| p.accounts.iter().filter(|a| !super::hl::is_hl_account(&a.id)).cloned().collect())
.unwrap_or_default();
if !accounts.is_empty() {
ui.horizontal(|ui| {
ui.label(RichText::new("Default live account").font(ui_font(12.5)));
@@ -1974,10 +2037,14 @@ impl TerminalApp {
),
);
note(ui, "Commands (or ⌘K): BUY AAPL 10 · SELL ALL TSLA · BUY NVDA $500 · SELL MSFT 5 LMT 410 · DEMO · LIVE");
note(
ui,
"Hyperliquid testnet: BUY xyz:AAPL 0.05 @250 · SELL BTC 0.0002 MKT · KILL cancels every open Hyperliquid order.",
);
}
}
enum TicketAct {
pub(super) enum TicketAct {
Submit,
EnableLive,
Mode(AccountMode),
+464
View File
@@ -0,0 +1,464 @@
//! The built-in wallet (Turnkey), testnet only: sign-in through the browser, the short Turnkey
//! session, approving and revoking the trading (agent) key with the wallet, Deposit and backup.
//!
//! The wallet's key never leaves Turnkey. Turnkey signs only `approveAgent` (see
//! `crate::wallet_worker`); orders are signed by the agent key on this device, as before.
//! See `docs/architecture/trading-hyperliquid.md` §4.5.
use std::sync::Arc;
use std::time::Duration;
use tr_hl::model::SpotUsdc;
use tr_turnkey::session::random_state;
use tr_turnkey::{Session, SessionKey};
use web_time::Instant;
use super::TerminalApp;
use crate::prefs::HlWallet;
use crate::wallet_worker::{SignedIn, WalletRequest, WalletResponse, session_account};
use crate::worker::Request;
/// A session with less than this left isn't used to sign: the user signs in again first.
const SESSION_MARGIN_SECS: u64 = 60;
/// How often the Deposit window re-reads spot USDC while it is open.
const BALANCES_EVERY: Duration = Duration::from_secs(15);
/// What to do once a sign-in finishes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum After {
Approve,
Revoke,
}
/// A sign-in waiting for the browser.
pub struct SignIn {
id: u64,
state: String,
/// The device key's public half (the page asks Turnkey for a session bound to it).
#[cfg(not(target_arch = "wasm32"))]
public_key: String,
/// The page to open (again), once known.
pub url: Option<String>,
pub started: Instant,
then: Option<After>,
/// The wallet the pending action is for. A sign-in to any other wallet is refused.
expected: Option<String>,
/// Web: the key waits here for the page's message. Desktop: the worker holds it.
#[cfg(target_arch = "wasm32")]
key: Option<SessionKey>,
}
impl SignIn {
fn new(id: u64, state: String, then: Option<After>, expected: Option<String>) -> Self {
Self {
id,
state,
#[cfg(not(target_arch = "wasm32"))]
public_key: String::new(),
url: None,
started: Instant::now(),
then,
expected,
#[cfg(target_arch = "wasm32")]
key: None,
}
}
/// Past the sign-in's deadline ([`tr_turnkey::SIGN_IN_TIMEOUT`]).
pub fn expired(&self) -> bool {
self.started.elapsed() > tr_turnkey::SIGN_IN_TIMEOUT
}
/// Refuse a wallet other than the one the pending action is for.
fn check_account(&self, address: &str) -> Result<(), String> {
match &self.expected {
Some(want) if !want.eq_ignore_ascii_case(address) => Err(format!(
"You signed in to wallet {}, but this was for wallet {}. Nothing changed; sign in with that wallet's email or passkey.",
super::hl_view::masked(address),
super::hl_view::masked(want)
)),
_ => Ok(()),
}
}
}
#[derive(Default)]
pub struct WalletState {
/// The live Turnkey session (at most 15 minutes), if any.
pub session: Option<Arc<Session>>,
/// The secret store item already read, so it isn't read again every frame.
session_tried: Option<String>,
pub signing_in: Option<SignIn>,
next_id: u64,
pub deposit: bool,
pub backup_prompt: bool,
pub backup_ack: bool,
/// Spot USDC for the Deposit window: `(address, result)`.
pub spot: Option<(String, Result<SpotUsdc, String>)>,
spot_at: Option<Instant>,
/// A revoke is with the wallet: the agent is forgotten only once it really happens.
pub revoking: bool,
}
impl WalletState {
/// A new id for a sign-in, never one used before in this run.
fn next_request_id(&mut self) -> u64 {
self.next_id += 1;
self.next_id
}
/// Forget the wallet's state but keep counting request ids, so a late answer to an old
/// sign-in can never match a new one.
fn reset(&mut self) {
*self = Self { next_id: self.next_id, ..Default::default() };
}
/// Minutes left on the session, if it can still sign.
pub fn session_minutes(&self) -> Option<u64> {
let s = self.session.as_ref()?;
let left = s.remaining(now_secs());
(left > SESSION_MARGIN_SECS).then(|| left.div_ceil(60))
}
}
pub(crate) fn now_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// The sign-in page in the browser build: this site's own `/wallet/`.
#[cfg(target_arch = "wasm32")]
fn web_page() -> String {
format!("{}/wallet", super::hl_web::origin())
}
impl TerminalApp {
fn wallet_send(&self, r: WalletRequest) {
self.worker.send(Request::Wallet(r));
}
/// The signed-in wallet, if any.
pub(super) fn wallet(&self) -> Option<&HlWallet> {
self.prefs.trading.hl.wallet.as_ref()
}
/// Open the sign-in page. `then` runs once the session is back.
pub(super) fn wallet_sign_in(&mut self, then: Option<After>) {
let id = self.hl.wallet.next_request_id();
let key = SessionKey::generate();
let state = random_state();
let public_key = key.public_hex();
// An approve or revoke is for the wallet shown now, and only that one.
let expected = then.and(self.wallet().map(|w| w.address.clone()));
#[allow(unused_mut)]
let mut sign_in = SignIn::new(id, state.clone(), then, expected);
self.hl.message = None;
#[cfg(not(target_arch = "wasm32"))]
{
sign_in.public_key = public_key;
self.hl.wallet.signing_in = Some(sign_in);
self.wallet_send(WalletRequest::SignIn { id, store: self.hl.store.clone(), key, state });
}
#[cfg(target_arch = "wasm32")]
{
let url = tr_turnkey::web_sign_in_url(&web_page(), &public_key, &state);
let opened = super::hl_web::wallet_popup(&url);
sign_in.url = Some(url);
sign_in.key = Some(key);
self.hl.wallet.signing_in = Some(sign_in);
if !opened {
self.hl.message = Some((
"The browser blocked the sign-in window: allow pop-ups, then press Open Again.".into(),
true,
));
}
}
}
/// Stop waiting for the browser.
pub(super) fn wallet_cancel_sign_in(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
crate::wallet_worker::cancel_sign_in();
self.hl.wallet.signing_in = None;
}
/// Open the sign-in page again (the browser tab was closed).
pub(super) fn wallet_reopen(&mut self) {
let Some(url) = self.hl.wallet.signing_in.as_ref().and_then(|s| s.url.clone()) else { return };
#[cfg(not(target_arch = "wasm32"))]
if let Err(e) = super::broker::open_external(&url) {
self.hl.message = Some((format!("Couldn't open the browser: {e}"), true));
}
#[cfg(target_arch = "wasm32")]
if !super::hl_web::wallet_popup(&url) {
self.hl.message = Some(("The browser blocked the sign-in window: allow pop-ups.".into(), true));
}
}
/// Web: the page's message for the sign-in with `state`.
#[cfg(target_arch = "wasm32")]
pub(super) fn wallet_page_answered(&mut self, state: &str, session: Option<String>, error: Option<String>) {
let Some(sign_in) = self.hl.wallet.signing_in.as_mut() else { return };
if !tr_turnkey::session::same_secret(&sign_in.state, state) {
return;
}
match (session, sign_in.key.take()) {
(Some(token), Some(key)) => {
let id = sign_in.id;
self.wallet_send(WalletRequest::Finish { id, store: self.hl.store.clone(), key, token });
}
(_, _) => {
self.hl.wallet.signing_in = None;
self.hl.message =
Some((format!("Not signed in: {}", error.unwrap_or_else(|| "no answer".into())), true));
}
}
}
/// Approve a trading key with the wallet (signing in first if there's no session).
pub(super) fn wallet_approve(&mut self) {
let Some(w) = self.wallet().cloned() else { return };
let Some(session) = self.hl.wallet.session.clone().filter(|_| self.hl.wallet.session_minutes().is_some())
else {
return self.wallet_sign_in(Some(After::Approve));
};
self.hl.busy = Some("Approving the trading key: the wallet signs approveAgent on Turnkey…");
self.hl.message = None;
self.hl.pending_approval = Some(w.address.clone());
self.wallet_send(WalletRequest::Approve {
store: self.hl.store.clone(),
session,
master: w.address,
device: super::hl::device(),
});
}
/// Replace the agent on Hyperliquid with one nobody holds (the wallet signs), and delete its
/// key here.
pub(super) fn wallet_revoke(&mut self) {
let Some(w) = self.wallet().cloned() else { return };
let Some(session) = self.hl.wallet.session.clone().filter(|_| self.hl.wallet.session_minutes().is_some())
else {
return self.wallet_sign_in(Some(After::Revoke));
};
let base = self
.prefs
.trading
.hl
.agent
.as_ref()
.map(|a| tr_hl::trader::agent_base_name(&a.name).to_string())
.unwrap_or_else(|| format!("terminal-{}", super::hl::device()));
// Orders stop now; the agent itself is forgotten when the revoke happens.
self.prefs.trading.hl.enabled = false;
self.save_hl_prefs();
self.hl.wallet.revoking = true;
self.hl.busy = Some("Revoking: the wallet signs the replacement on Turnkey…");
self.wallet_send(WalletRequest::Revoke { store: self.hl.store.clone(), session, master: w.address, base });
}
/// Forget the Turnkey session on this device (trading keeps working with the agent key).
pub(super) fn wallet_end_session(&mut self) {
let Some(w) = self.wallet() else { return };
let account = session_account(&w.address);
self.hl.wallet.session = None;
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
}
/// Back up the wallet: the sign-in page's export mode, where Turnkey's own iframe shows
/// the recovery phrase. Terminal never sees it.
pub(super) fn wallet_backup(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
let url = tr_turnkey::export_url(&tr_turnkey::sign_in_page());
#[cfg(target_arch = "wasm32")]
let url = tr_turnkey::export_url(&web_page());
if let Err(e) = super::broker::open_external(&url) {
self.hl.message = Some((format!("Couldn't open the browser: {e}"), true));
}
}
pub(super) fn wallet_open_deposit(&mut self) {
self.hl.wallet.deposit = true;
self.hl.wallet.spot_at = None;
}
/// Read the session kept in the store (once), drop an expired one, refresh Deposit.
pub(super) fn wallet_periodic(&mut self) {
let Some(w) = self.wallet().cloned() else { return };
let account = session_account(&w.address);
if self.hl.wallet.session.is_none() && self.hl.wallet.session_tried.as_deref() != Some(&account) {
self.hl.wallet.session_tried = Some(account.clone());
self.wallet_send(WalletRequest::LoadSession { store: self.hl.store.clone(), account: account.clone() });
}
if self.hl.wallet.session.as_ref().is_some_and(|s| s.expired(now_secs())) {
self.hl.wallet.session = None;
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
}
if self.hl.wallet.deposit && self.hl.wallet.spot_at.is_none_or(|t| t.elapsed() > BALANCES_EVERY) {
self.hl.wallet.spot_at = Some(Instant::now());
self.wallet_send(WalletRequest::Balances { user: w.address });
}
}
pub(super) fn on_wallet(&mut self, r: WalletResponse) {
match r {
#[cfg(not(target_arch = "wasm32"))]
WalletResponse::Listening { id, port } => {
let Some(s) = self.hl.wallet.signing_in.as_mut().filter(|s| s.id == id) else { return };
let url = tr_turnkey::desktop_sign_in_url(&tr_turnkey::sign_in_page(), &s.public_key, &s.state, port);
s.url = Some(url);
self.wallet_reopen();
}
WalletResponse::SignedIn { id, result } => {
let Some(s) = self.hl.wallet.signing_in.take() else { return };
if s.id != id {
self.hl.wallet.signing_in = Some(s);
return;
}
match result {
Ok(signed) => self.wallet_signed_in(*signed, &s),
Err(e) => self.hl.message = Some((tr_hl::mask_addresses(&e), true)),
}
}
WalletResponse::SessionLoaded { account, session } => {
if self.wallet().is_some_and(|w| session_account(&w.address) == account)
&& self.hl.wallet.session.is_none()
{
self.hl.wallet.session = session;
}
}
WalletResponse::ApproveNotSigned(e) => {
self.hl.busy = None;
self.hl.pending_approval = None;
let e = tr_hl::mask_addresses(&e);
self.hl.message = Some((format!("Nothing was approved: {e}"), true));
}
WalletResponse::RevokeNotSigned(e) => {
self.hl.wallet.revoking = false;
self.hl.busy = None;
let e = tr_hl::mask_addresses(&e);
self.hl.message = Some((format!("Nothing was revoked; the trading key still works: {e}"), true));
}
WalletResponse::Balances { user, result } => {
if self.wallet().is_some_and(|w| w.address == user) {
self.hl.wallet.spot = Some((user, result));
}
}
}
}
fn wallet_signed_in(&mut self, s: SignedIn, sign_in: &SignIn) {
let SignedIn { session, address, user, saved } = s;
if let Err(e) = sign_in.check_account(&address) {
// Not the wallet the action was for: drop that session and change nothing.
let account = session_account(&address);
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
self.hl.message = Some((e, true));
return;
}
let then = sign_in.then;
let organization_id = session.organization_id().to_string();
if self.hl_master().as_deref() != Some(&address) {
// Another account: its agent (if any) is not this one's.
self.prefs.trading.hl.agent = None;
self.prefs.trading.hl.enabled = false;
self.hl.key = None;
self.hl.reset_key_lookup();
}
self.prefs.trading.hl.master = Some(address.clone());
self.prefs.trading.hl.wallet = Some(HlWallet { address: address.clone(), organization_id, user: user.clone() });
self.save_hl_prefs();
self.hl.wallet.session_tried = Some(session_account(&address));
self.hl.wallet.session = Some(Arc::new(session));
let masked = super::hl_view::masked(&address);
let mut note = format!("Signed in: wallet {masked} (Turnkey user {user}).");
if let Err(e) = saved {
note.push_str(&format!(" The session couldn't be kept in {} ({e}).", self.hl.store.describe()));
}
self.hl.message = Some((note, false));
self.hl_start();
match then {
Some(After::Approve) => self.wallet_approve(),
Some(After::Revoke) => self.wallet_revoke(),
None => {}
}
}
/// Stop showing the wallet here and forget its session (its agent key stays unless revoked).
pub(super) fn wallet_remove(&mut self) {
self.wallet_cancel_sign_in();
self.wallet_end_session();
self.prefs.trading.hl.wallet = None;
self.hl.wallet.reset();
}
/// Give up on a sign-in the browser never finished (the desktop's listener stops at the
/// same deadline; the browser build has no listener to time out).
pub(super) fn wallet_sign_in_deadline(&mut self) {
if self.hl.wallet.signing_in.as_ref().is_some_and(SignIn::expired) {
self.wallet_cancel_sign_in();
let mins = tr_turnkey::SIGN_IN_TIMEOUT.as_secs() / 60;
self.hl.message =
Some((format!("The sign-in wasn't finished within {mins} minutes. Start it again."), true));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Greptile #38: removing the wallet reset the request counter, so a cancelled sign-in's
/// late answer carried the same id as the next one and ended it.
#[test]
fn request_ids_stay_unique_when_the_wallet_is_removed() {
let mut w = WalletState::default();
let first = w.next_request_id();
w.reset();
assert_ne!(w.next_request_id(), first);
}
/// Greptile #38: a revoke (or approval) that needed a sign-in must not act on whichever
/// wallet the user happened to sign in to.
#[test]
fn a_sign_in_for_an_action_accepts_only_that_wallet() {
let a = "0x00000000000000000000000000000000000000aa";
let b = "0x00000000000000000000000000000000000000bb";
let pending = SignIn::new(1, "s".into(), Some(After::Revoke), Some(a.into()));
assert!(pending.check_account(a).is_ok());
assert!(pending.check_account(&a.to_ascii_uppercase().replace("0X", "0x")).is_ok());
assert!(pending.check_account(b).unwrap_err().contains("0x0000…00aa"));
let plain = SignIn::new(2, "s".into(), None, None);
assert!(plain.check_account(b).is_ok(), "a plain sign-in takes any wallet");
}
/// Greptile #38: the browser build's sign-in had no deadline at all.
#[test]
fn a_sign_in_expires_after_ten_minutes() {
let mut s = SignIn::new(1, "s".into(), None, None);
assert!(!s.expired());
s.started = Instant::now() - (tr_turnkey::SIGN_IN_TIMEOUT + Duration::from_secs(1));
assert!(s.expired());
}
#[test]
fn a_session_close_to_expiry_does_not_sign() {
let key = SessionKey::generate();
let claims = |exp| tr_turnkey::session::Claims {
organization_id: "org".into(),
user_id: "u".into(),
public_key: key.public_hex(),
exp,
session_type: String::new(),
};
let mut w = WalletState::default();
assert_eq!(w.session_minutes(), None);
let token = tr_turnkey::session::unsigned_token(&claims(now_secs() + 600));
w.session =
Some(Arc::new(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, now_secs()).unwrap()));
assert_eq!(w.session_minutes(), Some(10));
let token = tr_turnkey::session::unsigned_token(&claims(now_secs() + 30));
w.session =
Some(Arc::new(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, now_secs()).unwrap()));
assert_eq!(w.session_minutes(), None, "under a minute left: sign in again first");
}
}
+374
View File
@@ -0,0 +1,374 @@
//! The built-in wallet's views: its part of Settings › Trading › Hyperliquid, the Deposit
//! window (address, QR code, testnet balances) and the warning before a backup. Each shows
//! the amber TESTNET badge.
use egui::{Color32, RichText, Ui};
use tr_hl::nonce::now_ms;
use super::TerminalApp;
use super::hl_view::{masked, testnet_badge};
use super::portfolio_view::money;
use crate::prefs::HlPrefs;
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
/// A QR code of `data`, `side` points square, dark modules on white with a 4-module margin.
pub fn qr_code(ui: &mut Ui, data: &str, side: f32) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::hover());
let Ok(code) = qrcode::QrCode::new(data.as_bytes()) else { return resp };
let n = code.width();
let colors = code.to_colors();
let cell = side / (n + 8) as f32;
let p = ui.painter_at(rect);
p.rect_filled(rect, 4.0, Color32::WHITE);
for (i, c) in colors.iter().enumerate() {
if *c == qrcode::Color::Dark {
let (x, y) = ((i % n) as f32 + 4.0, (i / n) as f32 + 4.0);
let min = rect.min + egui::vec2(x * cell, y * cell);
p.rect_filled(egui::Rect::from_min_size(min, egui::vec2(cell, cell)), 0.0, Color32::BLACK);
}
}
resp
}
impl TerminalApp {
/// Settings › Trading › Hyperliquid: the wallet (or a watched address) and the trading key.
pub(super) fn wallet_section(&mut self, ui: &mut Ui, h: &HlPrefs) {
let note = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(ui_font(11.5)).color(muted()));
};
let busy = self.hl.busy.is_some();
let signing_in = self.hl.wallet.signing_in.is_some();
match (&h.wallet, &h.master) {
(Some(w), _) => {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(format!("Wallet {}", masked(&w.address))).font(mono(12.5)).color(text()));
self.stream_dot(ui);
if !w.user.is_empty() {
note(ui, &format!("· Turnkey · {}", w.user));
}
});
ui.horizontal_wrapped(|ui| {
if ui
.button("Deposit…")
.on_hover_text("The wallet's address, a QR code and its testnet balances")
.clicked()
{
self.wallet_open_deposit();
}
if ui
.button("Back Up Wallet…")
.on_hover_text("Show the recovery phrase in Turnkey's own window")
.clicked()
{
self.hl.wallet.backup_prompt = true;
self.hl.wallet.backup_ack = false;
}
if ui
.add_enabled(!busy, egui::Button::new("Remove").small())
.on_hover_text("Stop showing this wallet here (the trading key stays unless revoked)")
.clicked()
{
self.hl_disconnect();
}
});
ui.horizontal_wrapped(|ui| match self.hl.wallet.session_minutes() {
Some(m) => {
note(ui, &format!("Signed in to Turnkey for {m} more min."));
if ui.add(egui::Button::new("End Session").small()).clicked() {
self.wallet_end_session();
}
}
None => {
note(ui, "Not signed in to Turnkey. You only need to be to approve or revoke a trading key.")
}
});
}
(None, Some(m)) => {
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!("Watching {} (read-only)", masked(m))).font(mono(12.5)).color(text()),
);
self.stream_dot(ui);
if ui
.add_enabled(!busy, egui::Button::new("Remove").small())
.on_hover_text("Stop showing this account")
.clicked()
{
self.hl_disconnect();
}
});
self.sign_in_button(ui, signing_in, "Create Wallet / Sign In…");
}
(None, None) => {
self.sign_in_button(ui, signing_in, "Create Wallet / Sign In…");
note(
ui,
"Opens Terminal's sign-in page in your browser. Sign in with an email code or a passkey; a new account gets a wallet held by Turnkey. Terminal never sees the wallet's key, and you never paste one.",
);
egui::CollapsingHeader::new(RichText::new("Watch an address (read-only)").font(ui_font(12.0)))
.id_salt("hl-watch")
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.hl.connect_form)
.hint_text("0x… (testnet address)")
.font(mono(12.0))
.desired_width(dp(330.0)),
);
if ui.button("Show Account").on_hover_text("Read-only: no key involved").clicked() {
let a = self.hl.connect_form.clone();
self.hl_connect(&a);
}
});
note(ui, "Fills PORT, POS, ORD and ACTV from Hyperliquid testnet. Nothing is signed.");
});
}
}
if let Some(s) = &self.hl.wallet.signing_in {
let waited = s.started.elapsed().as_secs() / 60;
let reopen = s.url.is_some();
ui.horizontal_wrapped(|ui| {
ui.add(egui::Spinner::new().size(12.0));
note(
ui,
&format!("Finish signing in in your browser (terminal.rasmic.xyz/wallet). {waited} min so far, 10 at most."),
);
if reopen && ui.add(egui::Button::new("Open Again").small()).clicked() {
self.wallet_reopen();
}
if ui.add(egui::Button::new("Cancel").small()).clicked() {
self.wallet_cancel_sign_in();
}
});
}
// The trading (agent) key.
ui.add_space(dp(6.0));
match &h.agent {
Some(a) => {
let days = (a.valid_until.saturating_sub(now_ms())) / (24 * 3600 * 1000);
let expiry = chrono::DateTime::from_timestamp_millis(a.valid_until as i64)
.map(|t| t.with_timezone(&chrono::Local).format("%d %b %Y").to_string())
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
"Trading key {} · {}",
tr_hl::trader::agent_base_name(&a.name),
masked(&a.address)
))
.font(mono(12.0))
.color(text()),
);
note(ui, &format!("· expires {expiry} · key in {}", self.hl.store.describe()));
});
if days < 7 {
ui.label(
RichText::new(format!(
"The trading key expires in {days} days: revoke it and approve a new one."
))
.font(ui_font(12.0))
.color(amber()),
);
}
if self.hl.key.is_none() && !self.hl.key_loading {
ui.label(
RichText::new("This device has no key for this trading key.")
.font(ui_font(12.0))
.color(amber()),
);
}
ui.horizontal_wrapped(|ui| {
if h.wallet.is_some()
&& ui
.add_enabled(!busy && !signing_in, egui::Button::new("Revoke on Hyperliquid"))
.on_hover_text(
"The wallet replaces this key on Hyperliquid with one nobody holds: one Turnkey signature, after a sign-in if needed",
)
.clicked()
{
self.wallet_revoke();
}
if ui
.add_enabled(!busy, egui::Button::new("Delete Key Here"))
.on_hover_text("Terminal can't sign with it any more; it stays approved on Hyperliquid until it expires or is revoked")
.clicked()
{
self.hl_delete_agent_key();
}
});
}
None if h.wallet.is_some() => {
let go = ui
.add_enabled(
!busy && !signing_in && self.hl.store_error.is_none(),
egui::Button::new(RichText::new("Approve Trading Key").strong()),
)
.on_hover_text("Signs in to Turnkey first if there's no session")
.clicked();
if go {
self.wallet_approve();
}
note(
ui,
"Terminal makes a trading key on this device, and your wallet signs approveAgent for it once on Turnkey (one signature). It is named terminal-<device> and valid 180 days. It can trade but not withdraw. The wallet itself never signs orders.",
);
}
None => {}
}
}
fn sign_in_button(&mut self, ui: &mut Ui, signing_in: bool, label: &str) {
let b = egui::Button::new(RichText::new(label).strong().color(Color32::BLACK)).fill(amber());
if ui.add_enabled(!signing_in && self.hl.busy.is_none(), b).clicked() {
self.wallet_sign_in(None);
}
}
fn stream_dot(&self, ui: &mut Ui) {
let (dot, status) = if self.hl.connected {
(theme::up(), self.hl.stream_status.clone())
} else if self.hl.stream_status.is_empty() {
(amber(), "Connecting…".to_string())
} else {
(amber(), self.hl.stream_status.clone())
};
let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 4.0, dot);
ui.label(RichText::new(status).font(ui_font(11.5)).color(muted()));
if let Some(u) = &self.hl.user {
ui.label(
RichText::new(format!("· value {}", money(u.account_value(), "USD")))
.font(ui_font(11.5))
.color(muted()),
);
}
}
/// The Deposit window and the backup warning, when open.
pub(super) fn wallet_windows(&mut self, ctx: &egui::Context) {
if self.hl.wallet.deposit {
self.deposit_window(ctx);
}
if self.hl.wallet.backup_prompt {
self.backup_dialog(ctx);
}
}
fn deposit_window(&mut self, ctx: &egui::Context) {
let Some(w) = self.wallet().cloned() else {
self.hl.wallet.deposit = false;
return;
};
let mut close = false;
let modal = egui::Modal::new(egui::Id::new("wallet-deposit")).show(ctx, |ui| {
ui.set_width(dp(440.0));
ui.horizontal(|ui| {
testnet_badge(ui);
ui.label(RichText::new("Deposit").font(ui_font(16.0)).strong());
});
ui.add_space(dp(4.0));
ui.label(
RichText::new(
"Hyperliquid testnet only. Send test USDC on Hyperliquid testnet to this address. Never send real funds: this build can't reach mainnet.",
)
.font(ui_font(12.0))
.color(amber()),
);
ui.add_space(dp(8.0));
ui.vertical_centered(|ui| {
qr_code(ui, &w.address, dp(196.0)).on_hover_text("Your wallet's address");
});
ui.add_space(dp(8.0));
ui.horizontal(|ui| {
ui.label(RichText::new(&w.address).font(mono(12.0)).color(text()));
if ui.button("Copy").clicked() {
ui.ctx().copy_text(w.address.clone());
self.trade_toast("Wallet address copied", true);
}
});
ui.add_space(dp(6.0));
let user = self.hl.user.clone();
egui::Grid::new("wallet-balances").num_columns(2).spacing([dp(24.0), dp(4.0)]).show(ui, |ui| {
let row = |ui: &mut Ui, k: &str, v: String| {
ui.label(RichText::new(k).font(ui_font(12.0)).color(muted()));
ui.label(RichText::new(v).font(mono(12.5)).color(text()));
ui.end_row();
};
let perp = |f: fn(&tr_hl::stream::UserState) -> f64| {
user.as_deref().map(|u| money(f(u), "USD")).unwrap_or_else(|| "loading…".into())
};
row(ui, "Perp account value", perp(|u| u.account_value()));
row(ui, "Withdrawable", perp(|u| u.withdrawable()));
let spot = match &self.hl.wallet.spot {
Some((_, Ok(s))) if s.hold > 0.0 => format!("{} ({} on hold)", money(s.total, "USD"), money(s.hold, "USD")),
Some((_, Ok(s))) => money(s.total, "USD"),
Some((_, Err(e))) => format!("unavailable ({})", tr_hl::mask_addresses(e)),
None => "loading…".into(),
};
row(ui, "Spot USDC", spot);
});
ui.add_space(dp(6.0));
ui.label(
RichText::new("Test USDC comes from Hyperliquid's testnet faucet or a transfer from another testnet account.")
.font(ui_font(11.5))
.color(muted()),
);
ui.add_space(dp(6.0));
if ui.button("Close").clicked() {
close = true;
}
});
if close || modal.should_close() {
self.hl.wallet.deposit = false;
}
}
fn backup_dialog(&mut self, ctx: &egui::Context) {
let mut choice = None;
let modal = egui::Modal::new(egui::Id::new("wallet-backup")).show(ctx, |ui| {
ui.set_width(dp(450.0));
ui.horizontal(|ui| {
testnet_badge(ui);
ui.label(RichText::new("Back up your wallet?").font(ui_font(16.0)).strong());
});
ui.add_space(dp(4.0));
for line in [
"Your recovery phrase controls the wallet. Anyone who has it can take everything in it.",
"You'll sign in again on terminal.rasmic.xyz, and Turnkey shows the phrase in its own secure frame there. Terminal and Goshen never see it and can't recover it for you.",
"Write it down offline. Never type it into a website, and never share it, not even with support.",
] {
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
}
ui.add_space(dp(6.0));
ui.checkbox(&mut self.hl.wallet.backup_ack, "I'm somewhere private, and I understand the above");
ui.add_space(dp(6.0));
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
choice = Some(false);
}
let on = self.hl.wallet.backup_ack;
let b = if on {
egui::Button::new(RichText::new("Open the Backup Page").strong().color(Color32::BLACK)).fill(amber())
} else {
egui::Button::new(RichText::new("Open the Backup Page").strong())
};
if ui.add_enabled(on, b).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
if let Some(go) = choice {
self.hl.wallet.backup_prompt = false;
self.hl.wallet.backup_ack = false;
if go {
self.wallet_backup();
}
}
}
}
+48
View File
@@ -319,6 +319,54 @@ impl TerminalApp {
})
}
/// HIP-3 perps search can offer: those the server lists as live, or the linked ones until it
/// has answered.
pub(super) fn perp_candidates(&self) -> Vec<&str> {
match &self.live_perps {
Some(v) => v.iter().map(String::as_str).collect(),
None => tr_core::perps::LINKS.iter().map(|l| l.coin).collect(),
}
}
/// Whether the Terminal server lists the HIP-3 perp `coin` as live.
pub(super) fn perp_is_live(&self, coin: &str) -> bool {
self.live_perps.as_ref().is_some_and(|v| v.iter().any(|x| x == coin))
}
/// Where a pane on `key` can switch to: a perp's linked instrument, or an instrument's live
/// perp. Links to perps show only once the server has listed them live.
pub(super) fn switch_target(&self, key: &str) -> Option<&'static str> {
tr_core::perps::switch_target(key, |c| self.perp_is_live(c))
}
/// `PERP`: from a security to its HIP-3 perp, or from a perp back to its security.
pub(super) fn open_perp(&mut self, key: Option<String>) {
use tr_core::perps;
let Some(key) = key.map(|k| tr_data::security(&k).key()) else {
self.status = "PERP needs a security, for example AAPL PERP".into();
return;
};
if perps::is_perp(&key) {
match perps::link_for(&key) {
Some(l) => self.open_security(l.underlying),
None => self.status = format!("{key} is a HIP-3 perp; no listed instrument is linked to it"),
}
return;
}
let Some(l) = perps::perp_for(&key) else {
self.status = format!("No HIP-3 perp is linked to {key}");
return;
};
// Until the server has answered, try anyway: the pane says why if it can't load.
if self.live_perps.is_some() && !self.perp_is_live(l.coin) {
self.status = format!("{} isn't live on the Terminal server right now", l.coin);
return;
}
self.open_security(l.coin);
self.status =
format!("{} ({}) references {}. It is a perp, not {key}.", l.coin, perps::badge(l.coin), l.reference);
}
/// The focused security if it has listed options (US equity, ETF, index), else AAPL.
pub(super) fn optionable_symbol(&self) -> String {
use tr_core::AssetClass as C;
+119 -17
View File
@@ -97,6 +97,8 @@ pub struct ChartState {
paging: bool,
exhausted: bool,
error: Option<String>,
/// Why the bars shown, from the disk cache, couldn't be refreshed.
stale: Option<String>,
built: Vec<BuiltIndicator>,
compare_line: Option<LineRef>,
compare_scale: f64,
@@ -115,6 +117,9 @@ pub struct ChartState {
/// The workspace drew this chart's controls in the pane's tab strip, so skip the
/// header row and give the plot the space.
pub controls_in_strip: bool,
/// What `PERP` would switch this chart to (its live HIP-3 perp, or a perp's listed
/// instrument), set by the app each frame.
pub switch_to: Option<&'static str>,
}
impl ChartState {
@@ -148,6 +153,7 @@ impl ChartState {
paging: false,
exhausted: false,
error: None,
stale: None,
built: Vec::new(),
compare_line: None,
compare_scale: 1.0,
@@ -161,6 +167,7 @@ impl ChartState {
actions: Vec::new(),
hover_enabled: true,
controls_in_strip: false,
switch_to: None,
}
}
@@ -175,6 +182,7 @@ impl ChartState {
self.paging = false;
self.exhausted = false;
self.error = None;
self.stale = None;
self.viewport = None;
self.manual_scale = None;
self.glide = None;
@@ -397,6 +405,7 @@ impl ChartState {
prepended: usize,
from_cache: bool,
exhausted: bool,
full: bool,
live: bool,
w: &Worker,
) {
@@ -421,9 +430,10 @@ impl ChartState {
self.bars = bars;
self.gpu = Some(gpu);
self.paging = false;
self.exhausted = exhausted || self.exhausted && prepended == 0;
self.exhausted = end_of_history(self.exhausted, exhausted, prepended, full);
if !from_cache {
self.loading = false;
self.stale = None;
}
self.error = None;
match self.viewport.as_mut() {
@@ -439,7 +449,7 @@ impl ChartState {
}
self.apply_range();
// Live ticks only move the last bar: refresh indicators at most every 1.5 s and
// leave the compare series alone.
// leave the compare series alone. (A live feed filling a gap arrives as a refresh.)
let due = self.last_indicator_req.is_none_or(|t| t.elapsed().as_millis() >= 1500);
if !live || due {
self.request_indicators(w);
@@ -450,13 +460,33 @@ impl ChartState {
}
}
/// The range row's status: errors, loading, or how many bars are shown and from where.
fn status_text(&self) -> String {
match (&self.error, self.loading, self.paging) {
(Some(e), _, _) => format!("ERR {e}"),
(_, true, _) => "loading…".into(),
(_, _, true) => "loading history…".into(),
_ => format!(
"{} {}bars{}{}{}",
self.bars.len(),
if self.stale.is_some() { "cached " } else { "" },
self.stale.as_deref().map(|e| format!(" · {e}")).unwrap_or_default(),
if self.log { " LOG" } else { "" },
if self.magnet { " MAGNET" } else { "" }
),
}
}
pub fn on_history_error(&mut self, generation: u64, error: String) {
if generation == self.generation {
let paging = std::mem::take(&mut self.paging);
self.loading = false;
self.paging = false;
self.exhausted = true;
if self.bars.is_empty() {
self.error = Some(error);
} else if !paging {
// The refresh after painting from the disk cache failed.
self.stale = Some(error);
}
}
}
@@ -561,6 +591,9 @@ impl ChartState {
);
hui.spacing_mut().item_spacing.x = 6.0;
hui.label(RichText::new(&self.sec.ticker).font(mono(12.0)).color(amber()));
if self.sec.class == AssetClass::Perp {
hui.label(RichText::new(tr_core::perps::badge(&self.symbol)).font(ui_font(11.0)).color(theme::perp()));
}
if hui.available_width() > Self::controls_width(&hui) {
clicked |= self.controls(&mut hui, w);
} else {
@@ -613,24 +646,18 @@ impl ChartState {
}
rx = lr.right();
}
let status = match (&self.error, self.loading, self.paging) {
(Some(e), _, _) => format!("ERR {e}"),
(_, true, _) => "loading…".into(),
(_, _, true) => "loading history…".into(),
_ => format!(
"{} bars{}{}",
self.bars.len(),
if self.log { " LOG" } else { "" },
if self.magnet { " MAGNET" } else { "" }
),
};
painter.text(
let status_color = if self.error.is_some() || self.stale.is_some() { theme::down() } else { muted() };
let status = painter.text(
pos2(range_row.right() - 6.0, range_row.center().y),
Align2::RIGHT_CENTER,
status,
self.status_text(),
mono(10.5),
muted(),
status_color,
);
if let Some(target) = self.switch_to {
let room = status.left() - 12.0 - (rx + 10.0);
clicked |= self.switch_chip(ui, &painter, pos2(rx + 10.0, range_row.center().y), room, target);
}
let body = Rect::from_min_max(pos2(rect.left(), header.bottom()), pos2(rect.right(), range_row.top()));
let plot_all = Rect::from_min_max(body.min, pos2(body.right() - AXIS_W, body.bottom() - TIME_H));
@@ -640,6 +667,11 @@ impl ChartState {
let Some(gpu) = self.gpu.clone() else {
let msg = self.error.clone().unwrap_or_else(|| format!("Loading {} {}…", self.symbol, self.tf));
if self.sec.class == AssetClass::Perp {
let badge = tr_core::perps::badge(&self.symbol);
let below = plot_all.center() + vec2(0.0, 20.0);
painter.text(below, Align2::CENTER_CENTER, badge, ui_font(11.5), theme::perp());
}
painter.text(plot_all.center(), Align2::CENTER_CENTER, msg, mono(13.0), amber_dim());
return clicked;
};
@@ -1085,6 +1117,36 @@ impl ChartState {
}
}
/// The range row's link between a security and its HIP-3 perp, drawn when it fits in
/// `room`. Clicking it (or ⇧P) loads the other one here.
fn switch_chip(&mut self, ui: &Ui, painter: &egui::Painter, left: Pos2, room: f32, target: &str) -> bool {
let to_perp = tr_core::perps::is_perp(target);
let label = if to_perp { format!("⇄ {target} PERP") } else { format!("⇄ {target}") };
let g = painter.layout_no_wrap(label, mono(11.0), theme::perp());
if g.size().x + 10.0 > room {
return false;
}
let r = Rect::from_min_size(pos2(left.x, left.y - RANGE_H / 2.0 + 2.0), vec2(g.size().x + 10.0, RANGE_H - 4.0));
let resp = ui.interact(r, ui.id().with((self.id, "perp-switch")), Sense::click());
let fill = if resp.hovered() { theme::control_hover() } else { theme::control() };
painter.rect_filled(r, 3.0, fill);
painter.galley(pos2(r.left() + 5.0, r.center().y - g.size().y / 2.0), g, theme::perp());
let tip = match tr_core::perps::link_for(if to_perp { target } else { &self.symbol }) {
Some(l) if to_perp => format!(
"Open {target}, the HIP-3 perp on Hyperliquid's {} dex (⇧P). It references {}. A perp's price \
can differ from the listed instrument's, and it trades when that market is closed.",
tr_core::perps::venue(target).unwrap_or("xyz"),
l.reference
),
_ => format!("Open {target}, the listed instrument this perp references (⇧P)"),
};
let resp = resp.on_hover_text(tip);
if resp.clicked() {
self.actions.push(Action::OpenChart(target.to_string()));
}
resp.clicked()
}
fn paint_legend(&self, ui: &Ui, price_rect: Rect, i: usize, sub_rects: &[(Rect, usize, PriceScale)]) {
let Some(b) = self.bars.get(i) else { return };
let p = ui.painter_at(price_rect);
@@ -1100,6 +1162,9 @@ impl ChartState {
*x += g.size().x + 8.0;
};
put(format!("{} · {}", self.sec.ticker, self.tf), amber(), &mut x);
if self.sec.class == AssetClass::Perp {
put(tr_core::perps::badge(&self.symbol), theme::perp(), &mut x);
}
for (k, val) in [("O", b.open), ("H", b.high), ("L", b.low), ("C", b.close)] {
put(format!("{k}{val:.dec$}"), col, &mut x);
}
@@ -1435,10 +1500,47 @@ fn hover_card(ui: &Ui, at: Pos2, t: &Trade) {
}
}
/// Whether the chart has reached the start of its history after a response. A full load decides
/// it, since it may come from another source than the bars before it. A page decides it too. Live
/// updates and refreshes keep what the chart knew.
fn end_of_history(was: bool, exhausted: bool, prepended: usize, full: bool) -> bool {
if full { exhausted } else { exhausted || was && prepended == 0 }
}
#[cfg(test)]
mod tests {
use super::*;
/// Bars from the disk cache whose refresh failed say so, so a perp whose server is down
/// never passes for live. A failed page of older bars leaves the shown ones as they are.
#[test]
fn cached_bars_that_could_not_refresh_say_why() {
let down = "the Terminal server is not reachable";
let mut c = ChartState::new(1, "xyz:AAPL", Timeframe::M15);
c.on_history_error(c.generation, down.into());
assert_eq!(c.status_text(), format!("ERR {down}"), "with nothing to show, the error is the message");
c.error = None;
c.bars = Arc::new((0..3).map(|i| Bar::new(i * 900, 1.0, 1.0, 1.0, 1.0, 1.0)).collect());
c.on_history_error(c.generation, down.into());
assert_eq!(c.status_text(), format!("3 cached bars · {down}"));
c.on_history_error(c.generation + 1, "another load".into());
assert_eq!(c.status_text(), format!("3 cached bars · {down}"), "other generations are ignored");
let mut p = ChartState::new(2, "xyz:AAPL", Timeframe::M15);
p.bars = c.bars.clone();
p.paging = true;
p.on_history_error(p.generation, down.into());
assert_eq!(p.status_text(), "3 bars", "a failed page doesn't make the shown bars stale");
}
#[test]
fn a_full_load_from_a_new_source_can_page_again() {
assert!(!end_of_history(true, false, 0, true), "a reload from a source with more history");
assert!(end_of_history(false, true, 0, true));
assert!(end_of_history(true, false, 0, false), "a live update keeps the end");
assert!(!end_of_history(true, false, 5, false), "a page that added bars");
assert!(end_of_history(false, true, 0, false), "an empty page");
}
#[test]
fn glide_pans_linearly_and_zooms_evenly() {
let from = Viewport { right: 100.0, bar_px: 2.0, width_px: 800.0 };
+55 -3
View File
@@ -2,6 +2,8 @@
//!
//! Examples: `AAPL`, `AAPL US EQUITY <GO>`, `5`, `1W`, `RSI`, `SMA 50`, `ICHIMOKU`, `IND MOMENTUM`,
//! `CMP MSFT`, `AAPL CONG`, `CONG`, `POL PELOSI`, `TRACK PELOSI`, `LAYOUT 4`, `FIB`, `HELP`.
//! Hyperliquid HIP-3 perps go by their venue key (`xyz:AAPL`), and `AAPL PERP` opens the perp
//! linked to a security.
use tr_chart::drawings::Tool;
use tr_chart::scene::ChartType;
@@ -275,6 +277,11 @@ pub enum Function {
Trade(TradeArgs),
/// Switch between the demo account and live accounts (`DEMO`, `MODE LIVE`); `true` = live.
AccountMode(bool),
/// `KILL`: cancel every open Hyperliquid order and turn Hyperliquid orders off (confirmed).
Kill,
/// `AAPL PERP`: open the HIP-3 perp linked to a security. Alone, or on a perp, it switches
/// the focused pane between the two.
Perp,
}
fn num<T: std::str::FromStr>(t: Option<&&str>) -> Option<T> {
@@ -358,6 +365,7 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
"BUY" => Function::Trade(TradeArgs::parse(Some(true), rest)?),
"SELL" => Function::Trade(TradeArgs::parse(Some(false), rest)?),
"TRADE" | "TICKET" | "EMSX" => Function::Trade(TradeArgs::parse(None, rest)?),
"KILL" if rest.is_empty() => Function::Kill,
"DEMO" | "PAPER" if rest.is_empty() => Function::AccountMode(false),
"MODE" => match words().join(" ").as_str() {
"DEMO" | "PAPER" => Function::AccountMode(false),
@@ -365,6 +373,7 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
_ => return None,
},
"RANGE" => Function::Range(Range::parse(rest.first()?)?),
"PERP" if rest.is_empty() => Function::Perp,
// Timeframes win where spellings overlap (`1D`, `1M`); otherwise try a range (`5D`, `YTD`),
// then the indicator catalog (`RSI`, `BB 20 2.5`).
_ => {
@@ -418,9 +427,20 @@ pub fn hint(input: &str) -> String {
match parse(input) {
None if input.trim().is_empty() => "Type a ticker and press <GO> (Enter). HELP for functions.".into(),
None => "Unrecognised command".into(),
Some(Command::Symbol(s)) => match Security::parse(&s).and_then(|x| x.name()) {
Some(name) => format!("Load {s} — {name}"),
None => format!("Load {s}"),
Some(Command::Symbol(s)) => {
let badge = tr_core::perps::is_perp(&s).then(|| tr_core::perps::badge(&s));
match (Security::parse(&s).and_then(|x| x.name()), badge) {
(Some(name), Some(b)) => format!("Load {s} — {name} · {b}"),
(None, Some(b)) => format!("Load {s} · {b}"),
(Some(name), None) => format!("Load {s} — {name}"),
(None, None) => format!("Load {s}"),
}
}
Some(Command::SymbolFunction(s, Function::Perp)) => match tr_core::perps::counterpart(&s) {
Some(other) if tr_core::perps::is_perp(&s) => format!("{s}: switch to {other}"),
Some(perp) => format!("{s}: open its HIP-3 perp {perp} ({})", tr_core::perps::badge(perp)),
None if tr_core::perps::is_perp(&s) => format!("{s}: no listed instrument is linked to it"),
None => format!("{s}: no HIP-3 perp is linked to it"),
},
Some(Command::SymbolFunction(s, f)) => format!("{s}: {}", describe(&f)),
Some(Command::Function(f)) => describe(&f),
@@ -534,6 +554,10 @@ fn describe(f: &Function) -> String {
}
Function::AccountMode(false) => "Switch to the DEMO account (paper trading)".into(),
Function::AccountMode(true) => "Switch to LIVE brokerage accounts (asks first)".into(),
Function::Kill => {
"Kill switch: cancel every open Hyperliquid testnet order, then turn Hyperliquid orders off".into()
}
Function::Perp => "Switch between the security and its HIP-3 perp (PERP · HL xyz)".into(),
}
}
@@ -694,6 +718,10 @@ mod tests {
assert_eq!(t("SELL ALL TSLA 5"), None);
assert_eq!(parse("DEMO"), Some(Command::Function(Function::AccountMode(false))));
assert_eq!(parse("MODE LIVE"), Some(Command::Function(Function::AccountMode(true))));
assert_eq!(parse("KILL"), Some(Command::Function(Function::Kill)));
assert_eq!(parse("kill"), Some(Command::Function(Function::Kill)));
let perp = t("BUY xyz:AAPL 0.5 @226.4").unwrap();
assert_eq!((perp.symbol.as_deref(), perp.units, perp.limit), (Some("xyz:AAPL"), Some(0.5), Some(226.4)));
}
#[test]
@@ -713,6 +741,30 @@ mod tests {
assert_eq!(parse("AAPL WHATEVER"), None);
}
#[test]
fn hip3_perps() {
let sym = |s: &str| Some(Command::Symbol(s.into()));
assert_eq!(parse("xyz:aapl"), sym("xyz:AAPL"));
assert_eq!(parse("XYZ:AAPL <GO>"), sym("xyz:AAPL"));
assert_eq!(parse("aapl"), sym("AAPL US Equity"), "a ticker is still the listed stock");
assert_eq!(
parse("xyz:AAPL 5"),
Some(Command::SymbolFunction("xyz:AAPL".into(), Function::Timeframe(Timeframe::M5)))
);
assert_eq!(parse("xyz:GOLD OB"), Some(Command::SymbolFunction("xyz:GOLD".into(), Function::OrderBook(None))));
// `PERP` after a security asks for its perp; alone it switches the focused pane.
let perp = |s: &str| Some(Command::SymbolFunction(s.into(), Function::Perp));
assert_eq!(parse("AAPL PERP"), perp("AAPL US Equity"));
assert_eq!(parse("aapl <equity> perp"), perp("AAPL US Equity"));
assert_eq!(parse("EURUSD CRNCY PERP"), perp("EURUSD Curncy"));
assert_eq!(parse("PERP"), Some(Command::Function(Function::Perp)));
assert_eq!(hint("xyz:aapl"), "Load xyz:AAPL — Apple Inc perp · PERP · HL xyz");
assert_eq!(hint("xyz:SKHX"), "Load xyz:SKHX · PERP · HL xyz");
assert_eq!(hint("AAPL PERP"), "AAPL US Equity: open its HIP-3 perp xyz:AAPL (PERP · HL xyz)");
assert_eq!(hint("xyz:AAPL PERP"), "xyz:AAPL: switch to AAPL US Equity");
assert_eq!(hint("NDX INDEX PERP"), "NDX Index: no HIP-3 perp is linked to it");
}
#[test]
fn hints() {
assert_eq!(hint("5"), "Interval 5 minutes");
+1028
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -7,6 +7,7 @@ mod chart_view;
mod command;
mod congress_view;
mod dock;
mod hl_worker;
mod layout;
mod layouts;
mod markets_view;
@@ -15,6 +16,7 @@ mod prefs;
mod storage;
mod theme;
mod themes;
mod wallet_worker;
#[cfg(target_arch = "wasm32")]
mod web;
mod widgets;
+29 -6
View File
@@ -261,12 +261,22 @@ pub fn curve(ui: &mut Ui, curves: Option<&YieldCurves>) -> Vec<Action> {
out
}
/// Merge catalog matches with network hits, catalog first, without duplicates.
pub fn merged_hits(query: &str, network: &[SearchHit]) -> Vec<SearchHit> {
/// A search hit for a HIP-3 perp, labelled with its venue.
pub fn perp_hit(key: &str) -> SearchHit {
let name = tr_core::perps::link_for(key).map_or_else(
|| format!("Hyperliquid {} perp", tr_core::perps::venue(key).unwrap_or("HIP-3")),
|l| l.name.to_string(),
);
SearchHit { key: key.to_string(), name, class: AssetClass::Perp, exchange: Some(tr_core::perps::badge(key)) }
}
/// Merge catalog matches, then HIP-3 perps among `perps`, then network hits, without duplicates.
pub fn merged_hits(query: &str, network: &[SearchHit], perps: &[&str]) -> Vec<SearchHit> {
let mut out: Vec<SearchHit> = catalog::search(query, 30)
.into_iter()
.map(|l| SearchHit { key: l.key(), name: l.name.to_string(), class: l.class, exchange: None })
.collect();
out.extend(tr_core::perps::search(query, perps.iter().copied(), 10).into_iter().map(perp_hit));
for h in network {
if !out.iter().any(|o| o.key == h.key) {
out.push(h.clone());
@@ -284,11 +294,12 @@ pub fn class_color(c: AssetClass) -> Color32 {
AssetClass::Crypto => Color32::from_rgb(255, 150, 60),
AssetClass::Govt => Color32::from_rgb(190, 160, 255),
AssetClass::Cmdty => Color32::from_rgb(230, 200, 90),
AssetClass::Perp => theme::perp(),
}
}
/// Security search panel (`SECF <query>`).
pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit]) -> (Vec<Action>, bool) {
/// Security search panel (`SECF <query>`). `perps` are the HIP-3 perps it can offer.
pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit], perps: &[&str]) -> (Vec<Action>, bool) {
let mut out = Vec::new();
let mut changed = false;
ui.horizontal(|ui| {
@@ -306,7 +317,7 @@ pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit]) -> (Vec<Ac
.color(amber())
.background_color(theme::header()),
);
let hits = merged_hits(query, network);
let hits = merged_hits(query, network, perps);
if hits.is_empty() && !query.trim().is_empty() {
ui.label(RichText::new("No matches.").font(mono(12.0)).color(muted()));
}
@@ -374,8 +385,20 @@ mod tests {
exchange: None,
},
];
let m = merged_hits("bitcoin", &net);
let m = merged_hits("bitcoin", &net, &[]);
assert_eq!(m.iter().filter(|h| h.key == "BTCUSD Curncy").count(), 1);
assert!(m.iter().any(|h| h.key == "BITO US Equity"));
}
/// The listed instrument comes first; its perp follows, labelled as one.
#[test]
fn search_offers_perps_after_listed_instruments() {
let m = merged_hits("aapl", &[], &["xyz:AAPL", "xyz:GOLD"]);
let keys: Vec<&str> = m.iter().map(|h| h.key.as_str()).collect();
assert_eq!(keys, ["AAPL US Equity", "xyz:AAPL"]);
assert_eq!((m[1].class, m[1].name.as_str()), (AssetClass::Perp, "Apple Inc perp"));
assert_eq!(m[1].exchange.as_deref(), Some("PERP · HL xyz"));
assert_eq!(perp_hit("xyz:SKHX").name, "Hyperliquid xyz perp");
assert!(merged_hits("aapl", &[], &[]).iter().all(|h| h.class != AssetClass::Perp));
}
}
+80 -1
View File
@@ -216,6 +216,59 @@ pub struct TradingPrefs {
pub max_order_value: Option<f64>,
/// Account the ticket preselects.
pub default_account: Option<String>,
/// Hyperliquid (testnet only in this build), with its own opt-in.
pub hl: HlPrefs,
}
/// Hyperliquid order entry. Separate from SnapTrade's `enabled`: turning one on never turns on
/// the other. Holds only public data: the agent's private key lives in the OS secret store.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct HlPrefs {
/// Orders may be signed and sent (after the acknowledgement dialog). Off by default, and
/// turned off again by the kill switch.
pub enabled: bool,
/// Always testnet: any stored value (`"mainnet"` included) reads as testnet.
pub network: tr_hl::Network,
/// The account (master address) shown in the portfolio panes, read-only until an agent
/// is approved.
pub master: Option<String>,
/// The approved agent's public details (never its key).
pub agent: Option<HlAgent>,
/// The built-in wallet, once signed in (its address is `master`). Public details only:
/// Turnkey holds the wallet's key, and the session lives in the OS secret store.
pub wallet: Option<HlWallet>,
/// Keep a `scheduleCancel` about a minute ahead while the app is open, so open orders are
/// cancelled if Terminal disappears. Opt-in: it cancels orders placed elsewhere too.
pub dead_mans_switch: bool,
/// Web only: keep the agent key in this browser (encrypted with a key the page can't
/// export) instead of only for the session.
pub remember_on_browser: bool,
}
/// The user's built-in wallet on Turnkey, as Terminal remembers it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HlWallet {
/// Lower-case `0x…`: the Hyperliquid account.
pub address: String,
/// The user's Turnkey sub-organization.
pub organization_id: String,
/// Turnkey's name for the user (`whoami`), shown so a wrong account is noticed.
#[serde(default)]
pub user: String,
}
/// An approved API wallet, as Terminal remembers it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HlAgent {
/// Lower-case `0x…`.
pub address: String,
/// `terminal-<device> valid_until <ms>`.
pub name: String,
/// Expiry, Unix milliseconds.
pub valid_until: u64,
#[serde(default)]
pub network: tr_hl::Network,
}
/// Every rebindable action.
@@ -233,6 +286,7 @@ pub enum Action {
Orders,
Buy,
Sell,
TogglePerp,
NextPane,
PrevPane,
ToggleSidebar,
@@ -276,7 +330,7 @@ pub enum Action {
}
impl Action {
pub const ALL: [Action; 52] = [
pub const ALL: [Action; 53] = [
Action::Help,
Action::Congress,
Action::Politicians,
@@ -289,6 +343,7 @@ impl Action {
Action::Orders,
Action::Buy,
Action::Sell,
Action::TogglePerp,
Action::NextPane,
Action::PrevPane,
Action::ToggleSidebar,
@@ -345,6 +400,7 @@ impl Action {
Action::Orders => "Brokerage orders".into(),
Action::Buy => "Buy the current security".into(),
Action::Sell => "Sell the current security".into(),
Action::TogglePerp => "Switch between a security and its HIP-3 perp".into(),
Action::NextPane => "Next pane".into(),
Action::PrevPane => "Previous pane".into(),
Action::ToggleSidebar => "Toggle sidebar".into(),
@@ -585,6 +641,7 @@ impl Default for Keymap {
// Shift keeps plain typing free for the type-anywhere command line.
bind(Action::Buy, "Shift+B");
bind(Action::Sell, "Shift+S");
bind(Action::TogglePerp, "Shift+P");
bind(Action::NextPane, "Tab");
bind(Action::PrevPane, "Shift+Tab");
bind(Action::ToggleSidebar, if mac { "Ctrl+Cmd+S" } else { "Ctrl+Shift+S" });
@@ -841,6 +898,28 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn hyperliquid_prefs_never_select_mainnet() {
// A hand-edited or future file asking for mainnet still loads as testnet, and the rest
// of the preferences survive.
let json = r#"{"appearance":{"theme":"Nord"},"trading":{"enabled":true,"hl":{"enabled":true,"network":"mainnet",
"master":"0xabc","agent":{"address":"0xdef","name":"terminal-mac valid_until 1","valid_until":1,"network":"Mainnet"}}}}"#;
let p: Prefs = serde_json::from_str(json).unwrap();
assert_eq!(p.trading.hl.network, tr_hl::Network::Testnet);
assert_eq!(p.trading.hl.agent.as_ref().unwrap().network, tr_hl::Network::Testnet);
assert!(p.trading.hl.enabled && p.trading.enabled);
assert_eq!(p.appearance.theme, "Nord", "the rest of the file still loads");
for v in ["\"MAINNET\"", "\"a\"", "7", "null", "[]"] {
let p: Prefs = serde_json::from_str(&format!(r#"{{"trading":{{"hl":{{"network":{v}}}}}}}"#)).unwrap();
assert_eq!(p.trading.hl.network, tr_hl::Network::Testnet, "{v}");
}
// Off by default, opt-in switches off, nothing secret in the file.
let d = Prefs::default().trading.hl;
assert!(!d.enabled && !d.dead_mans_switch && !d.remember_on_browser && d.agent.is_none());
let saved = serde_json::to_string(&p).unwrap();
assert!(saved.contains(r#""network":"Testnet""#) && !saved.contains("ainnet"), "{saved}");
}
#[test]
fn v1_prefs_migrate_layout_bindings_to_alt() {
// A v1 file: layouts on ⌘1…, one customised, no panel actions yet.
+6
View File
@@ -141,6 +141,12 @@ pub fn accent_wash() -> Color32 {
Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), if palette().dark { 70 } else { 45 })
}
/// HIP-3 perp badges and labels: a colour no listed instrument uses, so a perp never reads as
/// the stock, future or FX rate it references.
pub fn perp() -> Color32 {
if palette().dark { Color32::from_rgb(196, 150, 255) } else { Color32::from_rgb(118, 64, 196) }
}
pub fn dem() -> Color32 {
if palette().dark { Color32::from_rgb(64, 156, 255) } else { Color32::from_rgb(0, 102, 214) }
}
+404
View File
@@ -0,0 +1,404 @@
//! The built-in wallet's background work: the desktop sign-in (a loopback listener the
//! browser returns to), turning a Turnkey session into a signed-in wallet, the Turnkey
//! session in the OS secret store, and `approveAgent` signed by the wallet.
//!
//! Turnkey signs only here, and only through `tr_hl::signing::sign_approve_agent_with`, which
//! hands it the digest of a testnet approval and nothing else. Orders never come this way:
//! they are signed by the agent key in `hl_worker`.
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicU64, Ordering};
use tr_broker::secrets::AgentKeyStore;
use tr_data::rt;
use tr_hl::model::SpotUsdc;
use tr_hl::nonce::now_ms;
use tr_hl::signing::sign_approve_agent_with;
use tr_hl::{AgentKey, SigningKey, trader};
use tr_turnkey::{Session, SessionKey};
use crate::hl_worker::{HlResponse, approve, revoke};
use crate::prefs::HlAgent;
/// The sign-in that may still finish; starting another or cancelling bumps it.
#[cfg(not(target_arch = "wasm32"))]
static SIGN_IN: AtomicU64 = AtomicU64::new(0);
/// Stop waiting for the browser (the listener closes within half a second).
#[cfg(not(target_arch = "wasm32"))]
pub fn cancel_sign_in() {
SIGN_IN.fetch_add(1 << 32, Ordering::SeqCst);
}
pub enum WalletRequest {
/// Desktop: listen on 127.0.0.1, say which port, and wait for the browser's session.
#[cfg(not(target_arch = "wasm32"))]
SignIn { id: u64, store: AgentKeyStore, key: SessionKey, state: String },
/// Web: a session token for `key` from the sign-in page's message. Check it and read the
/// wallet from Turnkey.
#[cfg(target_arch = "wasm32")]
Finish { id: u64, store: AgentKeyStore, key: SessionKey, token: String },
/// The session kept in the secret store, if it hasn't expired.
LoadSession { store: AgentKeyStore, account: String },
/// Forget the session on this device.
EndSession { store: AgentKeyStore, account: String },
/// Approve a fresh agent key, signed by the wallet.
Approve { store: AgentKeyStore, session: Arc<Session>, master: String, device: String },
/// Replace the agent on Hyperliquid (signed by the wallet) and delete its key here.
Revoke { store: AgentKeyStore, session: Arc<Session>, master: String, base: String },
/// Spot USDC for the Deposit window.
Balances { user: String },
}
/// A wallet the user just signed in to.
pub struct SignedIn {
pub session: Session,
/// Lower-case `0x…`.
pub address: String,
pub user: String,
/// `Err`: the session couldn't be kept in the secret store (it lasts until Terminal quits).
pub saved: Result<(), String>,
}
pub enum WalletResponse {
/// The desktop listener is up: open the sign-in page with this port.
#[cfg(not(target_arch = "wasm32"))]
Listening {
id: u64,
port: u16,
},
SignedIn {
id: u64,
result: Result<Box<SignedIn>, String>,
},
SessionLoaded {
account: String,
session: Option<Arc<Session>>,
},
Balances {
user: String,
result: Result<SpotUsdc, String>,
},
/// The wallet didn't sign the approval: nothing was sent, and the new key is dropped.
ApproveNotSigned(String),
/// The wallet didn't sign the revoke, so nothing changed: the agent key is still here.
RevokeNotSigned(String),
}
/// The secret store item for a wallet's Turnkey session.
pub fn session_account(address: &str) -> String {
format!("turnkey:testnet:{}", address.trim().to_ascii_lowercase())
}
fn now_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// Turnkey's API (or the local mock that `TR_TURNKEY_API_URL` names, loopback only).
fn turnkey() -> Result<tr_turnkey::Client, String> {
tr_turnkey::Client::new().map_err(|e| format!("{e:#}"))
}
/// Run one request; answers go through `send`.
pub async fn handle(req: WalletRequest, send: impl Fn(HlResponse) + Clone + 'static) {
let wallet = |r: WalletResponse| HlResponse::Wallet(Box::new(r));
match req {
#[cfg(not(target_arch = "wasm32"))]
WalletRequest::SignIn { id, store, key, state } => {
let this = SIGN_IN.fetch_add(1, Ordering::SeqCst) + 1;
let cb = match tr_turnkey::callback::Callback::bind().await {
Ok(cb) => cb,
Err(e) => return send(wallet(WalletResponse::SignedIn { id, result: Err(format!("{e:#}")) })),
};
send(wallet(WalletResponse::Listening { id, port: cb.port() }));
let cancelled = async {
while SIGN_IN.load(Ordering::SeqCst) == this {
rt::sleep(std::time::Duration::from_millis(400)).await;
}
};
let answer = tokio::select! {
a = cb.wait(&state, tr_turnkey::callback::SIGN_IN_TIMEOUT) => a.map_err(|e| format!("{e:#}")),
_ = cancelled => Err("Sign-in cancelled.".into()),
};
let result = match answer {
Ok(tr_turnkey::callback::Answer::Session(token)) => match turnkey() {
Ok(tk) => finish(&tk, store, key, &token).await,
Err(e) => Err(e),
},
Ok(tr_turnkey::callback::Answer::Failed(e)) => Err(format!("The sign-in page said: {e}")),
Err(e) => Err(e),
};
send(wallet(WalletResponse::SignedIn { id, result }));
}
#[cfg(target_arch = "wasm32")]
WalletRequest::Finish { id, store, key, token } => {
let result = match turnkey() {
Ok(tk) => finish(&tk, store, key, &token).await,
Err(e) => Err(e),
};
send(wallet(WalletResponse::SignedIn { id, result }));
}
WalletRequest::LoadSession { store, account } => {
let acct = account.clone();
let session = rt::blocking(move || {
let text = store.load(&acct).ok().flatten()?;
match Session::from_stored(&text, now_secs()) {
Ok(Some(s)) => Some(Arc::new(s)),
_ => {
// Expired or unreadable: it can't sign anything, so don't keep it.
let _ = store.delete(&acct);
None
}
}
})
.await
.flatten();
send(wallet(WalletResponse::SessionLoaded { account, session }));
}
WalletRequest::EndSession { store, account } => {
let _ = rt::blocking(move || store.delete(&account)).await;
}
WalletRequest::Approve { store, session, master, device } => {
let agent = AgentKey::generate();
let action = trader::approve_agent(&agent.address(), &device, trader::DEFAULT_AGENT_DAYS, now_ms());
let meta = HlAgent {
address: action.agent_address.clone(),
name: action.agent_name.clone(),
valid_until: trader::agent_valid_until(&action.agent_name).unwrap_or(0),
network: tr_hl::Network::Testnet,
};
let key = Arc::new(agent);
let signed = match turnkey() {
Ok(tk) => wallet_sign(&tk, &session, &master, &action).await,
Err(e) => Err(e),
};
let r = match signed {
Ok(body) => match tr_hl::client::Client::new() {
Ok(client) => approve(&client, store, master, key, body, meta).await,
Err(e) => {
HlResponse::Approved { master, agent: meta, key, result: Err(format!("{e:#}")), saved: Ok(()) }
}
},
// The agent key is dropped here; nothing was sent.
Err(e) => wallet(WalletResponse::ApproveNotSigned(e)),
};
send(r);
}
WalletRequest::Revoke { store, session, master, base } => {
let action = trader::revoke_agent(&base, now_ms());
let account = crate::app::hl_store_account(&master);
let signed = match turnkey() {
Ok(tk) => wallet_sign(&tk, &session, &master, &action).await,
Err(e) => Err(e),
};
let r = match (signed, tr_hl::client::Client::new()) {
(Ok(body), Ok(client)) => revoke(&client, store, account, Some(body)).await,
(Err(e), _) => wallet(WalletResponse::RevokeNotSigned(e)),
(_, Err(e)) => wallet(WalletResponse::RevokeNotSigned(format!("{e:#}"))),
};
send(r);
}
WalletRequest::Balances { user } => {
let result = match (tr_hl::client::Client::new(), tr_hl::key::parse_address(&user)) {
(Ok(c), Ok(a)) => c.spot_usdc(&a).await.map_err(|e| format!("{e:#}")),
(Err(e), _) | (_, Err(e)) => Err(format!("{e:#}")),
};
send(wallet(WalletResponse::Balances { user, result }));
}
}
}
/// The wallet signs `action` (a testnet `approveAgent`) through Turnkey: the `/exchange` body.
async fn wallet_sign(
tk: &tr_turnkey::Client,
session: &Session,
master: &str,
action: &tr_hl::actions::ApproveAgentAction,
) -> Result<serde_json::Value, String> {
if session.expired(now_secs()) {
return Err("the wallet session expired; sign in again".into());
}
let main = tr_hl::key::parse_address(master).map_err(|e| format!("{e:#}"))?;
let sig = sign_approve_agent_with(action, &main, |d| async move { tk.sign_digest(session, &main, &d).await })
.await
.map_err(|e| tr_hl::mask_addresses(&format!("{e:#}")))?;
Ok(trader::user_signed_body(action, &sig))
}
/// Accept the session for `key`, read the wallet from Turnkey, and keep the session in the
/// secret store for the rest of its 15 minutes.
async fn finish(
tk: &tr_turnkey::Client,
store: AgentKeyStore,
key: SessionKey,
token: &str,
) -> Result<Box<SignedIn>, String> {
let session = Session::accept(key, token, now_secs()).map_err(|e| format!("{e:#}"))?;
let me = tk.whoami(&session).await.map_err(|e| format!("Turnkey: {e:#}"))?;
let account = tk.ethereum_account(&session).await.map_err(|e| format!("Turnkey: {e:#}"))?;
let address = tr_hl::key::parse_address(&account.address)
.map(|a| tr_hl::key::address_hex(&a))
.map_err(|e| format!("Turnkey's wallet address: {e:#}"))?;
let stored = session.to_stored();
let item = session_account(&address);
let saved = rt::blocking(move || store.save(&item, &stored).map_err(|e| format!("{e:#}")))
.await
.unwrap_or_else(|| Err("the key store task failed".into()));
Ok(Box::new(SignedIn { session, address, user: me.username, saved }))
}
#[cfg(test)]
mod tests {
use tr_hl::signing::recover_approve_agent;
use tr_turnkey::mock::{MockTurnkey, USERNAME};
use super::*;
async fn signed_in(mock: &MockTurnkey, store: &AgentKeyStore) -> Box<SignedIn> {
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 600);
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
finish(&tk, store.clone(), key, &token).await.unwrap()
}
#[tokio::test]
async fn signing_in_finds_the_wallet_and_keeps_the_session_in_the_store() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let store = AgentKeyStore::memory();
let s = signed_in(&mock, &store).await;
assert_eq!(s.address, mock.address());
assert_eq!(s.user, USERNAME);
assert!(s.saved.is_ok());
let kept = store.load(&session_account(&s.address)).unwrap().expect("the session is in the store");
let back = Session::from_stored(&kept, now_secs()).unwrap().unwrap();
assert_eq!(back.key.public_hex(), s.session.key.public_hex());
// A token for another key is refused before Turnkey is asked anything.
let before = mock.requests().len();
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
let theirs = mock.mint_session(&SessionKey::generate().public_hex(), 600);
assert!(finish(&tk, store, SessionKey::generate(), &theirs).await.is_err());
assert_eq!(mock.requests().len(), before);
}
/// The `/exchange` body the wallet's signature goes into is a testnet approval, and the
/// signature recovers the wallet.
#[tokio::test]
async fn the_wallet_signs_a_testnet_approval_that_recovers_it() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let store = AgentKeyStore::memory();
let s = signed_in(&mock, &store).await;
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
let agent = AgentKey::generate();
let action = trader::approve_agent(&agent.address(), "ci", trader::DEFAULT_AGENT_DAYS, now_ms());
let body = wallet_sign(&tk, &s.session, &s.address, &action).await.unwrap();
assert_eq!(body["action"]["type"], "approveAgent");
assert_eq!(body["action"]["hyperliquidChain"], "Testnet");
assert_eq!(body["action"]["signatureChainId"], "0x66eee");
let sig: tr_hl::signing::WireSignature = serde_json::from_value(body["signature"].clone()).unwrap();
assert_eq!(tr_hl::key::address_hex(&recover_approve_agent(&action, &sig).unwrap()), s.address);
// 180 days, the longest Hyperliquid allows, so approvals (paid signatures) are rare.
let until = trader::agent_valid_until(&action.agent_name).unwrap();
assert!(until >= now_ms() + 179 * 86_400_000, "{}", action.agent_name);
}
#[tokio::test]
async fn an_expired_session_asks_turnkey_nothing() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 2);
let session = Session::accept(key, &token, now_secs()).unwrap();
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
let action = trader::approve_agent(&AgentKey::generate().address(), "ci", 180, now_ms());
let late =
Session::accept(SessionKey::from_hex(&session.key.to_hex()).unwrap(), &session.token, now_secs()).unwrap();
// Two seconds later it has expired.
tokio::time::sleep(std::time::Duration::from_millis(2_100)).await;
let err = wallet_sign(&tk, &late, &mock.address(), &action).await.unwrap_err();
assert!(err.contains("expired"), "{err}");
assert!(mock.requests().is_empty());
}
/// An approval the wallet didn't sign is reported as such, not as Hyperliquid's refusal.
#[tokio::test]
async fn an_approval_the_wallet_did_not_sign_says_so() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 1);
let session = Arc::new(Session::accept(key, &token, now_secs()).unwrap());
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
let sink = got.clone();
let req = WalletRequest::Approve {
store: AgentKeyStore::memory(),
session,
master: mock.address(),
device: "ci".into(),
};
handle(req, move |r| {
*sink.lock().unwrap() = Some(match r {
HlResponse::Wallet(w) => match *w {
WalletResponse::ApproveNotSigned(e) => e,
_ => "other".into(),
},
_ => "sent to Hyperliquid".into(),
});
})
.await;
assert!(got.lock().unwrap().as_deref().is_some_and(|e| e.contains("expired")), "{:?}", got.lock().unwrap());
assert!(mock.requests().is_empty());
}
/// A revoke the wallet didn't sign changes nothing: the agent key stays in the store and
/// the app is told so (it keeps the agent until a revoke really happens).
#[tokio::test]
async fn a_revoke_the_wallet_did_not_sign_keeps_the_agent() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let store = AgentKeyStore::memory();
let master = mock.address();
let agent_item = crate::app::hl_store_account(&master);
store.save(&agent_item, &AgentKey::generate().to_hex()).unwrap();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 1);
let session = Arc::new(Session::accept(key, &token, now_secs()).unwrap());
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
let sink = got.clone();
let req = WalletRequest::Revoke { store: store.clone(), session, master, base: "terminal-ci".into() };
handle(req, move |r| {
*sink.lock().unwrap() = Some(match r {
HlResponse::Wallet(w) if matches!(*w, WalletResponse::RevokeNotSigned(_)) => "not signed",
HlResponse::Revoked(_) => "revoked",
_ => "other",
});
})
.await;
assert_eq!(*got.lock().unwrap(), Some("not signed"));
assert!(store.load(&agent_item).unwrap().is_some(), "the agent key is still here");
assert!(mock.requests().is_empty());
}
#[tokio::test]
async fn an_expired_session_in_the_store_is_deleted_on_load() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let store = AgentKeyStore::memory();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 1);
let session = Session::accept(key, &token, now_secs()).unwrap();
let account = session_account(&mock.address());
store.save(&account, &session.to_stored()).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
let sink = got.clone();
handle(WalletRequest::LoadSession { store: store.clone(), account: account.clone() }, move |r| {
if let HlResponse::Wallet(w) = r
&& let WalletResponse::SessionLoaded { session, .. } = *w
{
*sink.lock().unwrap() = Some(session.is_some());
}
})
.await;
assert_eq!(*got.lock().unwrap(), Some(false));
assert!(store.load(&account).unwrap().is_none(), "the expired session was deleted");
}
}
+12 -1
View File
@@ -3,6 +3,10 @@
//!
//! Query flags: `?sample` serves offline sample data only (like `TR_PROVIDER=sample`), and
//! `?fps` shows the frame-time readout and repaints continuously (like `TR_FPS`).
//!
//! Crypto markets come from the Terminal server that `TR_SERVER_URL` named when the page was built
//! (scripts/build-web.sh). `?server=off` turns it off, and `?server=ws://localhost:8787/ws` uses a
//! server on this machine instead (see `tr_data::web::server_url`).
use std::cell::Cell;
@@ -19,6 +23,13 @@ pub fn start() {
let query = web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default();
let offline = has_flag(&query, "sample");
let perf = has_flag(&query, "fps");
let requested = web_sys::UrlSearchParams::new_with_str(&query).ok().and_then(|p| p.get("server"));
let server = tr_data::web::server_url(option_env!("TR_SERVER_URL"), requested.as_deref());
if let Some(url) = server.as_deref().filter(|_| !offline) {
tracing::info!(
"crypto markets come from the Terminal server at {url}, or Binance and Coinbase while it's down"
);
}
wasm_bindgen_futures::spawn_local(async move {
let Some(canvas) = canvas() else { return fail("the page has no #terminal canvas") };
let started = eframe::WebRunner::new()
@@ -29,7 +40,7 @@ pub fn start() {
// The first frame needs fonts; set them before it (set_fonts applies from the next pass).
cc.egui_ctx.set_fonts(crate::theme::base_fonts());
wasm_bindgen_futures::spawn_local(load_emoji_fonts(cc.egui_ctx.clone()));
let mut app = crate::create_app(cc, tr_data::for_browser(offline), 1, t0)?;
let mut app = crate::create_app(cc, tr_data::for_browser(offline, server.as_deref()), 1, t0)?;
if perf {
app.show_perf();
}
+393 -35
View File
@@ -34,6 +34,12 @@ pub type PaneId = u64;
/// Minutes between Congress disclosure checks (`TR_CONGRESS_REFRESH_MIN`; 0 disables).
pub const CONGRESS_REFRESH_MIN: u64 = 30;
/// The least time between a chart's reloads after its provider ended the live feed.
const RELOAD_AFTER_FEED_ENDS: Duration = Duration::from_secs(5);
/// How often a chart retries a HIP-3 perp's history while the Terminal server can't serve it.
const PERP_RETRY: Duration = if cfg!(test) { Duration::from_millis(100) } else { Duration::from_secs(10) };
pub enum Request {
/// Load the brokerage portfolio through SnapTrade (`None` = the simulated sample).
Portfolio(Option<tr_broker::Credentials>),
@@ -68,6 +74,13 @@ pub enum Request {
pane: PaneId,
symbol: String,
depth: usize,
/// Echoed in the response, so the pane knows which of its asks was answered.
ask: u64,
},
/// The latest trades, for markets the Terminal server serves.
Trades {
pane: PaneId,
symbol: String,
},
History {
pane: PaneId,
@@ -129,8 +142,14 @@ pub enum Request {
Forget(PaneId),
YieldCurves,
Search(String),
/// The HIP-3 perps the Terminal server lists as live.
PerpMarkets,
/// Order entry (see `app::trading`); only sent once trading is enabled.
Trade(TradeRequest),
/// Hyperliquid testnet: the user stream, reads, the agent key and signed actions.
Hl(crate::hl_worker::HlRequest),
/// The built-in wallet (Turnkey sign-in and approvals).
Wallet(crate::wallet_worker::WalletRequest),
}
/// A trading call through SnapTrade. Previews never place anything; placing takes a preview
@@ -225,8 +244,14 @@ pub enum Response {
OrderBook {
pane: PaneId,
symbol: String,
ask: u64,
book: Result<tr_data::OrderBook, String>,
},
Trades {
pane: PaneId,
symbol: String,
trades: Result<Vec<tr_data::TradePrint>, String>,
},
History {
pane: PaneId,
generation: u64,
@@ -238,6 +263,9 @@ pub enum Response {
prepended: usize,
from_cache: bool,
exhausted: bool,
/// A full load from the provider. Its `exhausted` replaces the chart's, which may have
/// come from the source the chart showed before.
full: bool,
/// A live-feed update of the latest bar.
live: bool,
},
@@ -262,7 +290,11 @@ pub enum Response {
symbol: String,
trades: Vec<Trade>,
},
Quotes(Vec<Quote>),
/// Quotes for the keys `asked`, which may leave some out.
Quotes {
asked: Vec<String>,
quotes: Vec<Quote>,
},
Latest(Vec<Trade>),
TickerTrades {
pane: PaneId,
@@ -287,7 +319,10 @@ pub enum Response {
query: String,
hits: Vec<SearchHit>,
},
/// Live HIP-3 perps (`xyz:AAPL`), or why the server couldn't say.
PerpMarkets(Result<Vec<String>, String>),
Trade(Box<TradeResponse>),
Hl(Box<crate::hl_worker::HlResponse>),
}
pub struct Worker {
@@ -305,6 +340,9 @@ struct Shared {
/// Authoritative bars per chart pane (history, paging and live ticks all merge here),
/// tagged with the pane's load generation.
series: std::sync::Mutex<SeriesMap>,
/// Counts each chart pane's reloads for a changed data source, up once as one starts and again
/// when it's done (odd while it runs), so a page of older bars that spans one is dropped.
sources: std::sync::Mutex<HashMap<PaneId, u64>>,
#[cfg(not(target_arch = "wasm32"))]
cache: std::sync::Mutex<Option<BarCache>>,
db: Mutex<Db>,
@@ -367,6 +405,7 @@ impl Worker {
let shared = Arc::new(Shared {
provider,
series: Default::default(),
sources: Default::default(),
cache: std::sync::Mutex::new(cache),
db: Mutex::new(db),
out,
@@ -407,8 +446,14 @@ impl Worker {
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
let (tx, mut req_rx) = tokio::sync::mpsc::unbounded_channel::<Request>();
let (out, rx) = mpsc::channel();
let shared =
Arc::new(Shared { provider, series: Default::default(), db: Mutex::new(Db::open_in_memory()?), out, ctx });
let shared = Arc::new(Shared {
provider,
series: Default::default(),
sources: Default::default(),
db: Mutex::new(Db::open_in_memory()?),
out,
ctx,
});
rt::spawn(async move {
while let Some(req) = req_rx.recv().await {
let s = shared.clone();
@@ -571,40 +616,31 @@ async fn handle(s: &Arc<Shared>, req: Request) {
prepended: 0,
from_cache: true,
exhausted: false,
full: false,
live: false,
});
}
match s.provider.history(&symbol, tf, None, limit).await {
Ok(bars) if !bars.is_empty() => {
cache_bars(s, &symbol, tf, &bars);
let exhausted = bars.len() < limit / 2;
let bars = Arc::new(bars);
if !store_series(s, pane, generation, bars.clone()) {
return; // superseded while loading
}
let gpu = build_gpu(bars.clone()).await;
s.send(Response::History {
pane,
generation,
symbol: symbol.clone(),
tf,
bars,
gpu,
prepended: 0,
from_cache: false,
exhausted,
live: false,
});
start_live(s.clone(), pane, generation, symbol, tf);
}
Ok(bars) if !bars.is_empty() => loaded(s, pane, generation, symbol, tf, limit, bars).await,
Ok(_) => s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") }),
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
Err(e) => {
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") });
if tr_data::is_perp(&symbol) {
retry_perp(s.clone(), pane, generation, symbol, tf, limit);
}
}
}
}
Request::MoreHistory { pane, generation, symbol, tf, existing, limit } => {
// Page from the worker's copy (it may hold newer live bars than the UI's).
let base = current_series(s, pane, generation).filter(|b| !b.is_empty()).unwrap_or(existing);
let Some(first) = base.first().copied() else { return };
// A page that overlaps a reload for another source is dropped. The reload answers the
// pane, and the chart pages again from the new source.
let source = source_switches(s, pane);
if source % 2 == 1 {
return;
}
match s.provider.history(&symbol, tf, Some(first.ts), limit).await {
Ok(older) => {
let older: Vec<Bar> = older.into_iter().filter(|b| b.ts < first.ts).collect();
@@ -613,6 +649,9 @@ async fn handle(s: &Arc<Shared>, req: Request) {
let mut map = s.series.lock().expect("series lock");
let Some((g, cur)) = map.get_mut(&pane).filter(|(g, _)| *g == generation) else { return };
let _ = g;
if source_switches(s, pane) != source {
return;
}
let mut merged = older;
merged.extend_from_slice(cur);
*cur = Arc::new(merged);
@@ -629,10 +668,14 @@ async fn handle(s: &Arc<Shared>, req: Request) {
prepended,
from_cache: false,
exhausted: prepended == 0,
full: false,
live: false,
});
}
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
Err(e) if source_switches(s, pane) == source => {
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") })
}
Err(_) => {}
}
}
Request::Indicators { pane, generation, bars, tf, specs, palette } => {
@@ -661,8 +704,11 @@ async fn handle(s: &Arc<Shared>, req: Request) {
s.send(Response::Markers { pane, symbol, trades });
}
Request::Quotes(symbols) => match s.provider.quotes(&symbols).await {
Ok(q) => s.send(Response::Quotes(q)),
Err(e) => s.send(Response::Status(format!("quotes: {e:#}"))),
Ok(quotes) => s.send(Response::Quotes { asked: symbols, quotes }),
Err(e) => {
s.send(Response::Status(format!("quotes: {e:#}")));
s.send(Response::Quotes { asked: symbols, quotes: Vec::new() });
}
},
Request::Latest { limit } => {
let t = s.db.lock().await.latest_trades(limit).unwrap_or_default();
@@ -708,6 +754,10 @@ async fn handle(s: &Arc<Shared>, req: Request) {
Request::Forget(pane) => {
s.series.lock().expect("series lock").remove(&pane);
}
Request::PerpMarkets => {
let perps = s.provider.perp_markets().await.map_err(|e| format!("{e:#}"));
s.send(Response::PerpMarkets(perps));
}
Request::YieldCurves => match s.provider.yield_curves().await {
Ok(c) => s.send(Response::Curves(Box::new(c))),
Err(e) => s.send(Response::Status(format!("yield curve: {e:#}"))),
@@ -751,15 +801,27 @@ async fn handle(s: &Arc<Shared>, req: Request) {
let items = s.provider.news(symbol.as_deref()).await.map_err(|e| format!("{e:#}"));
s.send(Response::News { pane, symbol, items });
}
Request::OrderBook { pane, symbol, depth } => {
Request::Trades { pane, symbol } => {
let trades = s.provider.trades(&symbol, 40).await.map_err(|e| format!("{e:#}"));
s.send(Response::Trades { pane, symbol, trades });
}
Request::OrderBook { pane, symbol, depth, ask } => {
let book = s.provider.order_book(&symbol, depth).await.map_err(|e| format!("{e:#}"));
s.send(Response::OrderBook { pane, symbol, book });
s.send(Response::OrderBook { pane, symbol, ask, book });
}
Request::Search(query) => {
let hits = s.provider.search(&query).await.unwrap_or_default();
s.send(Response::SearchHits { query, hits });
}
Request::Trade(t) => s.send(Response::Trade(Box::new(trade(t).await))),
Request::Hl(r) => {
let out = s.clone();
crate::hl_worker::handle(r, move |resp| out.send(Response::Hl(Box::new(resp)))).await
}
Request::Wallet(r) => {
let out = s.clone();
crate::wallet_worker::handle(r, move |resp| out.send(Response::Hl(Box::new(resp)))).await
}
}
}
@@ -828,24 +890,108 @@ fn store_series(s: &Shared, pane: PaneId, generation: u64, bars: Arc<Vec<Bar>>)
}
}
fn source_switches(s: &Shared, pane: PaneId) -> u64 {
s.sources.lock().expect("sources lock").get(&pane).copied().unwrap_or(0)
}
fn current_series(s: &Shared, pane: PaneId, generation: u64) -> Option<Arc<Vec<Bar>>> {
s.series.lock().expect("series lock").get(&pane).filter(|(g, _)| *g == generation).map(|(_, b)| b.clone())
}
/// Merge the provider's live feed (if any) into the pane's series, publishing at most
/// four updates a second. Stops when the pane reloads, closes or the feed ends.
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe) {
/// What one live bar did to a series.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct Applied {
changed: bool,
/// It went in before the latest bar (a feed filling a gap after an outage), so every
/// series aligned to the bars by index (compare lines) has to be rebuilt.
inserted: bool,
}
fn apply_live_bar(bars: &mut Vec<Bar>, bar: Bar) -> Applied {
let (len, last) = (bars.len(), bars.last().map(|b| b.ts));
let changed = Series::apply_live(bars, bar);
Applied { changed, inserted: bars.len() > len && bars.last().map(|b| b.ts) == last }
}
/// A full load's bars: cache them, show them and start the live feed, unless a newer load for
/// the pane has taken over.
async fn loaded(
s: &Arc<Shared>,
pane: PaneId,
generation: u64,
symbol: String,
tf: Timeframe,
limit: usize,
bars: Vec<Bar>,
) {
cache_bars(s, &symbol, tf, &bars);
let exhausted = bars.len() < limit / 2;
let bars = Arc::new(bars);
if !store_series(s, pane, generation, bars.clone()) {
return; // superseded while loading
}
let gpu = build_gpu(bars.clone()).await;
s.send(Response::History {
pane,
generation,
symbol: symbol.clone(),
tf,
bars,
gpu,
prepended: 0,
from_cache: false,
exhausted,
full: true,
live: false,
});
start_live(s.clone(), pane, generation, symbol, tf, limit);
}
/// A HIP-3 perp has no source but the Terminal server, so nothing else picks up its chart when
/// the server is away. Ask again every [`PERP_RETRY`] while the pane still shows this load; the
/// chart keeps showing the first failure (or its cached bars) until one works.
fn retry_perp(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
rt::spawn(async move {
loop {
rt::sleep(PERP_RETRY).await;
if current_series(&s, pane, generation).is_none() {
return;
}
match s.provider.history(&symbol, tf, None, limit).await {
Ok(bars) if !bars.is_empty() => return loaded(&s, pane, generation, symbol, tf, limit, bars).await,
Ok(_) => {
return s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") });
}
Err(e) => tracing::debug!("{symbol} history, still failing: {e:#}"),
}
}
});
}
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
let Some(mut feed) = s.provider.live(&symbol, tf) else { return };
rt::spawn(async move {
let started = web_time::Instant::now();
let mut tick = rt::interval(Duration::from_millis(250));
let mut dirty = false;
let mut inserted = false;
let mut ended = false;
loop {
tokio::select! {
bar = feed.recv() => {
let Some(bar) = bar else { break };
let Some(bar) = bar else {
ended = true;
break;
};
let mut map = s.series.lock().expect("series lock");
match map.get_mut(&pane) {
Some((g, bars)) if *g == generation => dirty |= Series::apply_live(Arc::make_mut(bars), bar),
Some((g, bars)) if *g == generation => {
let a = apply_live_bar(Arc::make_mut(bars), bar);
dirty |= a.changed;
inserted |= a.inserted;
}
_ => break,
}
}
@@ -869,12 +1015,26 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
prepended: 0,
from_cache: false,
exhausted: false,
live: true,
full: false,
// A bar slotted into a gap shifts everything after it: send it as a
// refresh so the chart rebuilds its compare line and indicators.
live: !std::mem::take(&mut inserted),
});
}
}
}
tracing::debug!("live feed for pane {pane} ({symbol}) stopped");
// A provider ends a feed the pane still shows when the chart's source changes (the Terminal
// server went away or came back): reload the chart from wherever its data comes from now.
if ended {
rt::sleep(RELOAD_AFTER_FEED_ENDS.saturating_sub(started.elapsed())).await;
if current_series(&s, pane, generation).is_some() {
let switch = || *s.sources.lock().expect("sources lock").entry(pane).or_default() += 1;
switch();
handle(&s, Request::History { pane, generation, symbol, tf, limit }).await;
switch();
}
}
});
}
@@ -950,6 +1110,20 @@ where
mod tests {
use super::*;
#[test]
fn bars_filling_a_gap_are_reported_as_inserted() {
let b = |ts: i64| Bar::new(ts, 1.0, 1.0, 1.0, 1.0, 1.0);
let mut bars = vec![b(0), b(60), b(240)];
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: true, inserted: false });
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: false, inserted: false });
assert_eq!(apply_live_bar(&mut bars, b(120)), Applied { changed: true, inserted: true });
assert_eq!(
apply_live_bar(&mut bars, Bar::new(60, 1.0, 2.0, 1.0, 2.0, 1.0)),
Applied { changed: true, inserted: false }
);
assert_eq!(bars.iter().map(|x| x.ts).collect::<Vec<_>>(), [0, 60, 120, 240, 300]);
}
#[test]
fn compare_alignment_carries_last_close() {
let base: Vec<Bar> = (0..5).map(|i| Bar::new(i * 10, 100.0, 100.0, 100.0, 100.0 + i as f64, 1.0)).collect();
@@ -986,6 +1160,7 @@ mod tests {
let s = Shared {
provider: std::sync::Arc::new(tr_data::sample::SampleProvider::at(0, 10)),
series: Default::default(),
sources: Default::default(),
cache: std::sync::Mutex::new(None),
db: Mutex::new(Db::open_in_memory().unwrap()),
out,
@@ -1000,6 +1175,189 @@ mod tests {
assert!(current_series(&s, 1, 1).is_none());
}
/// Bars whose close counts the loads. Reloads wait for `reload`, and older pages for `page`.
/// The first chart's live feed ends at once, the way `RemoteMarkets` ends one when the chart's
/// source changes.
#[derive(Default)]
struct SourceChanges {
loads: std::sync::atomic::AtomicUsize,
open: std::sync::Mutex<Vec<tokio::sync::mpsc::Sender<Bar>>>,
reload: tokio::sync::Notify,
page: tokio::sync::Notify,
}
#[async_trait::async_trait]
impl tr_data::MarketData for SourceChanges {
fn name(&self) -> &str {
"test"
}
async fn history(&self, _: &str, _: Timeframe, end: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
if end.is_some() {
self.page.notified().await;
return Ok(vec![Bar::new(-60, 1.0, 1.0, 1.0, 1.0, 1.0)]);
}
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
if n > 1 {
self.reload.notified().await;
}
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, n as f64, 1.0)])
}
fn live(&self, _: &str, _: Timeframe) -> Option<tr_data::LiveFeed> {
let (tx, rx) = tokio::sync::mpsc::channel(1);
if self.loads.load(std::sync::atomic::Ordering::SeqCst) > 1 {
self.open.lock().unwrap().push(tx);
}
Some(rx)
}
}
/// A server that's down for the first `down` history requests.
struct ServerBack {
down: usize,
loads: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl tr_data::MarketData for ServerBack {
fn name(&self) -> &str {
"server"
}
async fn history(&self, _: &str, _: Timeframe, _: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
anyhow::ensure!(n > self.down, "the Terminal server is not reachable");
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, 7.0, 1.0)])
}
}
/// A perp has no other source, so a chart whose first load failed keeps asking while it
/// shows the perp, and loads once the server answers. It stops once the pane moves on.
fn worker_on(provider: tr_data::Provider) -> (Arc<Shared>, mpsc::Receiver<Response>) {
let (out, rx) = mpsc::channel();
let s = Shared {
provider,
series: Default::default(),
sources: Default::default(),
cache: std::sync::Mutex::new(None),
db: Mutex::new(Db::open_in_memory().unwrap()),
out,
ctx: egui::Context::default(),
};
(Arc::new(s), rx)
}
#[tokio::test]
async fn perp_charts_retry_until_the_server_answers() {
let provider = Arc::new(ServerBack { down: 2, loads: Default::default() });
let (s, rx) = worker_on(provider.clone());
let load = |symbol: &str| Request::History {
pane: 1,
generation: 1,
symbol: symbol.into(),
tf: Timeframe::D1,
limit: 10,
};
handle(&s, load("xyz:AAPL")).await;
let t0 = std::time::Instant::now();
let mut errors = 0;
let bars = loop {
assert!(t0.elapsed() < Duration::from_secs(10), "no retry after {errors} errors");
match rx.try_recv() {
Ok(Response::HistoryError { .. }) => errors += 1,
Ok(Response::History { bars, full: true, .. }) => break bars,
Ok(_) => {}
Err(_) => tokio::time::sleep(Duration::from_millis(20)).await,
}
};
assert_eq!((errors, bars.last().map(|b| b.close)), (1, Some(7.0)), "one error shown, then the bars");
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 3);
// A chart the user moved on from isn't retried.
let provider = Arc::new(ServerBack { down: usize::MAX, loads: Default::default() });
let (s, _rx) = worker_on(provider.clone());
handle(&s, load("xyz:AAPL")).await;
assert!(claim_series(&s, 1, 2), "the pane loads something else");
tokio::time::sleep(PERP_RETRY * 3).await;
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 1, "no retries for an old load");
}
/// A worker on `SourceChanges` with pane 1 loading `BTCUSD Curncy`, and its responses.
async fn switching_chart() -> (Arc<Shared>, mpsc::Receiver<Response>, Arc<SourceChanges>) {
let (out, rx) = mpsc::channel();
let provider = Arc::new(SourceChanges::default());
let s = Arc::new(Shared {
provider: provider.clone(),
series: Default::default(),
sources: Default::default(),
cache: std::sync::Mutex::new(None),
db: Mutex::new(Db::open_in_memory().unwrap()),
out,
ctx: egui::Context::default(),
});
let symbol = "BTCUSD Curncy".to_string();
handle(&s, Request::History { pane: 1, generation: 1, symbol, tf: Timeframe::D1, limit: 10 }).await;
(s, rx, provider)
}
/// The last close of each chart update, until there are `n`.
async fn closes(rx: &mpsc::Receiver<Response>, n: usize) -> Vec<f64> {
let t0 = std::time::Instant::now();
let mut closes = Vec::new();
while closes.len() < n {
assert!(t0.elapsed() < Duration::from_secs(15), "no reload: {closes:?}");
match rx.try_recv() {
Ok(Response::History { generation: 1, bars, .. }) => closes.push(bars.last().unwrap().close),
Ok(_) => {}
Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
}
}
closes
}
#[tokio::test]
async fn charts_reload_when_the_provider_ends_their_live_feed() {
let t0 = std::time::Instant::now();
let (_s, rx, provider) = switching_chart().await;
provider.reload.notify_one();
assert_eq!(closes(&rx, 2).await, [1.0, 2.0], "the reload replaced the chart");
assert!(t0.elapsed() >= RELOAD_AFTER_FEED_ENDS, "not before the pause");
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn pages_that_overlap_a_source_switch_are_dropped() {
let (s, rx, provider) = switching_chart().await;
let more = || Request::MoreHistory {
pane: 1,
generation: 1,
symbol: "BTCUSD Curncy".into(),
tf: Timeframe::D1,
existing: Arc::new(Vec::new()),
limit: 10,
};
// One page starts before the switch, and its bars arrive after it.
let before = tokio::spawn({
let (s, req) = (s.clone(), more());
async move { handle(&s, req).await }
});
let t0 = std::time::Instant::now();
while source_switches(&s, 1) != 1 {
assert!(t0.elapsed() < Duration::from_secs(15), "no reload");
tokio::time::sleep(Duration::from_millis(20)).await;
}
// Another starts while the chart reloads, and gives up at once.
let during = tokio::time::timeout(Duration::from_secs(1), handle(&s, more())).await;
assert!(during.is_ok(), "a page started during the reload went ahead");
provider.reload.notify_one();
assert_eq!(closes(&rx, 2).await, [1.0, 2.0]);
provider.page.notify_one();
before.await.unwrap();
let ts: Vec<i64> = current_series(&s, 1, 1).unwrap().iter().map(|b| b.ts).collect();
assert_eq!(ts, [0], "the old source's page joined the new source's bars");
assert!(rx.try_iter().all(|r| !matches!(r, Response::History { prepended: 1.., .. })));
}
#[test]
fn join_all_runs_everything() {
let rt = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap();
+5
View File
@@ -16,10 +16,15 @@ futures = "0.3"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.23"
zeroize = "1"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
# Hyperliquid agent keys on Linux: the Secret Service over D-Bus (pure Rust, no libdbus).
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", default-features = false, features = ["async-secret-service", "tokio", "crypto-rust"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio.workspace = true
+3
View File
@@ -342,6 +342,7 @@ impl DemoBook {
placed: Some(now),
updated: Some(now),
underlying: None,
market: None,
};
let id = order.id.clone();
self.orders.insert(0, order);
@@ -522,6 +523,7 @@ impl DemoBook {
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
}),
}
} else {
@@ -561,6 +563,7 @@ impl DemoBook {
currency: "USD".into(),
trade_date: Some(now),
settlement_date: Some(now),
market: None,
},
);
Ok(())
+87 -4
View File
@@ -127,6 +127,10 @@ pub struct Position {
pub cash_equivalent: bool,
/// For options: the underlying ticker.
pub underlying: Option<String>,
/// The terminal market key the position prices and charts by, when the source names one
/// exactly (`xyz:AAPL` for a Hyperliquid HIP-3 perp). It wins over the kind-based mapping.
#[serde(default)]
pub market: Option<String>,
}
impl Position {
@@ -153,9 +157,13 @@ impl Position {
(cost.abs() > 1e-9).then(|| self.pnl_at(price).map(|p| p / cost.abs() * 100.0)).flatten()
}
/// The terminal market key for charting, quotes and Congress lookups, when the
/// instrument maps onto one (US-listed equities and ETFs, crypto, option underlyings).
/// The terminal market key for charting, quotes and Congress lookups: [`Self::market`]
/// when set, else the kind-based mapping (US-listed equities and ETFs, crypto, option
/// underlyings).
pub fn market_key(&self) -> Option<String> {
if let Some(m) = &self.market {
return Some(m.clone());
}
market_key(self.kind, self.underlying.as_deref().unwrap_or(&self.symbol), &self.currency)
}
@@ -261,6 +269,9 @@ pub struct Order {
pub placed: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
pub underlying: Option<String>,
/// As [`Position::market`].
#[serde(default)]
pub market: Option<String>,
}
impl Order {
@@ -285,6 +296,9 @@ impl Order {
}
pub fn market_key(&self) -> Option<String> {
if let Some(m) = &self.market {
return Some(m.clone());
}
market_key(self.kind, self.underlying.as_deref().unwrap_or(&self.symbol), "USD")
}
@@ -308,10 +322,16 @@ pub struct Activity {
pub currency: String,
pub trade_date: Option<DateTime<Utc>>,
pub settlement_date: Option<DateTime<Utc>>,
/// As [`Position::market`].
#[serde(default)]
pub market: Option<String>,
}
impl Activity {
pub fn market_key(&self) -> Option<String> {
if let Some(m) = &self.market {
return Some(m.clone());
}
market_key(InstrumentKind::Stock, self.symbol.as_deref()?, &self.currency)
}
}
@@ -372,9 +392,16 @@ impl Portfolio {
t.cash += a.cash.unwrap_or(0.0);
t.buying_power += a.buying_power.unwrap_or(0.0);
}
// Futures (Hyperliquid perps) are margined: the account's cash backs them, so their value
// is their profit or loss, never their notional.
let mut futures_pnl = 0.0;
for p in self.positions.iter().filter(|p| selected(&p.account_id) && !p.cash_equivalent) {
let live = price_of(p);
t.invested += p.market_value_at(live).unwrap_or(0.0);
if p.kind == InstrumentKind::Future {
futures_pnl += p.pnl_at(live).unwrap_or(0.0);
} else {
t.invested += p.market_value_at(live).unwrap_or(0.0);
}
if let (Some(cost), Some(pnl)) = (p.cost(), p.pnl_at(live)) {
t.cost += cost;
t.pnl += pnl;
@@ -392,7 +419,7 @@ impl Portfolio {
t.value = if reported.len() == n && n > 0 && price_of_is_trivial(&self.positions, &price_of) {
reported.iter().sum()
} else {
t.cash + t.invested + cash_like
t.cash + t.invested + futures_pnl + cash_like
};
t
}
@@ -538,6 +565,7 @@ pub fn parse_positions(account_id: &str, v: &Value) -> Vec<Position> {
.unwrap_or_else(|| text(&inst["currency"])),
cash_equivalent: p["cash_equivalent"].as_bool().unwrap_or(false),
underlying,
market: None,
})
})
.collect()
@@ -597,6 +625,7 @@ pub fn parse_orders(account_id: &str, v: &Value) -> Vec<Order> {
placed: time(&o["time_placed"]),
updated: time(&o["time_updated"]).or_else(|| time(&o["time_executed"])),
underlying,
market: None,
})
})
.collect()
@@ -623,6 +652,7 @@ pub fn parse_activities(account_id: &str, v: &Value) -> Vec<Activity> {
currency: Some(text(&a["currency"]["code"])).filter(|c| !c.is_empty()).unwrap_or("USD".into()),
trade_date: time(&a["trade_date"]),
settlement_date: time(&a["settlement_date"]),
market: None,
}
})
.collect()
@@ -645,6 +675,7 @@ mod tests {
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
}
}
@@ -686,6 +717,24 @@ mod tests {
assert_eq!(opt.market_key().as_deref(), Some("AAPL US Equity"), "options chart their underlying");
}
#[test]
fn an_exact_market_key_wins_over_the_kind() {
// A Hyperliquid HIP-3 perp: a future whose key the source names exactly.
let mut perp = pos(InstrumentKind::Future, "xyz:AAPL", -0.5, 320.0, 300.0);
assert_eq!(perp.market_key(), None, "futures have no key of their own");
perp.market = Some("xyz:AAPL".into());
assert_eq!(perp.market_key().as_deref(), Some("xyz:AAPL"), "never AAPL US Equity");
let mut btc = pos(InstrumentKind::Future, "BTC", 0.01, 76_000.0, 70_000.0);
btc.market = Some("BTCUSD Curncy".into());
assert_eq!(btc.market_key().as_deref(), Some("BTCUSD Curncy"));
// SnapTrade rows have none and keep today's mapping.
assert_eq!(pos(InstrumentKind::Stock, "AAPL", 1.0, 1.0, 1.0).market_key().as_deref(), Some("AAPL US Equity"));
// Saved data from before the field existed still loads.
let mut v = serde_json::to_value(pos(InstrumentKind::Stock, "AAPL", 1.0, 1.0, 1.0)).unwrap();
v.as_object_mut().unwrap().remove("market");
assert_eq!(serde_json::from_value::<Position>(v).unwrap().market, None);
}
#[test]
fn totals_reprice_with_live_quotes() {
let mut pf = Portfolio {
@@ -724,6 +773,40 @@ mod tests {
assert_eq!(pf.totals(Some("other"), |_| None), Totals::default());
}
/// Greptile #37: a perp is margined, so repricing it moves equity by its P&L, never by its
/// notional. 10 long at 190 marked 200 (P&L 100) in an account worth 1,000.
#[test]
fn futures_reprice_by_pnl_not_notional() {
let account = |cash: f64| Account {
id: "hl".into(),
name: "Hyperliquid".into(),
number: String::new(),
institution: "Hyperliquid".into(),
currency: "USD".into(),
total: Some(1000.0),
cash: Some(cash),
buying_power: Some(200.0),
kind: "Perps".into(),
status: String::new(),
is_paper: true,
synced: None,
connection_id: String::new(),
};
let mut long = pos(InstrumentKind::Future, "BTC", 10.0, 200.0, 190.0);
long.account_id = "hl".into();
// Cash is the collateral: equity less unrealised P&L.
let pf = Portfolio { accounts: vec![account(900.0)], positions: vec![long.clone()], ..Default::default() };
assert_eq!(pf.totals(None, |_| None).value, 1000.0, "reported equity");
assert_eq!(pf.totals(None, |_| Some(200.0)).value, 1000.0, "the same price leaves equity alone");
assert_eq!(pf.totals(None, |_| Some(210.0)).value, 1100.0, "+10 × 10");
assert_eq!(pf.totals(None, |_| Some(200.0)).invested, 0.0, "perp notional is not invested cash");
let mut short = pos(InstrumentKind::Future, "BTC", -10.0, 200.0, 210.0);
short.account_id = "hl".into();
let pf = Portfolio { accounts: vec![account(900.0)], positions: vec![short], ..Default::default() };
assert_eq!(pf.totals(None, |_| Some(200.0)).value, 1000.0);
assert_eq!(pf.totals(None, |_| Some(190.0)).value, 1100.0, "a short gains as the price falls");
}
#[test]
fn occ_symbols() {
let o = parse_occ("NVDA 261218C00250000").unwrap();
+3
View File
@@ -46,6 +46,7 @@ fn position(
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
}
}
@@ -120,6 +121,7 @@ pub fn portfolio() -> Portfolio {
placed: Some(at(days, 14)),
updated: Some(at(days, 15)),
underlying: None,
market: None,
};
let orders = vec![
order("sim-brokerage", "sim-o-1", "NVDA", K::Stock, "BUY", "PENDING", "Limit", 10.0, Some(205.00), None, 0),
@@ -162,6 +164,7 @@ pub fn portfolio() -> Portfolio {
currency: "USD".into(),
trade_date: Some(at(days, 14)),
settlement_date: Some(at(days - 1, 0)),
market: None,
};
let activities = vec![
act(
+243 -5
View File
@@ -246,20 +246,32 @@ mod keychain {
const NOT_FOUND: i32 = -25300;
pub fn get(service: &str) -> Result<Option<String>> {
match get_generic_password(service, KEYCHAIN_ACCOUNT) {
get_item(service, KEYCHAIN_ACCOUNT)
}
pub fn set(service: &str, value: &str) -> Result<()> {
set_item(service, KEYCHAIN_ACCOUNT, value)
}
pub fn delete(service: &str) -> Result<()> {
delete_item(service, KEYCHAIN_ACCOUNT)
}
pub fn get_item(service: &str, account: &str) -> Result<Option<String>> {
match get_generic_password(service, account) {
Ok(bytes) => Ok(Some(String::from_utf8(bytes).context("Keychain item is not UTF-8")?)),
Err(e) if e.code() == NOT_FOUND => Ok(None),
Err(e) => Err(anyhow::anyhow!("reading the Keychain: {e}")),
}
}
pub fn set(service: &str, value: &str) -> Result<()> {
set_generic_password(service, KEYCHAIN_ACCOUNT, value.as_bytes())
pub fn set_item(service: &str, account: &str, value: &str) -> Result<()> {
set_generic_password(service, account, value.as_bytes())
.map_err(|e| anyhow::anyhow!("writing the Keychain: {e}"))
}
pub fn delete(service: &str) -> Result<()> {
match delete_generic_password(service, KEYCHAIN_ACCOUNT) {
pub fn delete_item(service: &str, account: &str) -> Result<()> {
match delete_generic_password(service, account) {
Err(e) if e.code() != NOT_FOUND => Err(anyhow::anyhow!("deleting from the Keychain: {e}")),
_ => Ok(()),
}
@@ -279,6 +291,194 @@ mod keychain {
pub fn delete(_: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
#[allow(dead_code)]
pub fn get_item(_: &str, _: &str) -> Result<Option<String>> {
bail!("the Keychain is only available on macOS")
}
#[allow(dead_code)]
pub fn set_item(_: &str, _: &str, _: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
#[allow(dead_code)]
pub fn delete_item(_: &str, _: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
}
// ---- Hyperliquid agent keys ----------------------------------------------------------------
/// The Keychain service (and Secret Service attribute) for Hyperliquid agent keys, apart
/// from the SnapTrade item.
pub const HL_KEYCHAIN_SERVICE: &str = "terminal.hyperliquid";
/// Where Hyperliquid agent (API wallet) keys live: the macOS Keychain, the Linux Secret
/// Service, or memory (the browser's session-only keys). Never a file: without a system
/// secret store the key has nowhere safe to go, so Hyperliquid trading stays off.
///
/// Items are named by account, `testnet:<master address>`. Values are the key's hex; this
/// type hands them back in buffers that are wiped when dropped, and never logs them.
#[derive(Clone)]
pub struct AgentKeyStore {
backend: KeyBackend,
}
#[derive(Clone)]
enum KeyBackend {
Keychain,
SecretService,
Memory(std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, zeroize::Zeroizing<String>>>>),
Unavailable(String),
}
impl fmt::Debug for AgentKeyStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.describe())
}
}
impl AgentKeyStore {
/// The system store for this platform, or why there is none.
pub fn platform() -> Self {
let backend = if cfg!(target_os = "macos") {
KeyBackend::Keychain
} else if cfg!(target_os = "linux") {
KeyBackend::SecretService
} else {
KeyBackend::Unavailable(
"Hyperliquid trading keys need the macOS Keychain or a Linux Secret Service, and this platform has neither in this build."
.into(),
)
};
Self { backend }
}
/// Keys held only while the app runs (lost on quit or reload).
pub fn memory() -> Self {
Self { backend: KeyBackend::Memory(Default::default()) }
}
pub fn unavailable(why: impl Into<String>) -> Self {
Self { backend: KeyBackend::Unavailable(why.into()) }
}
pub fn describe(&self) -> String {
match &self.backend {
KeyBackend::Keychain => format!("macOS Keychain ({HL_KEYCHAIN_SERVICE})"),
KeyBackend::SecretService => format!("the Secret Service keyring ({HL_KEYCHAIN_SERVICE})"),
KeyBackend::Memory(_) => "memory only (gone when Terminal closes)".into(),
KeyBackend::Unavailable(_) => "no secret store".into(),
}
}
pub fn is_memory(&self) -> bool {
matches!(self.backend, KeyBackend::Memory(_))
}
/// Whether keys can be stored, and why not. On Linux this asks the Secret Service, so
/// call it off the UI thread.
pub fn check(&self) -> Result<(), String> {
match &self.backend {
KeyBackend::Unavailable(why) => Err(why.clone()),
KeyBackend::SecretService => secret_service::probe(),
_ => Ok(()),
}
}
pub fn load(&self, account: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
match &self.backend {
KeyBackend::Keychain => Ok(keychain::get_item(HL_KEYCHAIN_SERVICE, account)?.map(zeroize::Zeroizing::new)),
KeyBackend::SecretService => secret_service::get(account),
KeyBackend::Memory(m) => Ok(m.lock().expect("key store").get(account).cloned()),
KeyBackend::Unavailable(why) => anyhow::bail!("{why}"),
}
}
pub fn save(&self, account: &str, secret: &str) -> Result<()> {
match &self.backend {
KeyBackend::Keychain => keychain::set_item(HL_KEYCHAIN_SERVICE, account, secret),
KeyBackend::SecretService => secret_service::set(account, secret),
KeyBackend::Memory(m) => {
m.lock().expect("key store").insert(account.into(), zeroize::Zeroizing::new(secret.into()));
Ok(())
}
KeyBackend::Unavailable(why) => anyhow::bail!("{why}"),
}
}
/// Remove a key. Removing one that isn't there succeeds.
pub fn delete(&self, account: &str) -> Result<()> {
match &self.backend {
KeyBackend::Keychain => keychain::delete_item(HL_KEYCHAIN_SERVICE, account),
KeyBackend::SecretService => secret_service::delete(account),
KeyBackend::Memory(m) => {
m.lock().expect("key store").remove(account);
Ok(())
}
KeyBackend::Unavailable(_) => Ok(()),
}
}
}
/// The Linux Secret Service (GNOME Keyring, KWallet) through `keyring`.
#[cfg(target_os = "linux")]
mod secret_service {
use anyhow::Result;
use keyring::{Entry, Error};
use super::HL_KEYCHAIN_SERVICE;
fn unavailable(e: Error) -> String {
format!(
"No Secret Service keyring is available ({e}). Hyperliquid trading keys are never written to a file, so trading stays off until a keyring (GNOME Keyring or KWallet) is running."
)
}
pub fn probe() -> Result<(), String> {
let e = Entry::new(HL_KEYCHAIN_SERVICE, "probe").map_err(unavailable)?;
match e.get_password() {
Ok(_) | Err(Error::NoEntry) => Ok(()),
Err(err) => Err(unavailable(err)),
}
}
pub fn get(account: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
match Entry::new(HL_KEYCHAIN_SERVICE, account)?.get_password() {
Ok(s) => Ok(Some(zeroize::Zeroizing::new(s))),
Err(Error::NoEntry) => Ok(None),
Err(e) => Err(anyhow::anyhow!(unavailable(e))),
}
}
pub fn set(account: &str, secret: &str) -> Result<()> {
Entry::new(HL_KEYCHAIN_SERVICE, account)?.set_password(secret).map_err(|e| anyhow::anyhow!(unavailable(e)))
}
pub fn delete(account: &str) -> Result<()> {
match Entry::new(HL_KEYCHAIN_SERVICE, account)?.delete_credential() {
Ok(()) | Err(Error::NoEntry) => Ok(()),
Err(e) => Err(anyhow::anyhow!(unavailable(e))),
}
}
}
#[cfg(not(target_os = "linux"))]
mod secret_service {
use anyhow::{Result, bail};
const WHY: &str = "the Secret Service is only used on Linux";
pub fn probe() -> Result<(), String> {
Err(WHY.into())
}
pub fn get(_: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
bail!(WHY)
}
pub fn set(_: &str, _: &str) -> Result<()> {
bail!(WHY)
}
pub fn delete(_: &str) -> Result<()> {
bail!(WHY)
}
}
#[cfg(test)]
@@ -350,6 +550,44 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn agent_keys_never_fall_back_to_a_file() {
let none = AgentKeyStore::unavailable("no keyring here");
assert_eq!(none.check(), Err("no keyring here".into()));
assert!(none.save("testnet:0xabc", "secret").is_err(), "refused, not written anywhere");
assert!(none.load("testnet:0xabc").is_err());
assert!(none.delete("testnet:0xabc").is_ok());
let platform = AgentKeyStore::platform();
if cfg!(target_os = "macos") {
assert!(platform.describe().contains("Keychain"));
} else if cfg!(target_os = "linux") {
assert!(platform.describe().contains("Secret Service"));
} else {
assert!(platform.check().is_err(), "other platforms have no store, so trading stays off");
}
let mem = AgentKeyStore::memory();
mem.save("testnet:0xabc", "0x11").unwrap();
assert_eq!(mem.load("testnet:0xabc").unwrap().as_deref().map(String::as_str), Some("0x11"));
mem.delete("testnet:0xabc").unwrap();
assert_eq!(mem.load("testnet:0xabc").unwrap(), None);
assert!(!format!("{mem:?}").contains("0x11"));
}
/// Writes a throwaway agent key to the login Keychain, so it runs only when asked:
/// `cargo test -p tr-broker -- --ignored agent_key`.
#[cfg(target_os = "macos")]
#[test]
#[ignore = "writes to the login Keychain"]
fn agent_key_round_trips_through_the_keychain() {
let store = AgentKeyStore::platform();
let account = format!("test:{}", std::process::id());
store.save(&account, "0x00").unwrap();
assert_eq!(store.load(&account).unwrap().as_deref().map(String::as_str), Some("0x00"));
store.delete(&account).unwrap();
assert_eq!(store.load(&account).unwrap(), None);
store.delete(&account).unwrap();
}
/// Writes throwaway items (dummy credentials) to the login Keychain, so it runs only when
/// asked: `cargo test -p tr-broker -- --ignored keychain`.
#[cfg(target_os = "macos")]
+2
View File
@@ -685,6 +685,7 @@ pub fn simulate_place(pf: &mut Portfolio, preview: &OrderPreview, now: DateTime<
placed: Some(now),
updated: Some(now),
underlying: None,
market: None,
};
if let Some(px) = fill {
let signed = if req.side == Side::Buy { units } else { -units };
@@ -715,6 +716,7 @@ fn apply_fill(pf: &mut Portfolio, account: &str, symbol: &str, signed: f64, px:
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
});
return;
};
+24 -10
View File
@@ -89,18 +89,24 @@ impl Series {
i.checked_sub(1)
}
/// Apply one live bar: absorb it into the last bar if it is the same period, append it
/// if newer, ignore it if older. Returns true if the series changed.
/// Apply one live bar by its `ts`: absorb it into the bar of the same period, append it if
/// newer, or insert it where a period is missing (a feed backfilling a gap after an outage).
/// Bars older than the whole series are ignored; paging loads those. Returns true if the
/// series changed.
pub fn apply_live(bars: &mut Vec<Bar>, live: Bar) -> bool {
match bars.last_mut() {
Some(last) if last.ts == live.ts => {
let before = *last;
last.absorb(&live);
*last != before
if bars.last().is_none_or(|last| last.ts < live.ts) {
bars.push(live);
return true;
}
match bars.binary_search_by_key(&live.ts, |b| b.ts) {
Ok(i) => {
let before = bars[i];
bars[i].absorb(&live);
bars[i] != before
}
Some(last) if last.ts > live.ts => false,
_ => {
bars.push(live);
Err(0) => false,
Err(i) => {
bars.insert(i, live);
true
}
}
@@ -148,6 +154,14 @@ mod tests {
assert!(!Series::apply_live(&mut v, Bar::new(0, 1.0, 1.0, 1.0, 1.0, 1.0)));
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.6, 11.4, 11.6, 5.0)));
assert_eq!(v.len(), 2);
// A feed backfilling after an outage: a missing period is inserted, an older bar it
// still holds is completed, and nothing older than the series is added.
assert!(Series::apply_live(&mut v, Bar::new(240, 12.0, 12.0, 12.0, 12.0, 1.0)));
assert!(Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)));
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.7, 11.4, 11.65, 6.0)));
assert_eq!(v.iter().map(|b| b.ts).collect::<Vec<_>>(), [60, 120, 180, 240]);
assert_eq!((v[1].high, v[1].close, v[1].volume), (11.7, 11.65, 6.0));
assert!(!Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)), "no change");
}
#[test]
+1
View File
@@ -5,6 +5,7 @@ pub mod bar;
pub mod catalog;
pub mod indicators;
pub mod options;
pub mod perps;
pub mod sample;
pub mod security;
pub mod timeframe;
+334
View File
@@ -0,0 +1,334 @@
//! Hyperliquid HIP-3 perpetuals, such as `xyz:AAPL`: their market keys, the venue label every
//! surface shows with them, and the reviewed table that links an instrument the app already
//! has (`AAPL US Equity`) to the perp on the same underlying.
//!
//! A perp is its own instrument. `AAPL US Equity` stays the listed stock, with its own quotes
//! and chart; the table only lets that view point at `xyz:AAPL`. Which perps exist, and which
//! are live, comes from the Terminal server at run time, so a row here never makes a perp
//! appear by itself.
//!
//! A row needs all of these (docs/architecture/server.md §7.3):
//! - the venue's own docs say the perp references the underlying the app key means;
//! - the perp is live (not `isDelisted`) on Hyperliquid;
//! - the `xyz` dex, unless xyz has no market on that underlying and another dex's match is
//! unambiguous;
//! - its price tracks the app instrument's.
/// A perp linked to an app instrument with the same underlying.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PerpLink {
/// The perp's market key, as Hyperliquid names the coin.
pub coin: &'static str,
/// The app market key with the same underlying.
pub underlying: &'static str,
/// Display name.
pub name: &'static str,
/// What the perp references, in the venue's words.
pub reference: &'static str,
}
const fn link(coin: &'static str, underlying: &'static str, name: &'static str, reference: &'static str) -> PerpLink {
PerpLink { coin, underlying, name, reference }
}
/// Checked on 2026-11-02 against trade.xyz's specification index, Hyperliquid's `meta` (all
/// live) and the app instrument's price (within 0.6%; oil differs by its contract month).
///
/// Left out on purpose: `xyz:XYZ100` and `xyz:JP225` track the Nasdaq-100 and the Nikkei 225
/// closely, but the venue's docs name neither index. `xyz:HO` tracks HO1, but the docs describe
/// the diesel market under another name and never tie it to `HO`. `xyz:KR200` is the KOSPI 200,
/// which the app doesn't have.
pub const LINKS: &[PerpLink] = &[
link("xyz:AAPL", "AAPL US Equity", "Apple Inc perp", "1 share of Apple Inc. common stock (Nasdaq: AAPL)"),
link(
"xyz:MSFT",
"MSFT US Equity",
"Microsoft Corp perp",
"1 share of Microsoft Corporation common stock (Nasdaq: MSFT)",
),
link("xyz:NVDA", "NVDA US Equity", "NVIDIA Corp perp", "1 share of NVIDIA Corporation common stock (Nasdaq: NVDA)"),
link(
"xyz:AMZN",
"AMZN US Equity",
"Amazon.com Inc perp",
"1 share of Amazon.com, Inc. common stock (Nasdaq: AMZN)",
),
link(
"xyz:GOOGL",
"GOOGL US Equity",
"Alphabet Inc Class A perp",
"1 share of Alphabet Inc. Class A common stock (Nasdaq: GOOGL)",
),
link(
"xyz:META",
"META US Equity",
"Meta Platforms Inc perp",
"1 share of Meta Platforms, Inc. Class A common stock (Nasdaq: META)",
),
link("xyz:TSLA", "TSLA US Equity", "Tesla Inc perp", "1 share of Tesla, Inc. common stock (Nasdaq: TSLA)"),
link(
"xyz:LLY",
"LLY US Equity",
"Eli Lilly & Co perp",
"1 share of Eli Lilly and Company common stock (NYSE: LLY)",
),
link("xyz:AVGO", "AVGO US Equity", "Broadcom Inc perp", "1 share of Broadcom Inc. common stock (Nasdaq: AVGO)"),
link(
"xyz:AMD",
"AMD US Equity",
"Advanced Micro Devices perp",
"1 share of Advanced Micro Devices, Inc. common stock (Nasdaq: AMD)",
),
link("xyz:NFLX", "NFLX US Equity", "Netflix Inc perp", "1 share of Netflix, Inc. common stock (Nasdaq: NFLX)"),
link(
"xyz:COIN",
"COIN US Equity",
"Coinbase Global Inc perp",
"1 share of Coinbase Global, Inc. Class A common stock (Nasdaq: COIN)",
),
link(
"xyz:MSTR",
"MSTR US Equity",
"Strategy Inc perp",
"1 share of Strategy Inc Class A common stock (Nasdaq: MSTR)",
),
link(
"xyz:TLT",
"TLT US Equity",
"iShares 20+ Year Treasury Bond ETF perp",
"1 share of the iShares 20+ Year Treasury Bond ETF (Nasdaq: TLT)",
),
link(
"xyz:SMH",
"SMH US Equity",
"VanEck Semiconductor ETF perp",
"1 share of the VanEck Semiconductor ETF (Nasdaq: SMH)",
),
link(
"xyz:XLE",
"XLE US Equity",
"Energy Select Sector SPDR perp",
"1 share of the Energy Select Sector SPDR ETF (NYSE Arca: XLE)",
),
link("xyz:SP500", "SPX Index", "S&P 500 perp", "the S&P 500 Index"),
link(
"xyz:CL",
"CL1 Comdty",
"WTI crude oil perp",
"1 barrel of WTI Light Sweet Crude Oil, from a futures contract it rolls monthly",
),
link(
"xyz:BRENTOIL",
"CO1 Comdty",
"Brent crude oil perp",
"1 barrel of Brent crude oil, from a futures contract it rolls monthly",
),
link(
"xyz:NATGAS",
"NG1 Comdty",
"Natural gas perp",
"1 MMBtu of Henry Hub natural gas, from a futures contract it rolls monthly",
),
link(
"xyz:COPPER",
"HG1 Comdty",
"Copper perp",
"1 pound of high-grade copper, from a futures contract it rolls monthly",
),
link("xyz:GOLD", "GC1 Comdty", "Gold perp", "the spot price of 1 troy ounce of gold (XAU/USD), not the future"),
link(
"xyz:SILVER",
"SI1 Comdty",
"Silver perp",
"the spot price of 1 troy ounce of silver (XAG/USD), not the future",
),
link(
"xyz:PLATINUM",
"PL1 Comdty",
"Platinum perp",
"the spot price of 1 troy ounce of platinum (XPT/USD), not the future",
),
link("xyz:EUR", "EURUSD Curncy", "EUR/USD perp", "the EUR/USD spot rate, US dollars per euro"),
link("xyz:GBP", "GBPUSD Curncy", "GBP/USD perp", "the GBP/USD spot rate, US dollars per pound"),
link("xyz:JPY", "USDJPY Curncy", "USD/JPY perp", "the USD/JPY spot rate, yen per US dollar"),
];
/// The canonical key for a perp written in any case: `XYZ:aapl` is `xyz:AAPL`. `None` unless
/// it looks like `{dex}:{coin}`: a lowercase-able dex name of letters, then letters and digits.
pub fn normalize(input: &str) -> Option<String> {
let (dex, coin) = input.trim().split_once(':')?;
let ok = |s: &str, digits: bool| {
!s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphabetic() || (digits && c.is_ascii_digit()))
};
(ok(dex, false) && ok(coin, true)).then(|| format!("{}:{}", dex.to_ascii_lowercase(), coin.to_ascii_uppercase()))
}
/// Whether `key` names a HIP-3 perp.
pub fn is_perp(key: &str) -> bool {
normalize(key).is_some()
}
/// The dex a perp trades on: `xyz` for `xyz:AAPL`.
pub fn venue(key: &str) -> Option<&str> {
key.split_once(':').map(|(dex, _)| dex).filter(|_| is_perp(key))
}
/// The label every surface shows next to a perp, so it never passes for the listed
/// instrument: `PERP · HL xyz`.
pub fn badge(key: &str) -> String {
match venue(key) {
Some(dex) => format!("PERP · HL {}", dex.to_ascii_lowercase()),
None => "PERP · HL".into(),
}
}
/// The perp linked to an app market key.
pub fn perp_for(key: &str) -> Option<&'static PerpLink> {
LINKS.iter().find(|l| l.underlying == key)
}
/// The link for a perp, by its key in any case.
pub fn link_for(key: &str) -> Option<&'static PerpLink> {
let k = normalize(key)?;
LINKS.iter().find(|l| l.coin == k)
}
/// The perps among `keys` that match every word of `query` in their key, coin or linked name,
/// best first. `PERP` in the query matches every perp; a yellow key (`GOLD CMDTY`) asks for
/// listed instruments, so it matches none.
pub fn search<'a>(query: &str, keys: impl IntoIterator<Item = &'a str>, limit: usize) -> Vec<&'a str> {
let words: Vec<String> = query.split_whitespace().map(str::to_ascii_uppercase).collect();
if words.is_empty() || words.iter().any(|w| crate::YellowKey::parse(w).is_some()) {
return Vec::new();
}
let words: Vec<&String> = words.iter().filter(|w| !matches!(w.as_str(), "PERP" | "PERPS")).collect();
let mut scored: Vec<(i32, &'a str)> = keys
.into_iter()
.filter_map(|key| {
let up = key.to_ascii_uppercase();
let coin = up.split_once(':').map_or(up.as_str(), |(_, c)| c);
let name = link_for(key).map(|l| l.name.to_ascii_uppercase()).unwrap_or_default();
let mut score = 1;
for w in &words {
score += if coin == w.as_str() {
1000
} else if coin.starts_with(w.as_str()) {
600 - 10 * (coin.len() - w.len()) as i32
} else if up.starts_with(w.as_str()) {
500
} else if name.split(|c: char| c.is_whitespace() || c == '/').any(|n| n.starts_with(w.as_str())) {
300
} else {
return None;
};
}
Some((score, key))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
scored.into_iter().take(limit).map(|(_, k)| k).collect()
}
/// Where `PERP` takes a pane on `key`: from a linked perp to its instrument, or from an
/// instrument to its perp while `live` says the server lists that perp.
pub fn switch_target(key: &str, live: impl Fn(&str) -> bool) -> Option<&'static str> {
match link_for(key) {
Some(l) => Some(l.underlying),
None => perp_for(key).map(|l| l.coin).filter(|c| live(c)),
}
}
/// The other side of a link: an instrument's perp, or a perp's instrument.
pub fn counterpart(key: &str) -> Option<&'static str> {
match link_for(key) {
Some(l) => Some(l.underlying),
None => perp_for(key).map(|l| l.coin),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Security, catalog};
#[test]
fn keys_normalise_and_label_their_venue() {
assert_eq!(normalize("XYZ:aapl").as_deref(), Some("xyz:AAPL"));
assert_eq!(normalize(" xyz:XYZ100 ").as_deref(), Some("xyz:XYZ100"));
assert_eq!(normalize("para:10Y").as_deref(), Some("para:10Y"));
for not in ["AAPL", "xyz:", ":AAPL", "x1:AAPL", "xyz:AA-PL", "xyz:AAPL:B", "AAPL US Equity"] {
assert_eq!(normalize(not), None, "{not}");
}
assert_eq!(venue("xyz:AAPL"), Some("xyz"));
assert_eq!(venue("BTC"), None);
assert_eq!(badge("xyz:AAPL"), "PERP · HL xyz");
assert_eq!(badge("para:10Y"), "PERP · HL para");
}
#[test]
fn links_point_at_app_instruments_and_back() {
assert_eq!(perp_for("AAPL US Equity").map(|l| l.coin), Some("xyz:AAPL"));
assert_eq!(perp_for("SPX Index").map(|l| l.coin), Some("xyz:SP500"));
assert_eq!(perp_for("USDJPY Curncy").map(|l| l.coin), Some("xyz:JPY"));
assert_eq!(perp_for("CL1 Comdty").map(|l| l.coin), Some("xyz:CL"));
assert_eq!(link_for("XYZ:eur").map(|l| l.underlying), Some("EURUSD Curncy"));
// Not linked: the docs don't name these indices, or the app lacks the instrument.
for unlinked in ["NDX Index", "NKY Index", "HO1 Comdty", "KOSPI Index", "JPM US Equity", "BTCUSD Curncy"] {
assert_eq!(perp_for(unlinked), None, "{unlinked}");
}
assert_eq!(link_for("xyz:XYZ100"), None);
assert_eq!(counterpart("AAPL US Equity"), Some("xyz:AAPL"));
assert_eq!(counterpart("xyz:aapl"), Some("AAPL US Equity"));
assert_eq!(counterpart("xyz:SKHX"), None);
}
#[test]
fn switching_follows_links_to_live_perps_only() {
let live = |c: &str| c == "xyz:AAPL";
assert_eq!(switch_target("AAPL US Equity", live), Some("xyz:AAPL"));
assert_eq!(switch_target("GC1 Comdty", live), None, "xyz:GOLD isn't listed live here");
assert_eq!(switch_target("xyz:GOLD", live), Some("GC1 Comdty"), "back to the instrument always works");
assert_eq!(switch_target("XYZ:aapl", |_| false), Some("AAPL US Equity"));
assert_eq!(switch_target("xyz:SKHX", |_| true), None, "unlinked");
assert_eq!(switch_target("NDX Index", |_| true), None, "no link");
}
#[test]
fn search_finds_perps_by_coin_key_or_name() {
let live = ["xyz:AAPL", "xyz:GOLD", "xyz:SKHX", "xyz:SP500", "para:10Y", "xyz:AMD", "xyz:AMZN"];
let s = |q: &str| search(q, live, 10);
assert_eq!(s("aapl"), ["xyz:AAPL"]);
assert_eq!(s("xyz:aa"), ["xyz:AAPL"]);
assert_eq!(s("am")[..2], ["xyz:AMD", "xyz:AMZN"], "coin prefixes, shorter first");
assert_eq!(s("apple"), ["xyz:AAPL"], "a linked perp's name");
assert_eq!(s("gold perp"), ["xyz:GOLD"]);
assert_eq!(s("s&p"), ["xyz:SP500"]);
assert_eq!(s("skhx"), ["xyz:SKHX"], "unlinked perps by coin");
assert_eq!(s("10y"), ["para:10Y"]);
assert_eq!(s("perp").len(), live.len());
assert!(s("gold cmdty").is_empty(), "a yellow key asks for the listed instrument");
assert!(s("").is_empty() && s("zzzz").is_empty());
assert_eq!(search("a", live, 2).len(), 2);
}
/// Every row names a real app instrument, by its canonical key, on one side, and a
/// well-formed xyz perp on the other, each at most once.
#[test]
fn the_table_is_well_formed() {
let mut coins: Vec<&str> = LINKS.iter().map(|l| l.coin).collect();
let mut keys: Vec<&str> = LINKS.iter().map(|l| l.underlying).collect();
coins.sort();
coins.dedup();
keys.sort();
keys.dedup();
assert_eq!((coins.len(), keys.len()), (LINKS.len(), LINKS.len()), "one row per perp and per instrument");
for l in LINKS {
assert_eq!(normalize(l.coin).as_deref(), Some(l.coin), "{} is canonical", l.coin);
assert_eq!(venue(l.coin), Some("xyz"), "{} prefers xyz", l.coin);
let sec = Security::parse(l.underlying).expect("parses");
assert_eq!(sec.key(), l.underlying, "{} is a canonical key", l.underlying);
assert!(catalog::by_security(&sec).is_some(), "{} is in the catalog", l.underlying);
assert!(l.name.ends_with(" perp") && !l.reference.is_empty());
}
}
}
+55 -8
View File
@@ -1,11 +1,12 @@
//! Securities across asset classes, addressed with Bloomberg-style market keys:
//! `AAPL US Equity`, `SPX Index`, `BTCUSD Curncy`, `EURUSD Curncy`, `US10Y Govt`, `CL1 Comdty`.
//! Hyperliquid HIP-3 perps keep the venue's own name, `xyz:AAPL` (see [`crate::perps`]).
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::catalog;
use crate::{catalog, perps};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum AssetClass {
@@ -16,6 +17,8 @@ pub enum AssetClass {
Crypto,
Govt,
Cmdty,
/// A Hyperliquid HIP-3 perpetual (`xyz:AAPL`): its own instrument, never the listed one.
Perp,
}
/// The Bloomberg "yellow key" a security lives under.
@@ -26,11 +29,14 @@ pub enum YellowKey {
Curncy,
Govt,
Comdty,
/// HIP-3 perps, which Bloomberg has no key for. Nothing typed parses to it: a perp is named
/// by its venue key (`xyz:AAPL`), and `PERP` after a security is a function.
Perp,
}
impl YellowKey {
pub const ALL: [YellowKey; 5] =
[YellowKey::Equity, YellowKey::Index, YellowKey::Curncy, YellowKey::Govt, YellowKey::Comdty];
pub const ALL: [YellowKey; 6] =
[YellowKey::Equity, YellowKey::Index, YellowKey::Curncy, YellowKey::Govt, YellowKey::Comdty, YellowKey::Perp];
/// Accepts `<CRNCY>`, `CURNCY`, `crncy`, `CMDTY`, `COMDTY`, `GOVT`, `INDEX`, `EQUITY`, `EQ`, `ETF`.
pub fn parse(s: &str) -> Option<Self> {
@@ -52,6 +58,7 @@ impl YellowKey {
YellowKey::Curncy => "Curncy",
YellowKey::Govt => "Govt",
YellowKey::Comdty => "Comdty",
YellowKey::Perp => "Perp",
}
}
}
@@ -64,6 +71,7 @@ impl AssetClass {
AssetClass::Fx | AssetClass::Crypto => YellowKey::Curncy,
AssetClass::Govt => YellowKey::Govt,
AssetClass::Cmdty => YellowKey::Comdty,
AssetClass::Perp => YellowKey::Perp,
}
}
@@ -76,6 +84,7 @@ impl AssetClass {
AssetClass::Crypto => "Crypto",
AssetClass::Govt => "Govt",
AssetClass::Cmdty => "Comdty",
AssetClass::Perp => "Perp",
}
}
@@ -87,13 +96,14 @@ impl AssetClass {
AssetClass::Fx => 0.08,
AssetClass::Crypto => 0.60,
AssetClass::Govt => 0.20,
AssetClass::Cmdty => 0.30,
AssetClass::Cmdty | AssetClass::Perp => 0.30,
}
}
/// Exchanges trade crypto around the clock; everything else has sessions.
/// Exchanges trade crypto around the clock, and HIP-3 perps too (even while the market they
/// reference is closed); everything else has sessions.
pub fn trades_24_7(self) -> bool {
self == AssetClass::Crypto
matches!(self, AssetClass::Crypto | AssetClass::Perp)
}
}
@@ -122,9 +132,15 @@ impl Security {
Self::new(ticker, AssetClass::Equity)
}
/// Canonical market key, e.g. `AAPL US Equity`, `BTCUSD Curncy`.
/// A HIP-3 perp from its venue key in any case (`XYZ:aapl` is `xyz:AAPL`).
pub fn perp(key: &str) -> Option<Self> {
Some(Self { ticker: perps::normalize(key)?, class: AssetClass::Perp })
}
/// Canonical market key, e.g. `AAPL US Equity`, `BTCUSD Curncy`, `xyz:AAPL`.
pub fn key(&self) -> String {
match self.class {
AssetClass::Perp => self.ticker.clone(),
AssetClass::Equity | AssetClass::Etf => format!("{} US Equity", self.ticker),
c => format!("{} {}", self.ticker, c.yellow_key().label()),
}
@@ -134,6 +150,9 @@ impl Security {
/// Returns the security and how many input tokens it consumed.
pub fn resolve_tokens(tokens: &[&str]) -> Option<(Self, usize)> {
let (&head, rest) = tokens.split_first()?;
if let Some(perp) = Self::perp(head) {
return Some((perp, 1));
}
// `BTC/USD` is a pair; `BRK/B` is a share class.
let up = head.trim().to_ascii_uppercase();
let ticker = match up.split_once('/') {
@@ -187,7 +206,7 @@ impl Security {
AssetClass::Crypto
}
}
Some(YellowKey::Equity) => AssetClass::Equity,
Some(YellowKey::Equity) | Some(YellowKey::Perp) => AssetClass::Equity,
None if ticker.len() == 6 && is_fiat(&ticker[..3]) && is_fiat(&ticker[3..]) => AssetClass::Fx,
None => AssetClass::Equity,
};
@@ -196,6 +215,9 @@ impl Security {
/// Display name from the catalog, if known.
pub fn name(&self) -> Option<&'static str> {
if self.class == AssetClass::Perp {
return perps::link_for(&self.ticker).map(|l| l.name);
}
catalog::lookup(&self.ticker, Some(self.class.yellow_key())).filter(|l| l.class == self.class).map(|l| l.name)
}
@@ -218,6 +240,10 @@ impl Security {
AssetClass::Govt => 3,
AssetClass::Fx if self.ticker.ends_with("JPY") => 3,
AssetClass::Fx => 4,
// Hyperliquid quotes five significant figures, at most six decimals.
AssetClass::Perp if price.is_finite() && price != 0.0 => {
(4 - price.abs().log10().floor() as i64).clamp(0, 6) as usize
}
_ => match price.abs() {
p if p >= 1000.0 => 2,
p if p >= 1.0 => 2,
@@ -291,6 +317,27 @@ mod tests {
assert_eq!(Security::resolve_tokens(&t).unwrap().1, 1);
}
#[test]
fn hip3_perps_are_their_own_instruments() {
let p = r("xyz:AAPL");
assert_eq!(p, Security { ticker: "xyz:AAPL".into(), class: AssetClass::Perp });
assert_eq!(r("XYZ:aapl"), p, "any case");
assert_eq!(p.key(), "xyz:AAPL");
assert_eq!(r("AAPL"), Security::new("AAPL", AssetClass::Equity), "AAPL stays the listed stock");
assert_eq!(r("AAPL US Equity").key(), "AAPL US Equity");
assert_eq!(Security::resolve_tokens(&["xyz:AAPL", "5"]).unwrap().1, 1);
assert_eq!(p.name(), Some("Apple Inc perp"));
assert_eq!(r("xyz:SKHX").name(), None, "unlinked perps have no catalog name");
assert_eq!((p.class.label(), p.class.yellow_key().label()), ("Perp", "Perp"));
assert!(p.class.trades_24_7() && !p.has_congress_trades() && p.pair().is_none());
// Prices show the five significant figures Hyperliquid quotes.
assert_eq!(r("xyz:EUR").decimals(1.1208), 4);
assert_eq!(r("xyz:JPY").decimals(157.74), 2);
assert_eq!(r("xyz:GOLD").decimals(3958.05), 1);
assert_eq!(r("xyz:XYZ100").decimals(28390.5), 0);
assert_eq!(r("para:ANSEM").decimals(0.041887), 6);
}
#[test]
fn keys_and_helpers() {
assert_eq!(Security::new("BTCUSD", AssetClass::Crypto).key(), "BTCUSD Curncy");
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
[dependencies]
tr-core.workspace = true
tr-proto.workspace = true
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
+102 -14
View File
@@ -12,9 +12,14 @@
//! The router is wrapped in [`Fallback`], which serves sample data if a source is
//! unreachable, so the terminal always has something to draw.
//!
//! `TR_SERVER_URL` (`ws://` or `wss://`) opts in to the Terminal data server
//! ([`remote::RemoteMarkets`]): crypto markets then come from Hyperliquid perps through the
//! server, and everything else, or anything the server can't serve, from the sources above.
//!
//! The browser build (`wasm32`) can only reach sources that allow cross-origin requests:
//! [`for_browser`] serves live crypto through [`web::WebMarkets`] and sample data for the
//! rest. It has no SQLite bar cache.
//! rest. It has no SQLite bar cache. When the page was built with a Terminal server URL
//! ([`web::server_url`]), crypto comes through the server first, as on the desktop.
#[cfg(not(target_arch = "wasm32"))]
pub mod alpaca;
@@ -23,13 +28,15 @@ pub mod cache;
pub mod crypto;
#[cfg(not(target_arch = "wasm32"))]
pub mod markets;
pub mod remote;
pub mod rss;
pub mod rt;
pub mod sample;
#[cfg(not(target_arch = "wasm32"))]
pub mod treasury;
pub mod web;
mod ws;
/// The websocket client the providers use (the app's Hyperliquid user stream uses it too).
pub mod ws;
#[cfg(not(target_arch = "wasm32"))]
pub mod yahoo;
@@ -150,6 +157,17 @@ impl OrderBook {
}
}
/// One trade on a venue's tape.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TradePrint {
/// The venue's trade time (Unix milliseconds).
pub ts_ms: i64,
pub px: f64,
pub sz: f64,
/// The buyer was the aggressor.
pub buy: bool,
}
/// One listed option.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OptionContract {
@@ -196,7 +214,9 @@ pub struct OptionChain {
}
/// Live bar updates for one symbol and timeframe: each message is the latest state of
/// the current (possibly still forming) bar. The feed stops when the receiver is dropped.
/// the current (possibly still forming) bar, or, after an outage, an older bar that fills the
/// gap ([`tr_core::Series::apply_live`] slots it in). The feed stops when the receiver is
/// dropped.
pub type LiveFeed = mpsc::Receiver<Bar>;
/// Pick curve snapshots from per-date rows: latest, nearest at or before -30d and -365d.
@@ -267,6 +287,17 @@ pub trait MarketData: Send + Sync {
anyhow::bail!("no public order book for {symbol}")
}
/// The latest `n` trades, oldest first, where a public tape exists.
async fn trades(&self, symbol: &str, _n: usize) -> Result<Vec<TradePrint>> {
anyhow::bail!("no public trades for {symbol}")
}
/// The HIP-3 perps (`xyz:AAPL`) the Terminal server lists as live right now. An error
/// when there is no server or it can't be reached.
async fn perp_markets(&self) -> Result<Vec<String>> {
anyhow::bail!("no Terminal server")
}
/// The US Treasury curve. The default assembles it from each tenor's daily history.
async fn yield_curves(&self) -> Result<YieldCurves> {
let tenors = catalog::treasury_curve();
@@ -319,8 +350,8 @@ impl<P: MarketData> MarketData for Fallback<P> {
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
match self.primary.history(symbol, tf, end, limit).await {
Ok(b) => Ok(b),
// Paging older history: never splice sample bars onto real ones.
Err(e) if end.is_some() => Err(e),
// Paging older history: never splice sample bars onto real ones. Perps never get them.
Err(e) if end.is_some() || is_perp(symbol) => Err(e),
Err(e) => {
tracing::warn!("{} history {symbol} {tf} failed, using sample data: {e:#}", self.name);
self.sample.history(symbol, tf, end, limit).await
@@ -331,10 +362,11 @@ impl<P: MarketData> MarketData for Fallback<P> {
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
match self.primary.quotes(symbols).await {
// If the primary produced nothing at all (offline), serve sample quotes rather
// than an empty panel; partial results are never mixed with synthetic prices.
// than an empty panel; partial results are never mixed with synthetic prices. Perps
// are never filled in.
Ok(mut q) => {
let missing: Vec<String> =
symbols.iter().filter(|s| !q.iter().any(|x| &x.symbol == *s)).cloned().collect();
symbols.iter().filter(|s| !is_perp(s) && !q.iter().any(|x| &x.symbol == *s)).cloned().collect();
if !missing.is_empty() && q.is_empty() {
q.extend(self.sample.quotes(&missing).await.unwrap_or_default());
}
@@ -342,7 +374,8 @@ impl<P: MarketData> MarketData for Fallback<P> {
}
Err(e) => {
tracing::warn!("{} quotes failed, using sample data: {e:#}", self.name);
self.sample.quotes(symbols).await
let listed: Vec<String> = symbols.iter().filter(|s| !is_perp(s)).cloned().collect();
self.sample.quotes(&listed).await
}
}
}
@@ -362,6 +395,7 @@ impl<P: MarketData> MarketData for Fallback<P> {
async fn options(&self, symbol: &str, expiration: Option<i64>) -> Result<OptionChain> {
match self.primary.options(symbol, expiration).await {
Ok(c) => Ok(c),
Err(e) if is_perp(symbol) => Err(e),
Err(e) => {
tracing::warn!("{} options {symbol}: {e:#}; simulating", self.name);
self.sample.options(symbol, expiration).await
@@ -373,6 +407,7 @@ impl<P: MarketData> MarketData for Fallback<P> {
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
match self.primary.order_book(symbol, depth).await {
Ok(b) => Ok(b),
Err(e) if is_perp(symbol) => Err(e),
Err(e) => {
tracing::debug!("{} order book {symbol}: {e:#}; simulating", self.name);
self.sample.order_book(symbol, depth).await
@@ -380,6 +415,15 @@ impl<P: MarketData> MarketData for Fallback<P> {
}
}
/// Real trades only: nothing simulates a tape.
async fn trades(&self, symbol: &str, n: usize) -> Result<Vec<TradePrint>> {
self.primary.trades(symbol, n).await
}
async fn perp_markets(&self) -> Result<Vec<String>> {
self.primary.perp_markets().await
}
async fn yield_curves(&self) -> Result<YieldCurves> {
match self.primary.yield_curves().await {
Ok(c) => Ok(c),
@@ -396,6 +440,17 @@ pub fn security(symbol: &str) -> Security {
Security::parse(symbol).unwrap_or_else(|| Security::equity(symbol))
}
/// Whether `symbol` is a HIP-3 perp (`xyz:AAPL`).
pub fn is_perp(symbol: &str) -> bool {
tr_core::perps::is_perp(symbol)
}
/// What a source other than the Terminal server says about a HIP-3 perp. Only the server
/// carries them, and nothing stands in for them, not even sample data.
pub fn perp_needs_server(symbol: &str) -> anyhow::Error {
anyhow::anyhow!("{symbol} is a HIP-3 perp, which only the Terminal server provides")
}
/// Build the provider described in the crate docs from environment variables.
#[cfg(not(target_arch = "wasm32"))]
pub fn from_env() -> Provider {
@@ -412,18 +467,33 @@ pub fn from_env() -> Provider {
}
_ => None,
};
match markets::Markets::new(alpaca) {
Ok(m) => Arc::new(Fallback::new(m)),
Err(_) => Arc::new(sample::SampleProvider::default().with_live_ticks()),
let markets = match markets::Markets::new(alpaca) {
Ok(m) => Fallback::new(m),
Err(_) => return Arc::new(sample::SampleProvider::default().with_live_ticks()),
};
match var("TR_SERVER_URL") {
Some(url) => {
// The URL itself stays out of the logs.
tracing::info!("TR_SERVER_URL is set: crypto markets come through the Terminal server");
Arc::new(remote::RemoteMarkets::new(url.trim(), markets))
}
None => Arc::new(markets),
}
}
/// The browser build's provider: sample data only when `offline`, otherwise live crypto
/// with sample data for everything else.
pub fn for_browser(offline: bool) -> Provider {
/// with sample data for everything else. With a Terminal `server` URL, crypto comes through
/// that server, and from Binance and Coinbase whenever the server can't serve it.
pub fn for_browser(offline: bool, server: Option<&str>) -> Provider {
if !offline {
match web::WebMarkets::new() {
Ok(m) => return Arc::new(Fallback::new(m)),
Ok(m) => {
let m = Fallback::new(m);
return match server {
Some(url) => Arc::new(remote::RemoteMarkets::new(url, m)),
None => Arc::new(m),
};
}
Err(e) => tracing::warn!("crypto sources unavailable, using sample data: {e:#}"),
}
}
@@ -500,6 +570,24 @@ mod tests {
assert_eq!(p.name(), "broken");
}
/// A HIP-3 perp has no source but the Terminal server, so nothing stands in for it: no
/// sample bars, quotes or books when a source fails, and none in offline sample mode.
#[tokio::test]
async fn perps_never_get_stand_in_data() {
assert!(is_perp("xyz:AAPL") && is_perp("XYZ:aapl") && !is_perp("AAPL US Equity") && !is_perp("BTCUSD Curncy"));
let p = Fallback::new(Broken);
assert!(p.history("xyz:AAPL", Timeframe::D1, None, 10).await.is_err());
assert!(p.order_book("xyz:AAPL", 10).await.is_err());
let q = p.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
let s = sample::SampleProvider::default().with_live_ticks();
let e = s.history("xyz:AAPL", Timeframe::D1, None, 10).await.unwrap_err();
assert!(format!("{e:#}").contains("Terminal server"), "{e:#}");
assert!(s.order_book("xyz:AAPL", 10).await.is_err() && s.live("xyz:AAPL", Timeframe::M1).is_none());
let q = s.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
}
#[tokio::test]
async fn default_yield_curve_from_history() {
let c = sample::SampleProvider::at(1_790_000_000, 1_000).yield_curves().await.unwrap();
+13 -1
View File
@@ -6,6 +6,9 @@
//! | Govt | Treasury.gov daily curve (FRED fallback); Yahoo `^TNX` etc. intraday | Treasury.gov | — |
//! | Equity/ETF | Alpaca if keyed, else Yahoo | Yahoo spark | — |
//! | FX/Index/Comdty | Yahoo (`EURUSD=X`, `^GSPC`, `CL=F`) | Yahoo spark | — |
//! | HIP-3 perps (`xyz:AAPL`) | — | — | — |
//!
//! HIP-3 perps come only from the Terminal server ([`crate::remote::RemoteMarkets`]).
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
@@ -15,7 +18,9 @@ use crate::alpaca::AlpacaProvider;
use crate::crypto::CryptoProvider;
use crate::treasury::TreasuryProvider;
use crate::yahoo::{YahooProvider, yahoo_symbol};
use crate::{LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, security};
use crate::{
LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, perp_needs_server, security,
};
pub struct Markets {
yahoo: YahooProvider,
@@ -47,6 +52,7 @@ impl MarketData for Markets {
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
let sec = security(symbol);
match sec.class {
AssetClass::Perp => Err(perp_needs_server(symbol)),
AssetClass::Crypto => self.crypto.history(&sec, tf, end, limit).await,
AssetClass::Govt if tf.is_intraday() => match catalog::by_security(&sec).and_then(|l| l.yahoo) {
Some(y) => self.yahoo.history(y, tf, end, limit).await,
@@ -69,6 +75,7 @@ impl MarketData for Markets {
for key in symbols {
let sec = security(key);
match sec.class {
AssetClass::Perp => {}
AssetClass::Crypto => crypto.push((key.clone(), sec)),
AssetClass::Govt => govt.push((key.clone(), sec)),
_ => other.push((key.clone(), sec)),
@@ -123,6 +130,11 @@ impl MarketData for Markets {
Some(sec) => match sec.class {
// Yahoo has no crypto headlines; use CoinDesk's feed.
AssetClass::Crypto => return self.crypto.news(&sec).await,
// A linked perp shares its underlying's headlines.
AssetClass::Perp => match tr_core::perps::link_for(&sec.ticker) {
Some(l) => yahoo_symbol(&security(l.underlying)),
None => return Ok(Vec::new()),
},
AssetClass::Govt => catalog::by_security(&sec).and_then(|l| l.yahoo).unwrap_or("^TNX").to_string(),
_ => yahoo_symbol(&sec),
},
File diff suppressed because it is too large. Load diff
+51 -8
View File
@@ -37,16 +37,51 @@ pub async fn sleep(d: Duration) {
tokio::time::sleep(d).await;
}
/// Resolves after `d`, through `setTimeout`.
/// Resolves after `d`, through a `setTimeout` set on the first poll and cleared if the sleep is
/// dropped before it fires.
#[cfg(target_arch = "wasm32")]
pub async fn sleep(d: Duration) {
let ms = d.as_millis().min(i32::MAX as u128) as i32;
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
pub fn sleep(d: Duration) -> impl Future<Output = ()> + Send {
struct Timer(Option<i32>);
impl Drop for Timer {
fn drop(&mut self) {
if let (Some(id), Some(w)) = (self.0, web_sys::window()) {
w.clear_timeout_with_handle(id);
}
}
});
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}
let ms = d.as_millis().min(i32::MAX as u128) as i32;
// A promise isn't `Send`. The web build has one thread, so wrapping it is sound, and the provider
// traits' `Send` futures can then wait on it.
send_wrapper::SendWrapper::new(async move {
let mut timer = Timer(None);
let promise = js_sys::Promise::new(&mut |resolve, _| {
timer.0 = web_sys::window()
.and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms).ok());
});
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
timer.0 = None; // it fired
})
}
/// `fut` ran past its deadline.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Elapsed;
impl std::fmt::Display for Elapsed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("deadline has elapsed")
}
}
impl std::error::Error for Elapsed {}
/// Run `fut` for at most `d`.
pub async fn timeout<F: Future>(d: Duration, fut: F) -> Result<F::Output, Elapsed> {
let (fut, deadline) = (std::pin::pin!(fut), std::pin::pin!(sleep(d)));
match futures::future::select(fut, deadline).await {
futures::future::Either::Left((out, _)) => Ok(out),
futures::future::Either::Right(_) => Err(Elapsed),
}
}
/// A periodic timer: the first tick completes at once, later ones every `period`. A late
@@ -86,4 +121,12 @@ mod tests {
assert!(t0.elapsed() >= Duration::from_millis(40));
assert_eq!(blocking(|| 6 * 7).await, Some(42));
}
#[tokio::test]
async fn timeout_stops_at_the_deadline() {
assert_eq!(timeout(Duration::from_secs(5), async { 7 }).await, Ok(7));
let t0 = Instant::now();
assert_eq!(timeout(Duration::from_millis(20), std::future::pending::<()>()).await, Err(Elapsed));
assert!(t0.elapsed() >= Duration::from_millis(20));
}
}
+25 -2
View File
@@ -5,7 +5,7 @@ use async_trait::async_trait;
use tokio::sync::mpsc;
use tr_core::{Bar, Timeframe, sample};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, rt, security};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, Quote, is_perp, perp_needs_server, rt, security};
/// Deterministic synthetic data anchored at a fixed "now" per process, so paging is
/// consistent: page N+1 is exactly the bars preceding page N. Levels and volatility
@@ -48,25 +48,48 @@ impl MarketData for SampleProvider {
"sample"
}
/// HIP-3 perps have no sample data: only the Terminal server provides them.
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
if is_perp(symbol) {
return Err(perp_needs_server(symbol));
}
Ok(crate::clip(self.bars(symbol, tf), end, limit))
}
/// From daily history, like the default, but leaving out perps instead of failing on them.
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
let mut out = Vec::with_capacity(symbols.len());
for s in symbols.iter().filter(|s| !is_perp(s)) {
let bars = self.history(s, Timeframe::D1, None, 2).await?;
if let Some(last) = bars.last() {
let prev = if bars.len() > 1 { bars[bars.len() - 2].close } else { last.open };
out.push(Quote { symbol: s.clone(), last: last.close, prev_close: prev, ts: last.ts, spark: vec![] });
}
}
Ok(out)
}
/// A synthetic chain priced with Black-Scholes around the last price.
async fn options(&self, symbol: &str, expiration: Option<i64>) -> Result<OptionChain> {
if is_perp(symbol) {
return Err(perp_needs_server(symbol));
}
let spot = self.bars(symbol, Timeframe::D1).last().map(|b| b.close).unwrap_or(100.0);
Ok(simulated_chain(symbol, spot, expiration, chrono::Utc::now().timestamp()))
}
/// A synthetic book around the last price; sizes vary with the clock so it moves.
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
if is_perp(symbol) {
return Err(perp_needs_server(symbol));
}
let last = self.bars(symbol, Timeframe::M1).last().map(|b| b.close).unwrap_or(100.0);
Ok(simulated_book(symbol, last, depth, chrono::Utc::now().timestamp_millis() as u64 / 500))
}
/// Random-walk ticks on the last bar every 400 ms, rolling into new bars on schedule.
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
if !self.live_ticks {
if !self.live_ticks || is_perp(symbol) {
return None;
}
let mut bar = *self.bars(symbol, tf).last()?;
+84 -3
View File
@@ -3,7 +3,12 @@
//! A page can only read sources that allow cross-origin requests. Binance and Coinbase do,
//! so crypto is live: REST history, quotes and order books, and websocket bars. Yahoo,
//! Treasury.gov, Alpaca and CoinDesk do not, so every other asset class comes from the
//! sample generator and there are no headlines or network search.
//! sample generator and there are no headlines or network search. HIP-3 perps come only from
//! the Terminal server, so here they have no data at all.
//!
//! When the page has a Terminal server ([`server_url`]), [`crate::for_browser`] puts
//! [`crate::remote::RemoteMarkets`] in front of these, so crypto comes from the server and
//! from Binance and Coinbase only while the server can't serve it.
//!
//! TODO(web proxy): route equities, FX, indices, futures, Treasuries and news through a
//! same-origin proxy to those sources, then use [`crate::markets::Markets`] here instead.
@@ -14,7 +19,7 @@ use tr_core::{AssetClass, Bar, Timeframe};
use crate::crypto::CryptoProvider;
use crate::sample::SampleProvider;
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, security};
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, is_perp, perp_needs_server, security};
pub struct WebMarkets {
crypto: CryptoProvider,
@@ -27,6 +32,38 @@ impl WebMarkets {
}
}
/// The Terminal server a page uses for crypto markets: `built`, the URL set when the page was
/// built, unless the page's `?server=` parameter (`requested`) turns it off (`off`) or names a
/// server on the same machine, such as `ws://localhost:8787/ws`. Other servers are refused, so a
/// link can't point someone's page at a server that isn't ours.
pub fn server_url(built: Option<&str>, requested: Option<&str>) -> Option<String> {
let built = built.map(str::trim).filter(|u| !u.is_empty()).map(str::to_string);
let Some(requested) = requested.map(str::trim) else { return built };
if requested.eq_ignore_ascii_case("off") {
return None;
}
if is_local(requested) {
return Some(requested.to_string());
}
tracing::warn!("?server= takes a server on this machine (ws://localhost:8787/ws) or off; ignoring it");
built
}
/// A `ws://` or `wss://` URL whose host is this machine. Anything unusual (user info, other
/// spellings of the address) counts as not local.
fn is_local(url: &str) -> bool {
let Some(rest) = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://")) else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
if authority.contains('@') {
return false;
}
let host = match authority.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => authority.split(':').next().unwrap_or_default(),
};
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
fn is_crypto(symbol: &str) -> bool {
security(symbol).class == AssetClass::Crypto
}
@@ -38,6 +75,9 @@ impl MarketData for WebMarkets {
}
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
if is_perp(symbol) {
return Err(perp_needs_server(symbol));
}
if is_crypto(symbol) {
self.crypto.history(&security(symbol), tf, end, limit).await
} else {
@@ -46,7 +86,8 @@ impl MarketData for WebMarkets {
}
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
let (crypto, other): (Vec<String>, Vec<String>) = symbols.iter().cloned().partition(|s| is_crypto(s));
let listed = symbols.iter().filter(|s| !is_perp(s)).cloned();
let (crypto, other): (Vec<String>, Vec<String>) = listed.partition(|s| is_crypto(s));
let mut out = self.sample.quotes(&other).await?;
if !crypto.is_empty() {
let secs: Vec<_> = crypto.iter().map(|k| (k.clone(), security(k))).collect();
@@ -93,4 +134,44 @@ mod tests {
assert_eq!(m.yield_curves().await.unwrap().current.points.len(), 11);
assert!(is_crypto("BTCUSD Curncy") && !is_crypto("US10Y Govt"));
}
#[tokio::test]
async fn perps_need_the_server() {
let m = WebMarkets::new().unwrap();
assert!(m.history("xyz:AAPL", Timeframe::D1, None, 50).await.is_err());
assert!(m.order_book("xyz:AAPL", 10).await.is_err());
let q = m.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
}
#[test]
fn server_url_from_the_build_or_a_local_override() {
let prod = Some("wss://terminal-gateway.style.dev/ws");
assert_eq!(server_url(prod, None).as_deref(), prod);
assert_eq!(server_url(Some(" "), None), None, "an empty build setting means no server");
assert_eq!(server_url(None, None), None);
assert_eq!(server_url(prod, Some("off")), None);
for local in ["ws://localhost:8787/ws", "ws://127.0.0.1:9", "wss://[::1]:8787/ws", "ws://LOCALHOST/ws"] {
assert_eq!(server_url(prod, Some(local)).as_deref(), Some(local));
assert_eq!(server_url(None, Some(local)).as_deref(), Some(local));
}
for refused in [
"wss://evil.example/ws",
"ws://localhost.evil.example/ws",
"ws://[email protected]/ws",
"ws://evil.example:80@localhost/ws",
"http://localhost:8787/ws",
"",
] {
assert_eq!(server_url(prod, Some(refused)).as_deref(), prod, "{refused}");
assert_eq!(server_url(None, Some(refused)), None, "{refused}");
}
}
#[tokio::test]
async fn the_server_goes_in_front_of_the_browser_sources() {
assert_eq!(crate::for_browser(false, None).name(), "binance+sample");
assert_eq!(crate::for_browser(false, Some("ws://127.0.0.1:9/ws")).name(), "server+binance+sample");
assert_eq!(crate::for_browser(true, Some("ws://127.0.0.1:9/ws")).name(), "sample");
}
}
+75 -19
View File
@@ -1,18 +1,45 @@
//! A text-message websocket client: tokio-tungstenite on the desktop, the browser's
//! `WebSocket` on the web.
//! A websocket client: tokio-tungstenite on the desktop, the browser's `WebSocket` on the web.
use anyhow::Result;
#[cfg(not(target_arch = "wasm32"))]
pub use native::Socket;
#[cfg(target_arch = "wasm32")]
pub use web::Socket;
/// A data message. Control frames stay inside the socket.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
Text(String),
Binary(Vec<u8>),
}
impl Socket {
pub async fn send(&mut self, text: String) -> Result<()> {
self.send_message(Message::Text(text)).await
}
/// The next text message, skipping binary ones; `None` once the server closes the connection.
pub async fn next(&mut self) -> Option<Result<String>> {
loop {
match self.next_message().await? {
Ok(Message::Text(t)) => return Some(Ok(t)),
Ok(Message::Binary(_)) => {}
Err(e) => return Some(Err(e)),
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use anyhow::{Context, Result};
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use super::Message;
pub struct Socket(WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>);
impl Socket {
@@ -21,22 +48,32 @@ mod native {
Ok(Self(ws))
}
pub async fn send(&mut self, text: String) -> Result<()> {
Ok(self.0.send(Message::Text(text.into())).await?)
pub async fn send_message(&mut self, m: Message) -> Result<()> {
let m = match m {
Message::Text(t) => WsMessage::Text(t.into()),
Message::Binary(b) => WsMessage::Binary(b.into()),
};
Ok(self.0.send(m).await?)
}
/// The next text message; `None` once the server closes the connection.
pub async fn next(&mut self) -> Option<Result<String>> {
/// The next data message; `None` once the server closes the connection.
pub async fn next_message(&mut self) -> Option<Result<Message>> {
while let Some(msg) = self.0.next().await {
match msg {
Ok(Message::Text(t)) => return Some(Ok(t.as_str().to_owned())),
Ok(Message::Close(_)) => return None,
Ok(WsMessage::Text(t)) => return Some(Ok(Message::Text(t.as_str().to_owned()))),
Ok(WsMessage::Binary(b)) => return Some(Ok(Message::Binary(b.into()))),
Ok(WsMessage::Close(_)) => return None,
Ok(_) => {}
Err(e) => return Some(Err(e.into())),
}
}
None
}
/// Send a close frame.
pub async fn close(&mut self) {
let _ = self.0.close(None).await;
}
}
}
@@ -46,11 +83,13 @@ mod web {
use tokio::sync::mpsc;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{CloseEvent, MessageEvent, WebSocket};
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
use super::Message;
enum Event {
Open,
Text(String),
Message(Message),
Error,
Closed,
}
@@ -68,6 +107,8 @@ mod web {
impl Socket {
pub async fn connect(url: &str) -> Result<Self> {
let ws = WebSocket::new(url).map_err(|e| anyhow!("websocket {url}: {e:?}"))?;
// Binary frames arrive as ArrayBuffers rather than Blobs, which can only be read asynchronously.
ws.set_binary_type(BinaryType::Arraybuffer);
let (tx, rx) = mpsc::unbounded_channel();
let t = tx.clone();
let on_open = Closure::<dyn FnMut()>::new(move || {
@@ -75,9 +116,15 @@ mod web {
});
let t = tx.clone();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() {
let _ = t.send(Event::Text(text));
}
let data = e.data();
let msg = if let Some(text) = data.as_string() {
Message::Text(text)
} else if let Some(buf) = data.dyn_ref::<js_sys::ArrayBuffer>() {
Message::Binary(js_sys::Uint8Array::new(buf).to_vec())
} else {
return;
};
let _ = t.send(Event::Message(msg));
});
let t = tx.clone();
let on_error = Closure::<dyn FnMut()>::new(move || {
@@ -98,18 +145,27 @@ mod web {
}
}
pub async fn send(&mut self, text: String) -> Result<()> {
self.ws.send_with_str(&text).map_err(|e| anyhow!("websocket send: {e:?}"))
pub async fn send_message(&mut self, m: Message) -> Result<()> {
match m {
Message::Text(t) => self.ws.send_with_str(&t),
Message::Binary(b) => self.ws.send_with_u8_array(&b),
}
.map_err(|e| anyhow!("websocket send: {e:?}"))
}
/// The next text message; `None` once the server closes the connection.
pub async fn next(&mut self) -> Option<Result<String>> {
/// The next data message; `None` once the server closes the connection.
pub async fn next_message(&mut self) -> Option<Result<Message>> {
match self.rx.recv().await? {
Event::Text(t) => Some(Ok(t)),
Event::Message(m) => Some(Ok(m)),
Event::Error => Some(Err(anyhow!("websocket error"))),
Event::Open | Event::Closed => None,
}
}
/// Send a close frame.
pub async fn close(&mut self) {
let _ = self.ws.close();
}
}
impl Drop for Socket {
+1 -1
View File
@@ -412,7 +412,7 @@ pub fn yahoo_symbol(sec: &Security) -> String {
Some((b, q)) => format!("{b}-{}", if q == "USDT" { "USD" } else { q }),
None => sec.ticker.clone(),
},
AssetClass::Govt | AssetClass::Cmdty => sec.ticker.clone(),
AssetClass::Govt | AssetClass::Cmdty | AssetClass::Perp => sec.ticker.clone(),
}
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "tr-hl"
description = "Hyperliquid order entry on testnet: L1 and user-signed signing, actions, asset ids, rounding, hard caps, info/exchange clients and the user stream"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
tracing.workspace = true
alloy-primitives = { version = "1", default-features = false, features = ["std", "k256"] }
alloy-sol-types = { version = "1", default-features = false, features = ["std", "eip712-serde"] }
k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"] }
rand_core = { version = "0.6", features = ["getrandom"] }
rmp-serde = "1"
zeroize = "1"
web-time = "1"
futures = "0.3"
# Key generation on the web reads the browser's crypto.getRandomValues.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]
serde = { workspace = true, features = ["derive"] }
# The built-in wallet's approval, end to end against Turnkey's local stand-in.
tr-turnkey = { workspace = true, features = ["mock"] }
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util"] }
tracing-subscriber.workspace = true
+247
View File
@@ -0,0 +1,247 @@
//! A live check of this crate's signatures against Hyperliquid **testnet**, with throwaway
//! keys. Never run in CI. It only uses the crate's public API, so every order goes through
//! the same caps as the app.
//!
//! ```text
//! # keys: a 0600 JSON file outside the repo, {"main": "0x…", "agent": "0x…"}
//! cargo run -p tr-hl --example testnet_check -- --generate /tmp/hl-throwaway/keys.json
//! cargo run -p tr-hl --example testnet_check -- /tmp/hl-throwaway/keys.json
//! ```
//!
//! It never prints a key or a signature, and masks every address (`0x1234…abcd`). Without
//! test USDC the account doesn't exist on Hyperliquid, so the expected answers are business
//! rejections. Hyperliquid reports a signature it can't match as "User or API Wallet 0x… does
//! not exist", where 0x… is the address it recovered: a match with the signer means the
//! signature is right. A deliberately corrupted signature is sent as the control.
use std::path::Path;
use serde_json::{Value, json};
use tr_hl::SigningKey;
use tr_hl::actions::{Tif, cloid};
use tr_hl::client::{Client, ExchangeResponse, SendError, recovered_address};
use tr_hl::key::{AgentKey, MainKey, mask_address};
use tr_hl::nonce::{NonceManager, now_ms};
use tr_hl::trader::*;
/// Replace every `0x` + 40 hex digits with its masked form.
fn mask(text: &str) -> String {
tr_hl::mask_addresses(text)
}
fn generate(path: &Path) -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
let (main, agent) = (AgentKey::generate(), AgentKey::generate());
let text = zeroize::Zeroizing::new(json!({"main": *main.to_hex(), "agent": *agent.to_hex()}).to_string());
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
f.write_all(text.as_bytes())?;
println!(
"wrote throwaway keys (0600) for main {} and agent {}",
mask_address(&main.address()),
mask_address(&agent.address())
);
Ok(())
}
struct Check {
client: Client,
out: Vec<String>,
}
impl Check {
fn say(&mut self, s: impl AsRef<str>) {
let line = mask(s.as_ref());
println!("{line}");
self.out.push(line);
}
async fn info(&mut self, label: &str, body: Value) -> Value {
match self.client.info(body).await {
Ok(v) => {
self.say(format!("info {label}: {v}"));
v
}
Err(e) => {
self.say(format!("info {label}: ERROR {e:#}"));
Value::Null
}
}
}
/// Send, and say whom Hyperliquid recovered if it names an address.
fn report(&mut self, label: &str, signer: &alloy_primitives::Address, r: &Result<ExchangeResponse, SendError>) {
match r {
Ok(resp) => {
self.say(format!("{label}: {}", resp.summary()));
if let Some(rec) = resp.first_error().and_then(recovered_address) {
let verdict = if &rec == signer { "MATCHES the signer" } else { "does NOT match the signer" };
self.say(format!(" recovered {} {verdict} {}", mask_address(&rec), mask_address(signer)));
}
}
Err(e) => self.say(format!("{label}: send error: {e}")),
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) == Some("--generate") {
return generate(Path::new(args.get(1).expect("path to the keys file")));
}
let path = args.first().expect("path to the keys file");
let keys: Value = serde_json::from_str(&zeroize::Zeroizing::new(std::fs::read_to_string(path)?))?;
let main = MainKey::from_hex(keys["main"].as_str().unwrap())?;
let agent = AgentKey::from_hex(keys["agent"].as_str().unwrap())?;
drop(keys);
let (m, a) = (main.address(), agent.address());
let mut c = Check { client: Client::new()?, out: Vec::new() };
c.say(format!("testnet check at {} UTC ms {}; main {m:#x}, agent {a:#x}", chrono_like(now_ms()), now_ms()));
// 1. Before approving.
c.info("userRole(main) before", json!({"type": "userRole", "user": format!("{m:#x}")})).await;
c.info("userRole(agent) before", json!({"type": "userRole", "user": format!("{a:#x}")})).await;
c.info("extraAgents(main) before", json!({"type": "extraAgents", "user": format!("{m:#x}")})).await;
// 2. approveAgent, signed by the main key.
let approve = approve_agent(&a, "check", DEFAULT_AGENT_DAYS, now_ms());
c.say(format!(
"approveAgent name {:?}, chain {}, signatureChainId {}",
approve.agent_name, approve.hyperliquid_chain, approve.signature_chain_id
));
let sig = tr_hl::signing::sign_approve_agent(&main, &approve)?;
let r = c.client.send_user_signed(&user_signed_body(&approve, &sig)).await;
c.report("approveAgent (main key)", &m, &r);
// 3. After.
c.info("userRole(main) after", json!({"type": "userRole", "user": format!("{m:#x}")})).await;
c.info("userRole(agent) after", json!({"type": "userRole", "user": format!("{a:#x}")})).await;
c.info("extraAgents(main) after", json!({"type": "extraAgents", "user": format!("{m:#x}")})).await;
// 4. Markets, leverage and testnet mids.
let reg = c.client.registry().await?;
c.say(format!(
"registry: {} markets; xyz is perpDexs index {:?}; BTC id {}; xyz:AAPL id {}",
reg.len(),
reg.dex_index("xyz"),
reg.resolve("BTC").map(|x| x.id).unwrap_or(0),
reg.resolve("xyz:AAPL").map(|x| x.id).unwrap_or(0)
));
let (agent_nonces, main_nonces) = (NonceManager::new(), NonceManager::new());
for coin in ["BTC", "xyz:AAPL"] {
let asset = reg.resolve(coin).map_err(anyhow::Error::msg)?.clone();
let dex = asset.dex.clone();
let mid = c.client.mids(&dex).await?.get(coin).copied();
let data = c.client.asset_data(&m, coin).await?;
let lev = data.as_ref().and_then(|d| d.leverage);
c.say(format!("{coin}: testnet mid {mid:?}, account leverage {lev:?}"));
// 5. Leverage to 3× (agent), as the ticket does before a first order.
let s = sign_update_leverage(&agent, &agent_nonces, &asset, !asset.only_isolated, 3, now_ms())?;
let r = c.client.send(&s).await;
c.report(&format!("updateLeverage {coin} 3× (agent)"), &a, &r);
let lev = c.client.asset_data(&m, coin).await?.and_then(|d| d.leverage);
c.say(format!("{coin}: account leverage now {lev:?}"));
// 6. A small limit far below the market, by the agent and by the main key.
let mid = mid.expect("testnet mid");
let px = mid * 0.6;
let size = 12.0 / px;
let intent = OrderIntent {
coin: coin.into(),
is_buy: true,
size,
kind: OrderKind::Limit { px, tif: Tif::Gtc },
reduce_only: false,
};
let ctx = MarketCtx { mid: Some(mid), leverage: lev };
for (who, signer) in [("agent", a), ("main key", m)] {
let id = cloid(now_ms() as u128);
match plan_order(&reg, &intent, &ctx, &id) {
Ok(plan) => {
c.say(format!(
"{coin} order plan: BUY {} @ {} (notional ${:.2}), cloid {}",
plan.size(),
plan.price(),
plan.notional(),
plan.cloid()
));
let s = if who == "agent" {
sign_order(&agent, &agent_nonces, &plan, &ctx, now_ms())?
} else {
sign_order(&main, &main_nonces, &plan, &ctx, now_ms())?
};
let r = c.client.send(&s).await;
c.report(&format!("order {coin} limit buy (signed by {who})"), &signer, &r);
}
Err(refusal) => {
c.say(format!("{coin} order (signed by {who}): REFUSED before signing: {refusal}"));
// A reduce-only sell far above the market passes the caps whatever the
// leverage, and still exercises the order signature.
let ro = OrderIntent {
is_buy: false,
reduce_only: true,
kind: OrderKind::Limit { px: mid * 1.4, tif: Tif::Gtc },
size: 12.0 / (mid * 1.4),
..intent.clone()
};
let plan = plan_order(&reg, &ro, &ctx, &id).map_err(|e| anyhow::anyhow!("{e}"))?;
c.say(format!(
"{coin} reduce-only plan: SELL {} @ {} (notional ${:.2})",
plan.size(),
plan.price(),
plan.notional()
));
let s = if who == "agent" {
sign_order(&agent, &agent_nonces, &plan, &ctx, now_ms())?
} else {
sign_order(&main, &main_nonces, &plan, &ctx, now_ms())?
};
let r = c.client.send(&s).await;
c.report(&format!("order {coin} reduce-only limit sell (signed by {who})"), &signer, &r);
}
}
}
}
// 7. The control: a damaged agent signature recovers someone else.
let noop = sign_noop(&agent, &agent_nonces, now_ms())?;
let r = c.client.send(&noop.corrupted()).await;
c.report("noop with a CORRUPTED agent signature (control)", &a, &r);
// 8. Cancel an order that doesn't exist, and a noop.
let btc = reg.resolve("BTC").map_err(anyhow::Error::msg)?.clone();
let s = sign_cancel(&agent, &agent_nonces, &btc, 1, now_ms())?;
let r = c.client.send(&s).await;
c.report("cancel BTC oid 1 (agent)", &a, &r);
let r = c.client.send(&sign_noop(&agent, &agent_nonces, now_ms())?).await;
c.report("noop (agent)", &a, &r);
let r = c.client.send(&sign_noop(&main, &main_nonces, now_ms())?).await;
c.report("noop (main key)", &m, &r);
if let Some(out) = args.get(1) {
std::fs::write(out, c.out.join("\n") + "\n")?;
}
Ok(())
}
/// `2026-11-03T01:02:03Z` without a date crate.
fn chrono_like(ms: u64) -> String {
let s = ms / 1000;
let (days, rem) = (s / 86_400, s % 86_400);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}
+276
View File
@@ -0,0 +1,276 @@
//! Exchange actions as Hyperliquid hashes them.
//!
//! L1 actions are msgpack-encoded with named fields **in declaration order**, and that order
//! is part of the signature: each struct below lists its fields exactly as the Python SDK
//! builds them. Optional fields are left out rather than sent as `null` or `false`
//! (Hyperliquid rejects a cancel hashed with `f: false`, for instance). There is no
//! `builder` field anywhere: this build never charges a builder fee.
use serde::{Deserialize, Serialize};
/// Limit time in force: `Gtc`, `Ioc` or `Alo` (post-only).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Tif {
Gtc,
Ioc,
Alo,
}
impl Tif {
pub fn label(self) -> &'static str {
match self {
Tif::Gtc => "GTC",
Tif::Ioc => "IOC",
Tif::Alo => "Post-only",
}
}
}
/// Trigger kind: take profit or stop loss.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Tpsl {
#[serde(rename = "tp")]
Tp,
#[serde(rename = "sl")]
Sl,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LimitWire {
pub tif: Tif,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TriggerWire {
pub is_market: bool,
pub trigger_px: String,
pub tpsl: Tpsl,
}
/// `{"limit": {...}}` or `{"trigger": {...}}`: one of the two, never both.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrderTypeWire {
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<LimitWire>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger: Option<TriggerWire>,
}
impl OrderTypeWire {
pub fn limit(tif: Tif) -> Self {
Self { limit: Some(LimitWire { tif }), trigger: None }
}
pub fn trigger(is_market: bool, trigger_px: String, tpsl: Tpsl) -> Self {
Self { limit: None, trigger: Some(TriggerWire { is_market, trigger_px, tpsl }) }
}
}
/// One order: asset, is buy, price, size, reduce only, type, client order id.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrderWire {
pub a: u32,
pub b: bool,
pub p: String,
pub s: String,
pub r: bool,
pub t: OrderTypeWire,
#[serde(skip_serializing_if = "Option::is_none")]
pub c: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrderAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub orders: Vec<OrderWire>,
pub grouping: &'static str,
}
impl OrderAction {
pub(crate) fn new(orders: Vec<OrderWire>) -> Self {
Self { kind: "order", orders, grouping: "na" }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CancelWire {
pub a: u32,
pub o: u64,
}
/// Cancel by order id. `f` (fast) is left out: hashed as `false` it is rejected, and `true`
/// is refused for trigger orders.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CancelAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub cancels: Vec<CancelWire>,
}
impl CancelAction {
pub fn new(cancels: Vec<CancelWire>) -> Self {
Self { kind: "cancel", cancels }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CancelByCloidWire {
pub asset: u32,
pub cloid: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CancelByCloidAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub cancels: Vec<CancelByCloidWire>,
}
impl CancelByCloidAction {
pub fn new(cancels: Vec<CancelByCloidWire>) -> Self {
Self { kind: "cancelByCloid", cancels }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ModifyAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub oid: u64,
pub order: OrderWire,
}
impl ModifyAction {
pub(crate) fn new(oid: u64, order: OrderWire) -> Self {
Self { kind: "modify", oid, order }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateLeverageAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub asset: u32,
pub is_cross: bool,
pub leverage: u32,
}
impl UpdateLeverageAction {
pub(crate) fn new(asset: u32, is_cross: bool, leverage: u32) -> Self {
Self { kind: "updateLeverage", asset, is_cross, leverage }
}
}
/// The dead man's switch: cancel every open order at `time` (ms), or clear the schedule.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ScheduleCancelAction {
#[serde(rename = "type")]
pub kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<u64>,
}
impl ScheduleCancelAction {
pub fn new(time: Option<u64>) -> Self {
Self { kind: "scheduleCancel", time }
}
}
/// Uses up a nonce without doing anything (invalidates an in-flight action).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NoopAction {
#[serde(rename = "type")]
pub kind: &'static str,
}
impl Default for NoopAction {
fn default() -> Self {
Self { kind: "noop" }
}
}
/// Approve an API wallet. A user-signed action (EIP-712 `HyperliquidTransaction:ApproveAgent`),
/// signed by the main wallet, so it is JSON on the wire and not msgpack-hashed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApproveAgentAction {
#[serde(rename = "type")]
pub kind: String,
pub hyperliquid_chain: String,
/// The chain the wallet signs on, as `0x…` hex: always `0x66eee` (testnet).
pub signature_chain_id: String,
/// Lower-case `0x…`.
pub agent_address: String,
/// `terminal-<device> valid_until <ms>`: always named, never the unnamed slot.
pub agent_name: String,
pub nonce: u64,
}
impl ApproveAgentAction {
pub fn chain_id(&self) -> anyhow::Result<u64> {
let hex = self.signature_chain_id.trim_start_matches("0x");
Ok(u64::from_str_radix(hex, 16)?)
}
}
/// A 128-bit client order id, `0x` and 32 hex digits.
pub fn cloid(n: u128) -> String {
format!("0x{n:032x}")
}
/// Whether `s` is a well-formed client order id.
pub fn valid_cloid(s: &str) -> bool {
s.len() == 34 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_shapes_match_the_exchange_docs() {
let o = OrderWire {
a: 3,
b: true,
p: "50000".into(),
s: "0.0002".into(),
r: false,
t: OrderTypeWire::limit(Tif::Gtc),
c: Some(cloid(1)),
};
let json = serde_json::to_string(&OrderAction::new(vec![o.clone()])).unwrap();
assert_eq!(
json,
r#"{"type":"order","orders":[{"a":3,"b":true,"p":"50000","s":"0.0002","r":false,"t":{"limit":{"tif":"Gtc"}},"c":"0x00000000000000000000000000000001"}],"grouping":"na"}"#
);
assert!(!json.contains("builder"), "no builder fee is ever sent");
let trig = OrderTypeWire::trigger(true, "103".into(), Tpsl::Sl);
assert_eq!(
serde_json::to_string(&trig).unwrap(),
r#"{"trigger":{"isMarket":true,"triggerPx":"103","tpsl":"sl"}}"#
);
let cancel = serde_json::to_string(&CancelAction::new(vec![CancelWire { a: 3, o: 42 }])).unwrap();
assert_eq!(cancel, r#"{"type":"cancel","cancels":[{"a":3,"o":42}]}"#, "no `f` field");
let by = CancelByCloidAction::new(vec![CancelByCloidWire { asset: 750009, cloid: cloid(7) }]);
assert_eq!(
serde_json::to_string(&by).unwrap(),
r#"{"type":"cancelByCloid","cancels":[{"asset":750009,"cloid":"0x00000000000000000000000000000007"}]}"#
);
let lev = serde_json::to_string(&UpdateLeverageAction::new(3, true, 3)).unwrap();
assert_eq!(lev, r#"{"type":"updateLeverage","asset":3,"isCross":true,"leverage":3}"#);
assert_eq!(serde_json::to_string(&ScheduleCancelAction::new(None)).unwrap(), r#"{"type":"scheduleCancel"}"#);
assert_eq!(serde_json::to_string(&NoopAction::default()).unwrap(), r#"{"type":"noop"}"#);
let m = serde_json::to_string(&ModifyAction::new(9, o)).unwrap();
assert!(m.starts_with(r#"{"type":"modify","oid":9,"order":{"a":3,"#), "{m}");
}
#[test]
fn cloids_are_128_bit_hex() {
assert_eq!(cloid(1), "0x00000000000000000000000000000001");
assert!(valid_cloid(&cloid(u128::MAX)));
assert!(!valid_cloid("0x1") && !valid_cloid(&format!("0x{}", "g".repeat(32))));
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Asset ids and trading rules per market, read from the network every session.
//!
//! - A core perp's id is its index in `meta.universe`.
//! - A HIP-3 perp's id is `100000 + dex_index × 10000 + index`, where `dex_index` is the dex's
//! position in `perpDexs` (index 0 is the core dex) and `index` its place in `meta{dex}`.
//!
//! Ids differ between networks and change as dexes are deployed, so none is hard-coded
//! (testnet's `xyz` is dex 65 today, mainnet's is 1). This build trades the core dex and the
//! `xyz` HIP-3 dex only: `para`, `io`, `mkts`, other dexes and spot are refused with a reason.
use std::collections::HashMap;
use anyhow::{Context, Result, bail};
use serde_json::Value;
/// HIP-3 dexes this build trades.
pub const SUPPORTED_DEXES: [&str; 1] = ["xyz"];
/// HIP-3 dexes that exist on mainnet but are refused in this build.
pub const REFUSED_DEXES: [&str; 3] = ["para", "io", "mkts"];
/// One perp market.
#[derive(Debug, Clone, PartialEq)]
pub struct Asset {
/// Exactly as Hyperliquid names it: `BTC`, `kPEPE`, `xyz:AAPL`.
pub coin: String,
pub id: u32,
/// `""` for the core dex, else the HIP-3 dex (`xyz`).
pub dex: String,
pub sz_decimals: u32,
pub max_leverage: u32,
/// Isolated margin only (no cross).
pub only_isolated: bool,
pub delisted: bool,
}
impl Asset {
pub fn is_hip3(&self) -> bool {
!self.dex.is_empty()
}
/// `PERP · HL xyz` for HIP-3, `PERP · HL` for the core dex.
pub fn label(&self) -> String {
if self.is_hip3() { format!("PERP · HL {}", self.dex) } else { "PERP · HL".into() }
}
/// The terminal's market key for charts and quotes: `xyz:AAPL` for a HIP-3 perp, the
/// crypto pair (`BTCUSD Curncy`) for a core perp.
pub fn app_key(&self) -> String {
app_key(&self.coin)
}
}
/// [`Asset::app_key`] for a coin name.
pub fn app_key(coin: &str) -> String {
if coin.contains(':') { coin.to_string() } else { format!("{}USD Curncy", coin.to_ascii_uppercase()) }
}
/// Why a market can't be traded, in words for the ticket.
pub fn refusal(coin: &str) -> Option<String> {
let c = coin.trim();
if c.contains('/') || c.starts_with('@') {
return Some(format!("{c} is a spot market. This build trades Hyperliquid perps only (no spot)."));
}
if let Some((dex, _)) = c.split_once(':') {
let dex = dex.to_ascii_lowercase();
if REFUSED_DEXES.contains(&dex.as_str()) {
return Some(format!(
"{c} is on the {dex} HIP-3 dex. This build trades the core Hyperliquid perps and the xyz dex only."
));
}
if !SUPPORTED_DEXES.contains(&dex.as_str()) {
return Some(format!("{c}: this build trades the core Hyperliquid perps and the xyz dex only."));
}
}
None
}
/// Every tradable perp on the core and `xyz` dexes, by name.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AssetRegistry {
by_name: HashMap<String, Asset>,
/// `(perpDexs index, name)` of the dexes loaded, core first.
dexes: Vec<(usize, String)>,
}
impl AssetRegistry {
/// Build from `perpDexs`, the core `meta`, and `meta{dex}` for each supported dex.
pub fn from_info(perp_dexs: &Value, core_meta: &Value, dex_metas: &[(&str, &Value)]) -> Result<Self> {
let dexes = perp_dexs.as_array().context("perpDexs is not a list")?;
let mut reg = Self::default();
reg.add(0, "", core_meta)?;
for (name, meta) in dex_metas {
if !SUPPORTED_DEXES.contains(name) {
bail!("the {name} dex is not supported in this build");
}
let index = dexes
.iter()
.position(|d| d.get("name").and_then(Value::as_str) == Some(*name))
.with_context(|| format!("perpDexs has no {name} dex"))?;
reg.add(index, name, meta)?;
}
Ok(reg)
}
fn add(&mut self, dex_index: usize, dex: &str, meta: &Value) -> Result<()> {
let universe = meta.get("universe").and_then(Value::as_array).context("meta has no universe")?;
for (i, a) in universe.iter().enumerate() {
let Some(coin) = a.get("name").and_then(Value::as_str) else { continue };
let id = if dex.is_empty() { i } else { 100_000 + dex_index * 10_000 + i };
let int = |k: &str| a.get(k).and_then(Value::as_u64).unwrap_or(0) as u32;
let asset = Asset {
coin: coin.to_string(),
id: u32::try_from(id).context("asset id out of range")?,
dex: dex.to_string(),
sz_decimals: int("szDecimals"),
max_leverage: int("maxLeverage").max(1),
only_isolated: a.get("onlyIsolated").and_then(Value::as_bool).unwrap_or(false),
delisted: a.get("isDelisted").and_then(Value::as_bool).unwrap_or(false),
};
self.by_name.insert(coin.to_ascii_lowercase(), asset);
}
self.dexes.push((dex_index, dex.to_string()));
Ok(())
}
/// The market for `coin` (any case: `btc`, `XYZ:aapl`), or why it can't be traded.
pub fn resolve(&self, coin: &str) -> Result<&Asset, String> {
let c = coin.trim();
if c.is_empty() {
return Err("Enter a market (BTC, xyz:AAPL).".into());
}
if let Some(why) = refusal(c) {
return Err(why);
}
match self.by_name.get(&c.to_ascii_lowercase()) {
Some(a) if a.delisted => Err(format!("{} is delisted on Hyperliquid.", a.coin)),
Some(a) => Ok(a),
None => {
// `AAPL` on Hyperliquid is the xyz perp, never the stock.
let hip3 = format!("xyz:{c}").to_ascii_lowercase();
match self.by_name.get(&hip3).filter(|a| !a.delisted) {
Some(a) => Err(format!(
"{} is not a core Hyperliquid market. Hyperliquid has the perp {} ({}). Trade that instead?",
c.to_ascii_uppercase(),
a.coin,
a.label()
)),
None => Err(format!("{c} is not a Hyperliquid perp on testnet.")),
}
}
}
}
pub fn get(&self, coin: &str) -> Option<&Asset> {
self.by_name.get(&coin.trim().to_ascii_lowercase())
}
/// The loaded dexes, core (`""`) first.
pub fn dexes(&self) -> impl Iterator<Item = &str> {
self.dexes.iter().map(|(_, d)| d.as_str())
}
/// A dex's position in `perpDexs`.
pub fn dex_index(&self, dex: &str) -> Option<usize> {
self.dexes.iter().find(|(_, d)| d == dex).map(|(i, _)| *i)
}
pub fn len(&self) -> usize {
self.by_name.len()
}
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
pub fn assets(&self) -> impl Iterator<Item = &Asset> {
self.by_name.values()
}
}
#[cfg(test)]
pub(crate) mod fixtures {
use super::*;
pub fn json(name: &str) -> Value {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
/// The registry from testnet responses recorded on 2026-11-02.
pub fn registry() -> AssetRegistry {
AssetRegistry::from_info(
&json("testnet_perp_dexs.json"),
&json("testnet_meta_core.json"),
&[("xyz", &json("testnet_meta_xyz.json"))],
)
.unwrap()
}
}
#[cfg(test)]
mod tests {
use super::fixtures::*;
use super::*;
#[test]
fn ids_come_from_perp_dexs_and_meta() {
let r = registry();
assert_eq!(r.dex_index("xyz"), Some(65), "xyz is dex 65 on testnet (recorded)");
let btc = r.resolve("btc").unwrap();
assert_eq!((btc.id, btc.sz_decimals, btc.max_leverage, btc.dex.as_str()), (3, 5, 40, ""));
let aapl = r.resolve("XYZ:aapl").unwrap();
assert_eq!((aapl.id, aapl.sz_decimals, aapl.coin.as_str()), (750009, 3, "xyz:AAPL"));
assert_eq!(aapl.label(), "PERP · HL xyz");
assert_eq!(aapl.app_key(), "xyz:AAPL");
assert_eq!(btc.app_key(), "BTCUSD Curncy");
assert!(r.resolve("xyz:COIN").unwrap().only_isolated, "isolated-only HIP-3 markets are flagged");
assert_eq!(r.dexes().collect::<Vec<_>>(), ["", "xyz"]);
}
#[test]
fn the_same_market_gets_another_id_on_another_dex_index() {
// perpDexs with xyz at index 1, as on mainnet: ids move with it.
let dexes = serde_json::json!([null, {"name": "xyz"}]);
let r = AssetRegistry::from_info(
&dexes,
&json("testnet_meta_core.json"),
&[("xyz", &json("testnet_meta_xyz.json"))],
)
.unwrap();
assert_eq!(r.resolve("xyz:AAPL").unwrap().id, 110009);
}
#[test]
fn unsupported_markets_are_refused_with_a_reason() {
let r = registry();
for (coin, says) in [
("para:TOTAL2", "para HIP-3 dex"),
("io:OAI", "io HIP-3 dex"),
("mkts:US500", "mkts HIP-3 dex"),
("abc:XYZ", "xyz dex only"),
("HYPE/USDC", "spot"),
("@107", "spot"),
("AAPL", "Hyperliquid has the perp xyz:AAPL (PERP · HL xyz)"),
("NOPE", "not a Hyperliquid perp"),
] {
let e = r.resolve(coin).unwrap_err();
assert!(e.contains(says), "{coin}: {e}");
}
let delisted = r.assets().find(|a| a.delisted).unwrap().coin.clone();
assert!(r.resolve(&delisted).unwrap_err().contains("delisted"));
assert!(
AssetRegistry::from_info(
&json("testnet_perp_dexs.json"),
&json("testnet_meta_core.json"),
&[("para", &json("testnet_meta_xyz.json"))]
)
.is_err()
);
}
}
+83
View File
@@ -0,0 +1,83 @@
//! Hard limits, checked in this crate before anything is signed (the ticket checks them too,
//! but the ticket is not the safety): no order over $100 notional, no leverage above 3×, no
//! market order more than 1% from the mid. They are constants, not settings: nothing in this
//! build can raise them.
use std::fmt;
/// The most an order may be worth: size × the higher of its price and the mid.
pub const MAX_ORDER_NOTIONAL_USD: f64 = 100.0;
/// The most leverage Terminal sets, or places an order under.
pub const MAX_LEVERAGE: u32 = 3;
/// A market order is an IOC limit this far from the mid, at most.
pub const MAX_MARKET_SLIPPAGE: f64 = 0.01;
/// Why an action was refused before signing.
#[derive(Debug, Clone, PartialEq)]
pub enum Refusal {
Notional {
value: f64,
},
Leverage {
value: u32,
},
/// The account's leverage on this market isn't known yet, so an order can't be checked.
LeverageUnknown {
coin: String,
},
Slippage {
pct: f64,
},
/// No mid price, which a market order is priced from.
NoMid {
coin: String,
},
/// Anything else (market not tradable, bad size or price).
Invalid(String),
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Refusal::Notional { value } => write!(
f,
"Order value ${value:.2} is over the ${MAX_ORDER_NOTIONAL_USD:.0} limit for this testnet build."
),
Refusal::Leverage { value } => write!(
f,
"Leverage {value}× is over the {MAX_LEVERAGE}× limit for this testnet build. Set {MAX_LEVERAGE}× or less first."
),
Refusal::LeverageUnknown { coin } => {
write!(f, "Leverage on {coin} isn't known yet; wait for the account to load.")
}
Refusal::Slippage { pct } => {
write!(f, "Slippage {pct:.2}% is over the {:.0}% limit.", MAX_MARKET_SLIPPAGE * 100.0)
}
Refusal::NoMid { coin } => write!(f, "No testnet price for {coin} yet; use a limit order or wait."),
Refusal::Invalid(m) => f.write_str(m),
}
}
}
impl std::error::Error for Refusal {}
pub fn check_notional(value: f64) -> Result<(), Refusal> {
if !value.is_finite() || value > MAX_ORDER_NOTIONAL_USD + 1e-9 {
return Err(Refusal::Notional { value });
}
Ok(())
}
pub fn check_leverage(value: u32) -> Result<(), Refusal> {
if value > MAX_LEVERAGE {
return Err(Refusal::Leverage { value });
}
Ok(())
}
pub fn check_slippage(fraction: f64) -> Result<(), Refusal> {
if !fraction.is_finite() || fraction < 0.0 || fraction > MAX_MARKET_SLIPPAGE + 1e-12 {
return Err(Refusal::Slippage { pct: fraction * 100.0 });
}
Ok(())
}
+386
View File
@@ -0,0 +1,386 @@
//! `/info` and `/exchange` on testnet, over reqwest (native, and `fetch` in the browser).
//!
//! The client has no base-URL setting: it always talks to [`crate::network::API_URL`]. (Unit
//! tests point it at a local mock server through a `cfg(test)` constructor that the app
//! can't call.) Logs carry the action type, nonce, cloid, oid and status, never a request
//! body or a signature.
use alloy_primitives::Address;
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
use crate::assets::{AssetRegistry, SUPPORTED_DEXES};
use crate::key::address_hex;
use crate::model::*;
use crate::network::Network;
use crate::trader::Signed;
/// One order's or cancel's result.
#[derive(Debug, Clone, PartialEq)]
pub enum Status {
Resting {
oid: u64,
},
Filled {
oid: u64,
total_sz: f64,
avg_px: f64,
},
/// A cancel, modify or other action that went through.
Success,
Error(String),
Other(String),
}
/// What `/exchange` said.
#[derive(Debug, Clone, PartialEq)]
pub struct ExchangeResponse {
/// `status: "ok"`. Individual orders can still have failed: see `statuses`.
pub ok: bool,
/// `order`, `cancel`, `default`, …
pub kind: Option<String>,
pub statuses: Vec<Status>,
/// The whole request was refused (`status: "err"`).
pub error: Option<String>,
}
impl ExchangeResponse {
pub fn parse(v: &Value) -> Self {
let ok = v["status"].as_str() == Some("ok");
let error = (!ok).then(|| match &v["response"] {
Value::String(s) => s.clone(),
other => other.to_string(),
});
let statuses = v["response"]["data"]["statuses"]
.as_array()
.into_iter()
.flatten()
.map(|s| {
if s.as_str() == Some("success") {
Status::Success
} else if let Some(e) = s["error"].as_str() {
Status::Error(e.to_string())
} else if let Some(oid) = s["resting"]["oid"].as_u64() {
Status::Resting { oid }
} else if let Some(oid) = s["filled"]["oid"].as_u64() {
Status::Filled {
oid,
total_sz: num(&s["filled"]["totalSz"]).unwrap_or(0.0),
avg_px: num(&s["filled"]["avgPx"]).unwrap_or(0.0),
}
} else {
Status::Other(s.to_string())
}
})
.collect();
Self { ok, kind: v["response"]["type"].as_str().map(str::to_string), statuses, error }
}
/// The first error, whether for the request or for an order in it.
pub fn first_error(&self) -> Option<&str> {
self.error.as_deref().or_else(|| {
self.statuses.iter().find_map(|s| match s {
Status::Error(e) => Some(e.as_str()),
_ => None,
})
})
}
/// One line for a toast or a log, addresses masked: `ok (order): resting oid 123`, `err: …`.
pub fn summary(&self) -> String {
crate::key::mask_addresses(&self.summary_raw())
}
fn summary_raw(&self) -> String {
if let Some(e) = &self.error {
return format!("err: {e}");
}
let parts: Vec<String> = self
.statuses
.iter()
.map(|s| match s {
Status::Resting { oid } => format!("resting oid {oid}"),
Status::Filled { oid, total_sz, avg_px } => format!("filled {total_sz} @ {avg_px} (oid {oid})"),
Status::Success => "success".into(),
Status::Error(e) => format!("error: {e}"),
Status::Other(o) => o.clone(),
})
.collect();
format!(
"ok ({}){}",
self.kind.as_deref().unwrap_or("?"),
if parts.is_empty() { String::new() } else { format!(": {}", parts.join("; ")) }
)
}
}
/// `orderStatus`: the order's status, or `None` for `unknownOid`.
pub fn parse_order_status(v: &Value) -> Option<String> {
match v["status"].as_str() {
Some("order") => Some(v["order"]["status"].as_str().unwrap_or("unknown").to_string()),
_ => None,
}
}
/// The address Hyperliquid recovered from a signature it doesn't recognise: "User or API
/// Wallet 0x… does not exist." A match with the signer means the signature is right and the
/// account is just unknown (never funded); a mismatch means the signing is wrong.
pub fn recovered_address(error: &str) -> Option<Address> {
let i = error.find("0x")?;
crate::key::parse_address(error.get(i..i + 42)?).ok()
}
/// Why a send did not clearly succeed.
#[derive(Debug, Clone, PartialEq)]
pub enum SendError {
/// Nothing reached Hyperliquid (couldn't connect).
NotSent(String),
/// Hyperliquid answered with an HTTP error: nothing was done.
Rejected { status: u16, body: String },
/// It may have arrived: check open orders (by cloid) before sending again.
Uncertain(String),
}
impl std::fmt::Display for SendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SendError::NotSent(m) => write!(f, "not sent: {m}"),
SendError::Rejected { status, body } => write!(f, "rejected (HTTP {status}): {body}"),
SendError::Uncertain(m) => write!(f, "status unknown: {m}"),
}
}
}
impl std::error::Error for SendError {}
/// The longest any one request may take, on the desktop and in the browser.
pub const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
info_url: String,
exchange_url: String,
timeout: std::time::Duration,
}
impl Client {
pub fn new() -> Result<Self> {
Self::with_urls(Network::Testnet.info_url(), Network::Testnet.exchange_url())
}
/// Unit tests only: a local mock server.
#[cfg(test)]
pub(crate) fn mock(base: &str) -> Result<Self> {
Self::with_urls(format!("{base}/info"), format!("{base}/exchange"))
}
fn with_urls(info_url: String, exchange_url: String) -> Result<Self> {
let b = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let b = b.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")));
Ok(Self { http: b.build()?, info_url, exchange_url, timeout: REQUEST_TIMEOUT })
}
/// Every request has its own deadline. The browser's `fetch` has no client-wide timeout,
/// so a request that never got an answer would otherwise wait forever.
fn post(&self, url: &str) -> reqwest::RequestBuilder {
self.http.post(url).timeout(self.timeout)
}
/// Any `/info` query.
pub async fn info(&self, body: Value) -> Result<Value> {
let kind = body["type"].as_str().unwrap_or("?").to_string();
let r = self.post(&self.info_url).json(&body).send().await.with_context(|| format!("info {kind}"))?;
let status = r.status();
let text = r.text().await.with_context(|| format!("info {kind}"))?;
if !status.is_success() {
return Err(anyhow!("info {kind}: HTTP {status}: {}", text.chars().take(200).collect::<String>()));
}
serde_json::from_str(&text).with_context(|| format!("info {kind}: not JSON"))
}
/// The asset ids and rules for the core and `xyz` dexes, read fresh.
pub async fn registry(&self) -> Result<AssetRegistry> {
let (dexes, core) = futures::join!(self.info(json!({"type": "perpDexs"})), self.info(json!({"type": "meta"})));
let (dexes, core) = (dexes?, core?);
let mut metas = Vec::new();
for dex in SUPPORTED_DEXES {
metas.push((dex, self.info(json!({"type": "meta", "dex": dex})).await?));
}
let refs: Vec<(&str, &Value)> = metas.iter().map(|(d, m)| (*d, m)).collect();
AssetRegistry::from_info(&dexes, &core, &refs)
}
pub async fn clearinghouse(&self, user: &Address, dex: &str) -> Result<Clearinghouse> {
let v = self.info(json!({"type": "clearinghouseState", "user": address_hex(user), "dex": dex})).await?;
Ok(parse_clearinghouse(dex, &v))
}
/// The account's spot USDC (`spotClearinghouseState`), for the Deposit window.
pub async fn spot_usdc(&self, user: &Address) -> Result<crate::model::SpotUsdc> {
let v = self.info(json!({"type": "spotClearinghouseState", "user": address_hex(user)})).await?;
Ok(crate::model::spot_usdc(&v))
}
pub async fn open_orders(&self, user: &Address, dex: &str) -> Result<Vec<OpenOrder>> {
let v = self.info(json!({"type": "frontendOpenOrders", "user": address_hex(user), "dex": dex})).await?;
Ok(parse_open_orders(&v))
}
pub async fn asset_data(&self, user: &Address, coin: &str) -> Result<Option<AssetData>> {
let v = self.info(json!({"type": "activeAssetData", "user": address_hex(user), "coin": coin})).await?;
Ok(parse_asset_data(&v))
}
pub async fn mids(&self, dex: &str) -> Result<std::collections::HashMap<String, f64>> {
let v = self.info(json!({"type": "allMids", "dex": dex})).await?;
Ok(v.as_object().into_iter().flatten().filter_map(|(k, p)| Some((k.clone(), num(p)?))).collect())
}
/// An order's status by client order id: `Some("open" | "filled" | "canceled" | …)`, or
/// `None` when Hyperliquid has never seen it (`unknownOid`).
pub async fn order_status(&self, user: &Address, cloid: &str) -> Result<Option<String>> {
let v = self.info(json!({"type": "orderStatus", "user": address_hex(user), "oid": cloid})).await?;
Ok(parse_order_status(&v))
}
/// `missing`, `user`, `agent`, `vault` or `subAccount`.
pub async fn user_role(&self, user: &Address) -> Result<String> {
let v = self.info(json!({"type": "userRole", "user": address_hex(user)})).await?;
Ok(v["role"].as_str().unwrap_or("unknown").to_string())
}
pub async fn extra_agents(&self, user: &Address) -> Result<Vec<ExtraAgent>> {
Ok(parse_extra_agents(&self.info(json!({"type": "extraAgents", "user": address_hex(user)})).await?))
}
/// Post a signed L1 action.
pub async fn send(&self, signed: &Signed) -> Result<ExchangeResponse, SendError> {
tracing::info!(kind = signed.kind, nonce = signed.nonce, "hyperliquid testnet: sending");
let r = self.post_exchange(&signed.body()).await;
match &r {
Ok(resp) => {
tracing::info!(kind = signed.kind, nonce = signed.nonce, "hyperliquid testnet: {}", resp.summary())
}
Err(e) => tracing::warn!(
kind = signed.kind,
nonce = signed.nonce,
"hyperliquid testnet: {}",
crate::key::mask_addresses(&e.to_string())
),
}
r
}
/// Post a user-signed action body (`approveAgent`).
pub async fn send_user_signed(&self, body: &Value) -> Result<ExchangeResponse, SendError> {
let kind = body["action"]["type"].as_str().unwrap_or("?").to_string();
tracing::info!(kind, "hyperliquid testnet: sending");
let r = self.post_exchange(body).await;
if let Ok(resp) = &r {
tracing::info!(kind, "hyperliquid testnet: {}", resp.summary());
}
r
}
async fn post_exchange(&self, body: &Value) -> Result<ExchangeResponse, SendError> {
let r = self.post(&self.exchange_url).json(body).send().await.map_err(classify)?;
let status = r.status().as_u16();
let text = r.text().await.map_err(|e| SendError::Uncertain(e.to_string()))?;
if (400..500).contains(&status) {
return Err(SendError::Rejected { status, body: text.chars().take(300).collect() });
}
if status >= 500 {
return Err(SendError::Uncertain(format!("HTTP {status}")));
}
let v: Value =
serde_json::from_str(&text).map_err(|_| SendError::Uncertain(format!("unreadable answer: {text}")))?;
Ok(ExchangeResponse::parse(&v))
}
}
/// A failure before any answer: certainly not sent only if the connection never opened.
/// The browser's `fetch` doesn't say, so there every such failure is uncertain.
fn classify(e: reqwest::Error) -> SendError {
#[cfg(not(target_arch = "wasm32"))]
if e.is_connect() || e.is_builder() {
return SendError::NotSent(e.to_string());
}
SendError::Uncertain(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// Greptile #37 (f636b7c): the browser's `fetch` has no client-wide timeout, so a request
/// that never got an answer held the worker's turn and KILL waited behind it. Each request
/// now carries its own deadline, which reqwest honours on both targets.
#[test]
fn every_request_carries_its_own_deadline() {
let c = Client::new().unwrap();
for url in [&c.info_url, &c.exchange_url] {
let req = c.post(url).build().unwrap();
assert_eq!(req.timeout(), Some(&REQUEST_TIMEOUT), "{url}");
}
}
/// Against a server that takes the request and never answers, the send ends at the deadline
/// as uncertain, since it may have arrived, and a read ends with an error.
#[tokio::test]
async fn a_request_that_gets_no_answer_ends_at_its_deadline() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((sock, _)) = listener.accept().await {
held.push(sock); // read nothing, answer nothing
}
});
let c = Client { timeout: std::time::Duration::from_millis(300), ..Client::mock(&base).unwrap() };
let t0 = std::time::Instant::now();
let sent = c.post_exchange(&json!({"action": {"type": "noop"}})).await;
assert!(matches!(sent, Err(SendError::Uncertain(_))), "{sent:?}");
assert!(c.user_role(&Address::ZERO).await.is_err());
let took = t0.elapsed();
assert!(took >= std::time::Duration::from_millis(600) && took < std::time::Duration::from_secs(3), "{took:?}");
}
#[test]
fn responses_parse() {
let resting = ExchangeResponse::parse(
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77738308}}]}}}),
);
assert_eq!(resting.statuses, [Status::Resting { oid: 77738308 }]);
assert_eq!(resting.summary(), "ok (order): resting oid 77738308");
let min = ExchangeResponse::parse(
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"error": "Order must have minimum value of $10."}]}}}),
);
assert!(min.ok && min.first_error() == Some("Order must have minimum value of $10."));
let filled = ExchangeResponse::parse(
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"filled": {"totalSz": "0.02", "avgPx": "1891.4", "oid": 77747314}}]}}}),
);
assert_eq!(filled.statuses, [Status::Filled { oid: 77747314, total_sz: 0.02, avg_px: 1891.4 }]);
let cancel = ExchangeResponse::parse(
&json!({"status": "ok", "response": {"type": "cancel", "data": {"statuses": ["success"]}}}),
);
assert_eq!(cancel.statuses, [Status::Success]);
let err = ExchangeResponse::parse(
&json!({"status": "err", "response": "User or API Wallet 0x5e9ee1089755c3435139848e47e6635505d5a13a does not exist."}),
);
assert!(!err.ok);
assert_eq!(
recovered_address(err.first_error().unwrap()).map(|a| address_hex(&a)).as_deref(),
Some("0x5e9ee1089755c3435139848e47e6635505d5a13a")
);
assert_eq!(
ExchangeResponse::parse(&json!({"status": "ok", "response": {"type": "default"}})).summary(),
"ok (default)"
);
assert_eq!(recovered_address("Insufficient margin"), None);
let found = json!({"status": "order", "order": {"order": {"coin": "BTC", "oid": 1}, "status": "filled", "statusTimestamp": 1}});
assert_eq!(parse_order_status(&found).as_deref(), Some("filled"));
assert_eq!(parse_order_status(&json!({"status": "unknownOid"})), None);
}
}
+230
View File
@@ -0,0 +1,230 @@
//! Private keys that sign: the agent (API wallet) key Terminal generates and keeps, and a
//! main-wallet key for tests and the developer-only `testnet_check` example. The app never
//! holds a main-wallet key: its main wallet is the built-in Turnkey wallet.
//!
//! Neither type is `Clone`, `Serialize` or printable: `Debug` and `Display` show only the
//! masked address. The secret scalar lives in a k256 `SigningKey`, which zeroizes itself on
//! drop, and the only way out of an [`AgentKey`] is [`AgentKey::to_hex`] for the OS secret
//! store, which hands back a buffer that is zeroized when dropped. [`MainKey`] has no way
//! out at all.
use std::fmt;
use alloy_primitives::{Address, B256};
use anyhow::{Result, bail};
use k256::ecdsa::SigningKey as EcdsaKey;
use zeroize::Zeroizing;
mod sealed {
pub trait Sealed {
fn signer(&self) -> &k256::ecdsa::SigningKey;
}
}
/// A key this crate can sign with. Sealed: only [`AgentKey`] and [`MainKey`].
pub trait SigningKey: sealed::Sealed {
fn address(&self) -> Address {
Address::from_private_key(self.signer())
}
}
/// Sign a 32-byte hash (low-s, with the recovery parity), as Ethereum wallets do.
pub(crate) fn sign_hash(key: &impl SigningKey, hash: &B256) -> anyhow::Result<alloy_primitives::Signature> {
let (sig, recid) = key.signer().sign_prehash_recoverable(hash.as_slice())?;
let (r, s) = sig.split_bytes();
Ok(alloy_primitives::Signature::new(
alloy_primitives::U256::from_be_slice(&r),
alloy_primitives::U256::from_be_slice(&s),
recid.is_y_odd(),
))
}
fn parse(hex: &str) -> Result<EcdsaKey> {
let s = Zeroizing::new(hex.trim().trim_start_matches("0x").to_string());
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
// Never echo the input: it is a secret, or a typo of one.
bail!("a private key is 64 hex digits");
}
let mut bytes = Zeroizing::new([0u8; 32]);
for (i, b) in bytes.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16)?;
}
EcdsaKey::from_slice(bytes.as_slice()).map_err(|_| anyhow::anyhow!("not a valid secp256k1 private key"))
}
/// The API wallet key that signs orders. Generated on this device; approved once by the
/// main wallet; stored only in the OS secret store (or browser memory).
pub struct AgentKey(EcdsaKey);
impl AgentKey {
/// A fresh random key. Never reuse an agent address: rotation always generates one.
pub fn generate() -> Self {
Self(EcdsaKey::random(&mut rand_core::OsRng))
}
/// Load a key the secret store kept (`0x`-prefixed or bare hex).
pub fn from_hex(hex: &str) -> Result<Self> {
parse(hex).map(Self)
}
/// The hex form for the OS secret store. The buffer is wiped when dropped; never log it.
pub fn to_hex(&self) -> Zeroizing<String> {
let bytes = Zeroizing::new(<[u8; 32]>::from(self.0.to_bytes()));
let mut s = Zeroizing::new(String::with_capacity(66));
s.push_str("0x");
for b in bytes.iter() {
s.push_str(&format!("{b:02x}"));
}
s
}
}
impl sealed::Sealed for AgentKey {
fn signer(&self) -> &EcdsaKey {
&self.0
}
}
impl SigningKey for AgentKey {}
/// A main-wallet key for tests and the `testnet_check` example (throwaway testnet keys from
/// a 0600 file). Nothing in the app makes one. It cannot be exported or stored.
pub struct MainKey(EcdsaKey);
impl MainKey {
pub fn from_hex(hex: &str) -> Result<Self> {
parse(hex).map(Self)
}
/// A fresh random key (tests and the testnet check only).
pub fn generate() -> Self {
Self(EcdsaKey::random(&mut rand_core::OsRng))
}
}
impl sealed::Sealed for MainKey {
fn signer(&self) -> &EcdsaKey {
&self.0
}
}
impl SigningKey for MainKey {}
/// `0x1234…abcd`: enough to recognise an address without printing it in full.
pub fn mask_address(a: &Address) -> String {
let s = format!("{a:#x}");
format!("{}…{}", &s[..6], &s[s.len() - 4..])
}
/// `text` with every `0x` + 40 hex digits masked (`0x1234…abcd`), for anything shown or
/// logged: Hyperliquid's errors name full addresses.
pub fn mask_addresses(text: &str) -> String {
let b = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < b.len() {
if b[i..].starts_with(b"0x")
&& b.len() >= i + 42
&& b[i + 2..i + 42].iter().all(u8::is_ascii_hexdigit)
&& b.get(i + 42).is_none_or(|c| !c.is_ascii_hexdigit())
&& let Ok(a) = parse_address(&text[i..i + 42])
{
out.push_str(&mask_address(&a));
i += 42;
continue;
}
let ch = text[i..].chars().next().expect("on a char boundary");
out.push(ch);
i += ch.len_utf8();
}
out
}
/// Lower-case `0x…` hex, as Hyperliquid wants addresses.
pub fn address_hex(a: &Address) -> String {
format!("{a:#x}")
}
/// Parse a `0x…` address in any case.
pub fn parse_address(s: &str) -> Result<Address> {
let t = s.trim();
let hex = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")).unwrap_or(t);
if hex.len() != 40 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("an address is 0x followed by 40 hex digits");
}
Ok(hex.to_ascii_lowercase().parse::<Address>()?)
}
macro_rules! redacted {
($t:ident) => {
impl fmt::Debug for $t {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, concat!(stringify!($t), "({})"), mask_address(&Address::from_private_key(&self.0)))
}
}
impl fmt::Display for $t {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
};
}
redacted!(AgentKey);
redacted!(MainKey);
#[cfg(test)]
mod tests {
use super::*;
/// The Python SDK's public test key (signing_test.py), never funded.
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
#[test]
fn keys_round_trip_through_hex() {
let k = AgentKey::generate();
let back = AgentKey::from_hex(&k.to_hex()).unwrap();
assert_eq!(back.address(), k.address());
let bare = AgentKey::from_hex(SDK_KEY.trim_start_matches("0x")).unwrap();
assert_eq!(bare.address(), AgentKey::from_hex(SDK_KEY).unwrap().address());
assert_ne!(AgentKey::generate().address(), AgentKey::generate().address());
}
#[test]
fn debug_and_display_never_show_the_secret() {
let k = AgentKey::from_hex(SDK_KEY).unwrap();
let m = MainKey::from_hex(SDK_KEY).unwrap();
for s in [format!("{k:?}"), format!("{k}"), format!("{m:?}"), format!("{m}")] {
assert!(!s.contains("0123456789012345678901234567890123456789"), "{s}");
assert!(s.contains('…'), "{s}");
}
let addr = k.address();
assert!(format!("{k:?}").contains(&mask_address(&addr)));
}
#[test]
fn bad_keys_are_refused_without_echoing_them() {
for bad in ["", "0x12", "zz23456789012345678901234567890123456789012345678901234567890123"] {
assert_eq!(AgentKey::from_hex(bad).unwrap_err().to_string(), "a private key is 64 hex digits");
}
let zero = AgentKey::from_hex(&format!("0x{}", "0".repeat(64))).unwrap_err().to_string();
assert_eq!(zero, "not a valid secp256k1 private key");
}
#[test]
fn errors_are_masked_for_display() {
let e = "User or API Wallet 0x5E9EE1089755C3435139848E47E6635505D5A13A does not exist. Must deposit… User: 0x5e9ee1089755c3435139848e47e6635505d5a13a";
assert_eq!(mask_addresses(e), "User or API Wallet 0x5e9e…a13a does not exist. Must deposit… User: 0x5e9e…a13a");
let hash = format!("0x{}", "ab".repeat(32));
assert_eq!(mask_addresses(&hash), hash, "64-digit hashes are not addresses");
assert_eq!(
mask_addresses("cloid 0x0000000000000000000000000000002a ×3"),
"cloid 0x0000000000000000000000000000002a ×3"
);
}
#[test]
fn addresses_parse_and_mask() {
let a = parse_address("0x5E9EE1089755C3435139848E47E6635505D5A13A").unwrap();
assert_eq!(address_hex(&a), "0x5e9ee1089755c3435139848e47e6635505d5a13a");
assert_eq!(mask_address(&a), "0x5e9e…a13a");
assert!(parse_address("0x123").is_err() && parse_address("hello").is_err());
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Order entry on Hyperliquid, **testnet only**.
//!
//! - [`network`]: the testnet endpoints, and the only [`Network`] there is.
//! - [`key`]: the agent (API wallet) key, and a main-wallet key for tests and the
//! developer-only `testnet_check` example, both redacted. The app's main wallet is the
//! built-in Turnkey wallet, which signs through [`signing::sign_approve_agent_with`].
//! - [`signing`]: L1 phantom-agent and user-signed EIP-712 signatures.
//! - [`actions`]: order, cancel, cancelByCloid, modify, updateLeverage, scheduleCancel, noop
//! and approveAgent, with the field order Hyperliquid hashes.
//!
//! Mainnet is not reachable from this crate: there is no mainnet URL, the phantom agent's
//! source is always `"b"`, `hyperliquidChain` is always `"Testnet"`, and
//! `tests/mainnet_impossible.rs` greps the source for mainnet hosts.
pub mod actions;
pub mod assets;
pub mod caps;
pub mod client;
pub mod key;
pub mod model;
pub mod network;
pub mod nonce;
pub mod rounding;
pub mod signing;
pub mod stream;
pub mod trader;
#[cfg(test)]
mod logs_test;
#[cfg(test)]
mod vectors;
pub use key::{AgentKey, MainKey, SigningKey, mask_address, mask_addresses};
pub use network::Network;
+121
View File
@@ -0,0 +1,121 @@
//! A whole place-and-cancel cycle against a local mock `/exchange`, with every `tracing`
//! line captured: nothing in the logs may look like a private key, a signature or a signed
//! payload (any run of 64 or more hex digits).
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::assets::fixtures::registry;
use crate::client::{Client, Status};
use crate::key::AgentKey;
use crate::nonce::NonceManager;
use crate::trader::*;
/// Answers `/exchange` like Hyperliquid: a resting order, then a successful cancel.
async fn mock_exchange() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
tokio::spawn(async move {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
// Read headers and the body (Content-Length).
loop {
let n = sock.read(&mut chunk).await.unwrap_or(0);
if n == 0 {
return;
}
buf.extend_from_slice(&chunk[..n]);
let text = String::from_utf8_lossy(&buf).to_string();
if let Some(h) = text.find("\r\n\r\n") {
let len = text[..h]
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
})
.unwrap_or(0);
if buf.len() >= h + 4 + len {
break;
}
}
}
let req = String::from_utf8_lossy(&buf);
let body = if req.contains(r#""type":"cancel""#) {
r#"{"status":"ok","response":{"type":"cancel","data":{"statuses":["success"]}}}"#
} else {
r#"{"status":"ok","response":{"type":"order","data":{"statuses":[{"resting":{"oid":4242}}]}}}"#
};
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
});
}
});
format!("http://{addr}")
}
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Capture {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn longest_hex_run(s: &str) -> usize {
s.split(|c: char| !c.is_ascii_hexdigit()).map(str::len).max().unwrap_or(0)
}
#[tokio::test]
async fn a_place_and_cancel_cycle_logs_no_secrets() {
let cap = Capture::default();
let writer = cap.clone();
let sub =
tracing_subscriber::fmt().with_max_level(tracing::Level::TRACE).with_writer(move || writer.clone()).finish();
let _guard = tracing::subscriber::set_default(sub);
let base = mock_exchange().await;
let client = Client::mock(&base).unwrap();
let reg = registry();
let key = AgentKey::generate();
let nonces = NonceManager::new();
let ctx = MarketCtx { mid: Some(76_662.5), leverage: Some(Leverage { value: 3, cross: true }) };
let intent = OrderIntent {
coin: "BTC".into(),
is_buy: true,
size: 0.0002,
kind: OrderKind::Limit { px: 50_000.0, tif: crate::actions::Tif::Gtc },
reduce_only: false,
};
let plan = plan_order(&reg, &intent, &ctx, &crate::actions::cloid(7)).unwrap();
let signed = sign_order(&key, &nonces, &plan, &ctx, crate::nonce::now_ms()).unwrap();
// Whatever a careless caller might log, too.
tracing::info!("key {key:?} / {key} / signed {signed:?} / plan cloid {}", plan.cloid());
let placed = client.send(&signed).await.unwrap();
assert_eq!(placed.statuses, [Status::Resting { oid: 4242 }]);
let cancel = sign_cancel(&key, &nonces, reg.resolve("BTC").unwrap(), 4242, crate::nonce::now_ms()).unwrap();
let cancelled = client.send(&cancel).await.unwrap();
assert_eq!(cancelled.statuses, [Status::Success]);
let logs = String::from_utf8(cap.0.lock().unwrap().clone()).unwrap();
assert!(logs.contains("resting oid 4242") && logs.contains("success"), "the cycle was logged:\n{logs}");
let secret = key.to_hex();
assert!(!logs.contains(&secret[2..]), "the private key is in the logs");
assert!(
!logs.contains(&signed.signature().r[2..]) && !logs.contains(&signed.signature().s[2..]),
"a signature is in the logs"
);
assert!(longest_hex_run(&logs) < 64, "a 64-hex-digit secret-like run is in the logs:\n{logs}");
}
+365
View File
@@ -0,0 +1,365 @@
//! Account data from `/info` and the user stream: positions and margin per dex, open orders,
//! fills, funding and per-market leverage. Hyperliquid sends decimals as strings; anything
//! missing or malformed becomes `None` or 0 rather than failing the whole message.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::trader::Leverage;
pub(crate) fn num(v: &Value) -> Option<f64> {
match v {
Value::String(s) => s.trim().parse().ok(),
Value::Number(n) => n.as_f64(),
_ => None,
}
.filter(|x: &f64| x.is_finite())
}
fn text(v: &Value) -> String {
v.as_str().unwrap_or_default().to_string()
}
fn leverage(v: &Value) -> Option<Leverage> {
let value = v.get("value")?.as_u64()? as u32;
Some(Leverage { value, cross: v.get("type").and_then(Value::as_str) == Some("cross") })
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Margin {
pub account_value: f64,
pub total_ntl_pos: f64,
pub total_margin_used: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PerpPosition {
pub coin: String,
/// Signed size: negative is short.
pub szi: f64,
pub entry_px: Option<f64>,
pub position_value: f64,
pub unrealized_pnl: f64,
pub return_on_equity: f64,
pub liquidation_px: Option<f64>,
pub margin_used: f64,
pub leverage: Option<Leverage>,
pub cum_funding: f64,
}
impl PerpPosition {
/// The mark implied by value and size.
pub fn mark(&self) -> Option<f64> {
(self.szi.abs() > 0.0).then(|| self.position_value / self.szi.abs())
}
}
/// `clearinghouseState` for one dex.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Clearinghouse {
pub dex: String,
pub margin: Margin,
pub withdrawable: f64,
pub positions: Vec<PerpPosition>,
pub time: u64,
}
pub fn parse_clearinghouse(dex: &str, v: &Value) -> Clearinghouse {
let m = &v["marginSummary"];
let positions = v["assetPositions"]
.as_array()
.into_iter()
.flatten()
.filter_map(|a| {
let p = &a["position"];
let coin = text(&p["coin"]);
let szi = num(&p["szi"])?;
(!coin.is_empty()).then(|| PerpPosition {
coin,
szi,
entry_px: num(&p["entryPx"]),
position_value: num(&p["positionValue"]).unwrap_or(0.0),
unrealized_pnl: num(&p["unrealizedPnl"]).unwrap_or(0.0),
return_on_equity: num(&p["returnOnEquity"]).unwrap_or(0.0),
liquidation_px: num(&p["liquidationPx"]),
margin_used: num(&p["marginUsed"]).unwrap_or(0.0),
leverage: leverage(&p["leverage"]),
cum_funding: num(&p["cumFunding"]["allTime"]).unwrap_or(0.0),
})
})
.collect();
Clearinghouse {
dex: dex.to_string(),
margin: Margin {
account_value: num(&m["accountValue"]).unwrap_or(0.0),
total_ntl_pos: num(&m["totalNtlPos"]).unwrap_or(0.0),
total_margin_used: num(&m["totalMarginUsed"]).unwrap_or(0.0),
},
withdrawable: num(&v["withdrawable"]).unwrap_or(0.0),
positions,
time: v["time"].as_u64().unwrap_or(0),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OpenOrder {
pub coin: String,
pub is_buy: bool,
pub limit_px: f64,
pub sz: f64,
pub orig_sz: f64,
pub oid: u64,
pub timestamp: u64,
pub cloid: Option<String>,
pub reduce_only: bool,
/// `Limit`, `Stop Market`, `Take Profit Market`, …
pub order_type: String,
pub tif: Option<String>,
pub trigger_px: Option<f64>,
}
/// `openOrders` / `frontendOpenOrders` rows (the plain form lacks the type fields).
pub fn parse_open_orders(v: &Value) -> Vec<OpenOrder> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|o| {
Some(OpenOrder {
coin: Some(text(&o["coin"])).filter(|c| !c.is_empty())?,
is_buy: o["side"].as_str() == Some("B"),
limit_px: num(&o["limitPx"])?,
sz: num(&o["sz"]).unwrap_or(0.0),
orig_sz: num(&o["origSz"]).unwrap_or(0.0),
oid: o["oid"].as_u64()?,
timestamp: o["timestamp"].as_u64().unwrap_or(0),
cloid: o["cloid"].as_str().map(str::to_string),
reduce_only: o["reduceOnly"].as_bool().unwrap_or(false),
order_type: Some(text(&o["orderType"])).filter(|t| !t.is_empty()).unwrap_or_else(|| "Limit".into()),
tif: o["tif"].as_str().map(str::to_string),
trigger_px: num(&o["triggerPx"]).filter(|p| *p > 0.0 && o["isTrigger"].as_bool() == Some(true)),
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Fill {
pub coin: String,
pub px: f64,
pub sz: f64,
pub is_buy: bool,
pub time: u64,
/// `Open Long`, `Close Short`, …
pub dir: String,
pub closed_pnl: f64,
/// Includes any builder fee (always none from Terminal).
pub fee: f64,
pub oid: u64,
/// The globally unique trade id.
pub tid: u64,
}
pub fn parse_fills(v: &Value) -> Vec<Fill> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|f| {
Some(Fill {
coin: Some(text(&f["coin"])).filter(|c| !c.is_empty())?,
px: num(&f["px"])?,
sz: num(&f["sz"])?,
is_buy: f["side"].as_str() == Some("B"),
time: f["time"].as_u64().unwrap_or(0),
dir: text(&f["dir"]),
closed_pnl: num(&f["closedPnl"]).unwrap_or(0.0),
fee: num(&f["fee"]).unwrap_or(0.0),
oid: f["oid"].as_u64().unwrap_or(0),
tid: f["tid"].as_u64()?,
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Funding {
pub time: u64,
pub coin: String,
pub usdc: f64,
pub szi: f64,
pub rate: f64,
}
pub fn parse_fundings(v: &Value) -> Vec<Funding> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|f| {
Some(Funding {
time: f["time"].as_u64()?,
coin: text(&f["coin"]),
usdc: num(&f["usdc"]).unwrap_or(0.0),
szi: num(&f["szi"]).unwrap_or(0.0),
rate: num(&f["fundingRate"]).unwrap_or(0.0),
})
})
.collect()
}
/// `activeAssetData`: the account's leverage and what it can trade on one market.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AssetData {
pub coin: String,
pub leverage: Option<Leverage>,
/// `[buy, sell]`.
pub max_trade_szs: [f64; 2],
pub available_to_trade: [f64; 2],
pub mark_px: Option<f64>,
}
pub fn parse_asset_data(v: &Value) -> Option<AssetData> {
let pair = |k: &str| [num(&v[k][0]).unwrap_or(0.0), num(&v[k][1]).unwrap_or(0.0)];
Some(AssetData {
coin: Some(text(&v["coin"])).filter(|c| !c.is_empty())?,
leverage: leverage(&v["leverage"]),
max_trade_szs: pair("maxTradeSzs"),
available_to_trade: pair("availableToTrade"),
mark_px: num(&v["markPx"]),
})
}
/// A change to one order (`orderUpdates`): `open`, `filled`, `canceled`, `rejected`, …
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OrderUpdate {
pub coin: String,
pub oid: u64,
pub cloid: Option<String>,
pub status: String,
pub is_buy: bool,
pub limit_px: f64,
pub sz: f64,
pub time: u64,
}
pub fn parse_order_updates(v: &Value) -> Vec<OrderUpdate> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|u| {
let o = &u["order"];
Some(OrderUpdate {
coin: text(&o["coin"]),
oid: o["oid"].as_u64()?,
cloid: o["cloid"].as_str().map(str::to_string),
status: text(&u["status"]),
is_buy: o["side"].as_str() == Some("B"),
limit_px: num(&o["limitPx"]).unwrap_or(0.0),
sz: num(&o["sz"]).unwrap_or(0.0),
time: u["statusTimestamp"].as_u64().unwrap_or(0),
})
})
.collect()
}
/// An approved API wallet (`extraAgents`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtraAgent {
pub address: String,
pub name: String,
pub valid_until: Option<u64>,
}
pub fn parse_extra_agents(v: &Value) -> Vec<ExtraAgent> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|a| {
Some(ExtraAgent {
address: a["address"].as_str()?.to_ascii_lowercase(),
name: text(&a["name"]),
valid_until: a["validUntil"].as_u64(),
})
})
.collect()
}
/// USDC in the account's spot balances (`spotClearinghouseState`): what a deposit lands in.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct SpotUsdc {
pub total: f64,
/// Held by open spot orders.
pub hold: f64,
}
pub fn spot_usdc(v: &Value) -> SpotUsdc {
v["balances"]
.as_array()
.into_iter()
.flatten()
.find(|b| b["coin"] == "USDC")
.map(|b| SpotUsdc { total: num(&b["total"]).unwrap_or(0.0), hold: num(&b["hold"]).unwrap_or(0.0) })
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::fixtures::json;
/// The response shape from Hyperliquid's info-endpoint docs (spot), not a recording.
#[test]
fn spot_usdc_is_read_from_the_spot_balances() {
let v: Value = serde_json::from_str(
r#"{"balances":[{"coin":"USDC","token":0,"hold":"1.5","total":"14.625485","entryNtl":"0.0"},
{"coin":"PURR","token":1,"hold":"0","total":"2000","entryNtl":"1234.56"}]}"#,
)
.unwrap();
assert_eq!(spot_usdc(&v), SpotUsdc { total: 14.625485, hold: 1.5 });
assert_eq!(spot_usdc(&serde_json::json!({"balances": []})), SpotUsdc::default());
assert_eq!(spot_usdc(&serde_json::json!({})), SpotUsdc::default());
}
#[test]
fn recorded_testnet_account_parses() {
let c = parse_clearinghouse("xyz", &json("testnet_clearinghouse_xyz.json"));
assert_eq!(c.positions.len(), 3);
let tsla = c.positions.iter().find(|p| p.coin == "xyz:TSLA").unwrap();
assert_eq!((tsla.szi, tsla.entry_px), (-1.483, Some(404.349)));
assert_eq!(tsla.leverage, Some(Leverage { value: 10, cross: true }));
assert!((tsla.mark().unwrap() - 668.92198 / 1.483).abs() < 1e-9);
assert!(c.margin.account_value > 0.0 && c.withdrawable > 0.0);
let o = parse_open_orders(&json("testnet_frontend_open_orders_xyz.json"));
assert_eq!(o.len(), 1);
assert_eq!(
(o[0].coin.as_str(), o[0].is_buy, o[0].limit_px, o[0].oid),
("xyz:AAPL", false, 346.21, 63919996139)
);
assert_eq!((o[0].tif.as_deref(), o[0].trigger_px), (Some("Alo"), None));
assert!(parse_open_orders(&serde_json::json!([{"coin": "BTC"}])).is_empty(), "incomplete rows are skipped");
}
#[test]
fn streams_and_agents_parse() {
let a = parse_asset_data(&serde_json::json!({
"coin": "BTC", "leverage": {"type": "isolated", "value": 3},
"maxTradeSzs": ["0.1", "0.2"], "availableToTrade": ["10", "20"], "markPx": "76613.0"
}))
.unwrap();
assert_eq!(a.leverage, Some(Leverage { value: 3, cross: false }));
assert_eq!((a.max_trade_szs, a.mark_px), ([0.1, 0.2], Some(76613.0)));
let u = parse_order_updates(&serde_json::json!([{
"order": {"coin": "BTC", "side": "B", "limitPx": "50000", "sz": "0.0002", "oid": 7, "timestamp": 1, "origSz": "0.0002", "cloid": "0x0000000000000000000000000000002a"},
"status": "canceled", "statusTimestamp": 5
}]));
assert_eq!((u[0].status.as_str(), u[0].oid, u[0].is_buy), ("canceled", 7, true));
let agents =
parse_extra_agents(&serde_json::json!([{"address": "0xABC", "name": "terminal-mac", "validUntil": 9}]));
assert_eq!(
agents[0],
ExtraAgent { address: "0xabc".into(), name: "terminal-mac".into(), valid_until: Some(9) }
);
let f = parse_fundings(
&serde_json::json!([{"time": 1, "coin": "BTC", "usdc": "-0.01", "szi": "0.1", "fundingRate": "0.0001"}]),
);
assert_eq!(f[0].usdc, -0.01);
}
}
+97
View File
@@ -0,0 +1,97 @@
//! The one network this build can reach: Hyperliquid's testnet.
//!
//! There is no mainnet variant, constant or switch. Everything that names a network (the
//! endpoints, the phantom agent's `source`, `hyperliquidChain` in user-signed actions)
//! comes from [`Network::Testnet`], so nothing in this crate can address or sign for
//! mainnet. A preference file that says `"mainnet"` still reads as testnet (see the
//! `Deserialize` impl).
use serde::{Deserialize, Deserializer, Serialize};
/// Testnet REST base (`/info`, `/exchange`).
pub const API_URL: &str = "https://api.hyperliquid-testnet.xyz";
/// Testnet websocket.
pub const WS_URL: &str = "wss://api.hyperliquid-testnet.xyz/ws";
/// The chain id user-signed actions are signed under (as the Python SDK does). It only
/// names the EIP-712 domain the wallet signs in; `hyperliquidChain` picks the network.
pub const SIGNATURE_CHAIN_ID: u64 = 0x66eee;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
pub enum Network {
#[default]
Testnet,
}
impl Network {
pub fn info_url(self) -> String {
format!("{API_URL}/info")
}
pub fn exchange_url(self) -> String {
format!("{API_URL}/exchange")
}
pub fn ws_url(self) -> &'static str {
WS_URL
}
/// The phantom agent's `source` for L1 actions: `"b"` is testnet.
pub fn phantom_source(self) -> &'static str {
"b"
}
/// `hyperliquidChain` in user-signed actions such as `approveAgent`.
pub fn hyperliquid_chain(self) -> &'static str {
"Testnet"
}
/// The badge every surface shows.
pub fn label(self) -> &'static str {
"TESTNET"
}
}
/// Any stored value reads as testnet, including `"mainnet"` or garbage: this build has no
/// other network. Failing the whole preferences file instead would reset unrelated settings.
impl<'de> Deserialize<'de> for Network {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
if v != serde_json::Value::String("Testnet".into()) {
tracing::warn!("Hyperliquid network {v} is not available in this build; using testnet");
}
Ok(Network::Testnet)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoints_are_the_testnet_hosts() {
assert_eq!(API_URL, "https://api.hyperliquid-testnet.xyz");
assert_eq!(WS_URL, "wss://api.hyperliquid-testnet.xyz/ws");
let n = Network::default();
assert_eq!(n.info_url(), "https://api.hyperliquid-testnet.xyz/info");
assert_eq!(n.exchange_url(), "https://api.hyperliquid-testnet.xyz/exchange");
assert_eq!(n.ws_url(), WS_URL);
for url in [n.info_url(), n.exchange_url(), n.ws_url().to_string()] {
assert!(url.contains("hyperliquid-testnet.xyz"), "{url}");
}
}
#[test]
fn signing_names_testnet() {
assert_eq!(Network::Testnet.phantom_source(), "b");
assert_eq!(Network::Testnet.hyperliquid_chain(), "Testnet");
}
#[test]
fn no_stored_value_selects_mainnet() {
for json in [r#""Testnet""#, r#""mainnet""#, r#""Mainnet""#, r#""MAINNET""#, r#""a""#, "1", "null", "{}"] {
let n: Network = serde_json::from_str(json).unwrap();
assert_eq!(n, Network::Testnet, "{json}");
}
assert_eq!(serde_json::to_string(&Network::Testnet).unwrap(), r#""Testnet""#);
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Nonces: one per signed action, strictly increasing per signer, close to the clock.
//!
//! Hyperliquid keeps the 100 highest nonces per signer and accepts a new one if it is larger
//! than the smallest of those and within (now − 2 days, now + 1 day). Using
//! `max(last + 1, now_ms)` keeps every nonce unique even when two actions are signed in the
//! same millisecond.
use std::sync::atomic::{AtomicU64, Ordering};
/// Milliseconds since the Unix epoch (the browser's clock on the web).
pub fn now_ms() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
}
/// The next nonce for one signer. Share one per agent key.
#[derive(Debug, Default)]
pub struct NonceManager {
last: AtomicU64,
}
impl NonceManager {
pub fn new() -> Self {
Self::default()
}
/// `max(last + 1, now)`, atomically.
pub fn next(&self, now: u64) -> u64 {
let mut cur = self.last.load(Ordering::Relaxed);
loop {
let n = (cur + 1).max(now);
match self.last.compare_exchange_weak(cur, n, Ordering::AcqRel, Ordering::Relaxed) {
Ok(_) => return n,
Err(seen) => cur = seen,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nonces_follow_the_clock_and_never_repeat() {
let n = NonceManager::new();
assert_eq!(n.next(1_000), 1_000);
assert_eq!(n.next(1_000), 1_001, "same millisecond");
assert_eq!(n.next(900), 1_002, "a clock that steps back never reuses one");
assert_eq!(n.next(5_000), 5_000);
let shared = std::sync::Arc::new(NonceManager::new());
let handles: Vec<_> = (0..8)
.map(|_| {
let s = shared.clone();
std::thread::spawn(move || (0..500).map(|_| s.next(7)).collect::<Vec<_>>())
})
.collect();
let mut all: Vec<u64> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect();
all.sort();
all.dedup();
assert_eq!(all.len(), 4000, "unique across threads");
assert!(now_ms() > 1_700_000_000_000);
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Tick and lot rules for perps: prices have at most five significant figures and at most
//! `6 − szDecimals` decimals (integers are always valid); sizes have at most `szDecimals`
//! decimals. Wire strings carry no trailing zeros, as the Python SDK's `float_to_wire`.
use anyhow::{Result, bail};
/// Which way to round when a value doesn't fit the grid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Round {
Nearest,
/// Towards zero, e.g. a market buy's price cap, so slippage never exceeds the cap.
Down,
/// Away from zero, e.g. a market sell's price floor.
Up,
}
fn round_to(x: f64, decimals: i32, dir: Round) -> f64 {
let scale = 10f64.powi(decimals);
let v = x * scale;
// A hair of tolerance so 226.4 stays 226.4 when the float is 226.39999999.
let r = match dir {
Round::Nearest => v.round(),
Round::Down => (v + 1e-7).floor(),
Round::Up => (v - 1e-7).ceil(),
};
r / scale
}
/// The most decimals a perp price may have at this level.
pub fn price_decimals(px: f64, sz_decimals: u32) -> i32 {
let max = 6 - sz_decimals.min(6) as i32;
// log10 of zero (or of nothing) is not a number of digits.
if !px.is_finite() || px == 0.0 {
return max;
}
if px.abs() >= 100_000.0 {
return 0;
}
let sig = 4 - px.abs().log10().floor() as i32;
sig.clamp(0, max)
}
/// A valid perp price: five significant figures and `6 − szDecimals` decimals at most.
pub fn round_price(px: f64, sz_decimals: u32, dir: Round) -> f64 {
if !px.is_finite() || px <= 0.0 {
return px;
}
let d = price_decimals(px, sz_decimals);
let r = round_to(px, d, dir);
if r <= 0.0 {
// Smaller than the smallest tick: the caller refuses a zero price.
return 0.0;
}
// Rounding up can add a digit (99999.5 → 100000); one more pass settles it.
round_to(r, price_decimals(r, sz_decimals), dir)
}
/// A valid size: `szDecimals` decimals, rounded down so an order never grows.
pub fn round_size(sz: f64, sz_decimals: u32) -> f64 {
round_to(sz, sz_decimals as i32, Round::Down)
}
/// Whether `px` is already on the price grid.
pub fn price_ok(px: f64, sz_decimals: u32) -> bool {
px.is_finite() && px > 0.0 && (round_price(px, sz_decimals, Round::Nearest) - px).abs() <= px.abs() * 1e-12
}
/// `float_to_wire`: eight decimals at most, no trailing zeros, never `-0`. Refuses a value
/// that would change by rounding.
pub fn to_wire(x: f64) -> Result<String> {
if !x.is_finite() {
bail!("not a number");
}
let s = format!("{x:.8}");
if (s.parse::<f64>()? - x).abs() >= 1e-12 {
bail!("{x} has more than 8 decimals");
}
let t = s.trim_end_matches('0').trim_end_matches('.');
Ok(if t == "-0" || t.is_empty() { "0".into() } else { t.to_string() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prices_keep_five_significant_figures() {
// xyz:AAPL (szDecimals 3): three decimals at most.
assert_eq!(round_price(226.4, 3, Round::Nearest), 226.4);
assert_eq!(round_price(226.456, 3, Round::Nearest), 226.46, "five significant figures");
assert_eq!(round_price(4.56789, 3, Round::Nearest), 4.568, "and 6 − 3 = 3 decimals");
// BTC (szDecimals 5): one decimal at most, integers always valid.
assert_eq!(round_price(76662.57, 5, Round::Nearest), 76663.0);
assert_eq!(round_price(123456.7, 5, Round::Nearest), 123457.0, "integers are valid past five figures");
assert_eq!(round_price(1234.56, 5, Round::Nearest), 1234.6);
// Small prices.
assert_eq!(round_price(0.0123456, 0, Round::Nearest), 0.012346);
assert_eq!(round_price(0.000123456, 0, Round::Nearest), 0.000123, "six decimals at most");
}
#[test]
fn directed_rounding_never_overshoots() {
assert_eq!(round_price(226.456, 3, Round::Down), 226.45);
assert_eq!(round_price(226.451, 3, Round::Up), 226.46);
assert_eq!(round_price(99999.6, 0, Round::Up), 100000.0);
assert_eq!(round_price(226.4, 3, Round::Down), 226.4, "on-grid values stay put");
assert_eq!(round_price(226.4, 3, Round::Up), 226.4);
}
#[test]
fn zero_and_tiny_prices_never_panic() {
assert_eq!(price_decimals(0.0, 5), 1);
assert_eq!(round_price(0.00000001, 5, Round::Nearest), 0.0);
assert_eq!(round_price(0.00000001, 5, Round::Up), 0.0, "within the grid's tolerance of zero");
assert!(round_price(f64::NAN, 5, Round::Nearest).is_nan());
}
#[test]
fn sizes_round_down_to_lot() {
assert_eq!(round_size(0.123456, 5), 0.12345);
assert_eq!(round_size(0.5, 3), 0.5);
assert_eq!(round_size(0.0009, 3), 0.0, "too small rounds to nothing");
assert_eq!(round_size(0.3, 1), 0.3, "0.3 is not 0.2999…");
}
#[test]
fn wire_strings_match_float_to_wire() {
assert_eq!(to_wire(100.0).unwrap(), "100");
assert_eq!(to_wire(1670.1).unwrap(), "1670.1");
assert_eq!(to_wire(0.0147).unwrap(), "0.0147");
assert_eq!(to_wire(0.0002).unwrap(), "0.0002");
assert_eq!(to_wire(-0.0).unwrap(), "0");
assert_eq!(to_wire(0.0).unwrap(), "0");
assert!(to_wire(0.123456789).is_err(), "would round");
assert!(to_wire(f64::NAN).is_err());
assert!(price_ok(226.4, 3) && !price_ok(226.456, 3));
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Signatures, as the Python SDK's `hyperliquid/utils/signing.py` makes them.
//!
//! **L1 actions** (order, cancel, modify, leverage, schedule cancel, noop): the action is
//! msgpack-encoded with named fields, followed by the nonce (8 bytes, big-endian), `0x00`
//! (no vault) and, when set, `0x00 ‖ expiresAfter`. Its keccak-256 is the `connectionId` of
//! a "phantom agent" `Agent(string source, bytes32 connectionId)`, EIP-712-signed under the
//! domain `Exchange` v1, chain 1337. `source` is always testnet's `"b"`
//! ([`Network::phantom_source`]); nothing here can produce mainnet's.
//!
//! **User-signed actions** (`approveAgent`): EIP-712 over the action's own fields under the
//! domain `HyperliquidSignTransaction` v1 at `signatureChainId`, with `hyperliquidChain`
//! always `"Testnet"`.
//!
//! L1 signing is crate-private: callers go through [`crate::Trader`], which enforces the
//! hard caps before anything is signed.
use alloy_primitives::{Address, B256, Signature, U256, keccak256};
use alloy_sol_types::{SolStruct, eip712_domain, sol};
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use crate::actions::ApproveAgentAction;
use crate::key::{SigningKey, sign_hash};
use crate::network::{Network, SIGNATURE_CHAIN_ID};
sol! {
struct Agent {
string source;
bytes32 connectionId;
}
}
/// `{r, s, v}` as Hyperliquid takes it: `0x` hex without leading zeros (as the SDK's
/// `to_hex`), and `v` 27 or 28.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireSignature {
pub r: String,
pub s: String,
pub v: u8,
}
impl WireSignature {
fn from_alloy(sig: &Signature) -> Self {
Self { r: format!("{:#x}", sig.r()), s: format!("{:#x}", sig.s()), v: 27 + u8::from(sig.v()) }
}
/// From a wallet's 65-byte `0x` r ‖ s ‖ v hex (`eth_signTypedData_v4`); `v` may be 0/1.
pub fn from_rsv_hex(hex: &str) -> Result<Self> {
let h = hex.trim().trim_start_matches("0x");
ensure!(
h.len() == 130 && h.chars().all(|c| c.is_ascii_hexdigit()),
"the wallet returned a malformed signature"
);
let v = u8::from_str_radix(&h[128..130], 16)?;
let num = |s: &str| U256::from_str_radix(s, 16).map(|n| format!("{n:#x}"));
Ok(Self { r: num(&h[..64])?, s: num(&h[64..128])?, v: if v < 27 { v + 27 } else { v } })
}
fn to_alloy(&self) -> Result<Signature> {
let num = |s: &str| U256::from_str_radix(s.trim_start_matches("0x"), 16).context("signature is not hex");
ensure!(self.v == 27 || self.v == 28, "signature v must be 27 or 28");
Ok(Signature::new(num(&self.r)?, num(&self.s)?, self.v == 28))
}
}
/// The L1 `connectionId`: keccak-256 of msgpack(action) ‖ nonce ‖ `0x00` ‖ [`0x00` ‖ expiresAfter].
pub fn action_hash<A: Serialize>(action: &A, nonce: u64, expires_after: Option<u64>) -> Result<B256> {
let mut data = rmp_serde::to_vec_named(action).context("msgpack-encoding the action")?;
data.extend_from_slice(&nonce.to_be_bytes());
// No vault or sub-account: this build only trades the signer's own account.
data.push(0);
if let Some(t) = expires_after {
data.push(0);
data.extend_from_slice(&t.to_be_bytes());
}
Ok(keccak256(data))
}
/// The EIP-712 hash of the testnet phantom agent for `connection_id`.
pub(crate) fn agent_signing_hash(connection_id: B256) -> B256 {
let domain = eip712_domain! {
name: "Exchange",
version: "1",
chain_id: 1337,
verifying_contract: Address::ZERO,
};
let agent = Agent { source: Network::Testnet.phantom_source().into(), connectionId: connection_id };
agent.eip712_signing_hash(&domain)
}
/// Sign an L1 action for testnet. Crate-private: see [`crate::Trader`].
pub(crate) fn sign_l1<A: Serialize>(
key: &impl SigningKey,
action: &A,
nonce: u64,
expires_after: Option<u64>,
) -> Result<WireSignature> {
let hash = agent_signing_hash(action_hash(action, nonce, expires_after)?);
let sig = sign_hash(key, &hash).context("signing")?;
Ok(WireSignature::from_alloy(&sig))
}
/// The address that signed an L1 action, as Hyperliquid recovers it. A wrong signature
/// recovers some other address rather than failing, which is how Hyperliquid reports it
/// ("User or API Wallet 0x… does not exist").
pub fn recover_l1<A: Serialize>(
action: &A,
nonce: u64,
expires_after: Option<u64>,
sig: &WireSignature,
) -> Result<Address> {
let hash = agent_signing_hash(action_hash(action, nonce, expires_after)?);
Ok(sig.to_alloy()?.recover_address_from_prehash(&hash)?)
}
// ---- user-signed actions -------------------------------------------------------------------
/// One EIP-712 field value, encoded to 32 bytes.
pub(crate) enum Field<'a> {
Str(&'a str),
Addr(Address),
U64(u64),
}
impl Field<'_> {
fn encode(&self) -> B256 {
match self {
Field::Str(s) => keccak256(s.as_bytes()),
Field::Addr(a) => a.into_word(),
Field::U64(n) => B256::from(U256::from(*n)),
}
}
}
/// The EIP-712 signing hash of a `HyperliquidTransaction:*` user-signed action.
pub(crate) fn user_signed_hash(type_string: &str, fields: &[Field<'_>], chain_id: u64) -> B256 {
let mut enc = Vec::with_capacity(32 * (fields.len() + 1));
enc.extend_from_slice(keccak256(type_string.as_bytes()).as_slice());
for f in fields {
enc.extend_from_slice(f.encode().as_slice());
}
let struct_hash = keccak256(&enc);
let domain = eip712_domain! {
name: "HyperliquidSignTransaction",
version: "1",
chain_id: chain_id,
verifying_contract: Address::ZERO,
};
let mut msg = Vec::with_capacity(66);
msg.extend_from_slice(&[0x19, 0x01]);
msg.extend_from_slice(domain.separator().as_slice());
msg.extend_from_slice(struct_hash.as_slice());
keccak256(msg)
}
const APPROVE_AGENT_TYPE: &str =
"HyperliquidTransaction:ApproveAgent(string hyperliquidChain,address agentAddress,string agentName,uint64 nonce)";
/// The hash the main wallet signs to approve an agent. Refuses anything not addressed to
/// testnet: `hyperliquidChain` must be `Testnet` and `signatureChainId` the testnet
/// signature chain ([`SIGNATURE_CHAIN_ID`], 0x66eee), never Arbitrum One's 0xa4b1.
pub fn approve_agent_hash(a: &ApproveAgentAction) -> Result<B256> {
ensure!(a.kind == "approveAgent", "not an approveAgent action");
ensure!(a.hyperliquid_chain == Network::Testnet.hyperliquid_chain(), "this build only approves agents on testnet");
ensure!(
a.chain_id().ok() == Some(SIGNATURE_CHAIN_ID),
"this build only signs at the testnet signature chain ({SIGNATURE_CHAIN_ID:#x})"
);
let agent = crate::key::parse_address(&a.agent_address)?;
let fields = [Field::Str(&a.hyperliquid_chain), Field::Addr(agent), Field::Str(&a.agent_name), Field::U64(a.nonce)];
Ok(user_signed_hash(APPROVE_AGENT_TYPE, &fields, a.chain_id()?))
}
/// The main wallet signs `approveAgent`.
pub fn sign_approve_agent(main: &impl SigningKey, a: &ApproveAgentAction) -> Result<WireSignature> {
let sig = sign_hash(main, &approve_agent_hash(a)?).context("signing")?;
Ok(WireSignature::from_alloy(&sig))
}
/// A main wallet held elsewhere (the built-in Turnkey wallet) signs `approveAgent`.
///
/// `sign` gets only the digest of a testnet approval: [`approve_agent_hash`] refuses
/// anything else before `sign` is called. The answer is used only if it recovers `main`,
/// so a wrong or swapped signature is never sent. This is the only way a remote wallet's
/// signature enters this crate; L1 actions (orders, cancels) are signed by the local agent
/// key alone.
pub async fn sign_approve_agent_with<F, Fut>(a: &ApproveAgentAction, main: &Address, sign: F) -> Result<WireSignature>
where
F: FnOnce(B256) -> Fut,
Fut: std::future::Future<Output = Result<Signature>>,
{
let digest = approve_agent_hash(a)?;
let sig = sign(digest).await.context("the wallet didn't sign")?;
let signer = sig.recover_address_from_prehash(&digest).context("the wallet's signature is malformed")?;
ensure!(
&signer == main,
"the signature is not the wallet's ({}); nothing was sent",
crate::key::mask_address(&signer)
);
Ok(WireSignature::from_alloy(&sig))
}
/// Who signed an `approveAgent`.
pub fn recover_approve_agent(a: &ApproveAgentAction, sig: &WireSignature) -> Result<Address> {
Ok(sig.to_alloy()?.recover_address_from_prehash(&approve_agent_hash(a)?)?)
}
/// The EIP-712 typed data of an approval, as a wallet's `eth_signTypedData_v4` takes it
/// (the tests check it names the same domain and message as the digest).
pub fn approve_agent_typed_data(a: &ApproveAgentAction) -> Result<serde_json::Value> {
approve_agent_hash(a)?;
Ok(serde_json::json!({
"domain": {
"name": "HyperliquidSignTransaction",
"version": "1",
"chainId": a.chain_id()?,
"verifyingContract": "0x0000000000000000000000000000000000000000",
},
"types": {
"HyperliquidTransaction:ApproveAgent": [
{"name": "hyperliquidChain", "type": "string"},
{"name": "agentAddress", "type": "address"},
{"name": "agentName", "type": "string"},
{"name": "nonce", "type": "uint64"},
],
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"},
],
},
"primaryType": "HyperliquidTransaction:ApproveAgent",
"message": {
"hyperliquidChain": a.hyperliquid_chain,
"agentAddress": a.agent_address,
"agentName": a.agent_name,
"nonce": a.nonce,
},
}))
}
+239
View File
@@ -0,0 +1,239 @@
//! The user's own data over Hyperliquid's websocket, straight from the client (never through
//! Terminal's server): what to subscribe to, and how each message changes [`UserState`].
//!
//! `clearinghouseState` and `openOrders` arrive whole for one dex each time, so they replace
//! that dex's entry. `userFills` and `userFundings` start with a snapshot (`isSnapshot`) and
//! then stream additions, de-duplicated by trade id so a reconnect's snapshot never doubles
//! a fill. Messages for any other user are ignored.
//!
//! The socket itself lives with the caller (the app's worker uses `tr_data::ws`), which
//! sends [`subscriptions`], [`PING`] about every 30 s, and feeds every text frame to
//! [`UserState::apply`].
use std::collections::{BTreeMap, HashMap};
use serde_json::{Value, json};
use crate::model::*;
/// Keeps the connection open (Hyperliquid closes it after 60 s without traffic).
pub const PING: &str = r#"{"method":"ping"}"#;
/// Fills kept for `ACTV`.
pub const MAX_FILLS: usize = 500;
/// The subscribe messages for `user` on `dexes` (`""` is the core dex), with leverage for
/// `coins` (the ticket's market) and testnet mids per dex.
pub fn subscriptions<'a>(user: &str, dexes: impl IntoIterator<Item = &'a str>, coins: &[&str]) -> Vec<String> {
let user = user.to_ascii_lowercase();
let mut subs = Vec::new();
for dex in dexes {
subs.push(json!({"type": "clearinghouseState", "user": user, "dex": dex}));
subs.push(json!({"type": "openOrders", "user": user, "dex": dex}));
subs.push(json!({"type": "allMids", "dex": dex}));
}
for t in ["orderUpdates", "userFills", "userFundings"] {
subs.push(json!({"type": t, "user": user}));
}
for coin in coins {
subs.push(json!({"type": "activeAssetData", "user": user, "coin": coin}));
}
subs.into_iter().map(|s| json!({"method": "subscribe", "subscription": s}).to_string()).collect()
}
/// One subscription for leverage on another market (the ticket moved on).
pub fn asset_data_subscription(user: &str, coin: &str, subscribe: bool) -> String {
let method = if subscribe { "subscribe" } else { "unsubscribe" };
json!({"method": method, "subscription": {"type": "activeAssetData", "user": user.to_ascii_lowercase(), "coin": coin}})
.to_string()
}
/// What a message changed, for toasts and repaints.
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
Account,
Orders,
Fills(Vec<Fill>),
Fundings,
AssetData(String),
Mids,
OrderUpdates(Vec<OrderUpdate>),
}
/// Everything the panes and the ticket know about one account.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct UserState {
pub clearinghouse: BTreeMap<String, Clearinghouse>,
pub orders: BTreeMap<String, Vec<OpenOrder>>,
/// Newest first.
pub fills: Vec<Fill>,
pub fundings: Vec<Funding>,
pub asset_data: HashMap<String, AssetData>,
/// Testnet mids by coin (`BTC`, `xyz:AAPL`). Never mixed with mainnet quotes.
pub mids: HashMap<String, f64>,
}
impl UserState {
/// Apply one websocket text frame for `user`. Returns what changed (nothing for acks,
/// pongs and other users' data).
pub fn apply(&mut self, user: &str, frame: &str) -> Vec<Event> {
let Ok(m) = serde_json::from_str::<Value>(frame) else { return Vec::new() };
let data = &m["data"];
let mine = |d: &Value| d["user"].as_str().is_some_and(|u| u.eq_ignore_ascii_case(user));
match m["channel"].as_str() {
Some("clearinghouseState") if mine(data) => {
let dex = data["dex"].as_str().unwrap_or_default().to_string();
self.clearinghouse.insert(dex.clone(), parse_clearinghouse(&dex, &data["clearinghouseState"]));
vec![Event::Account]
}
Some("openOrders") if mine(data) => {
let dex = data["dex"].as_str().unwrap_or_default().to_string();
self.orders.insert(dex, parse_open_orders(&data["orders"]));
vec![Event::Orders]
}
Some("userFills") if mine(data) => {
let fills = parse_fills(&data["fills"]);
let snapshot = data["isSnapshot"].as_bool().unwrap_or(false);
let new = self.add_fills(fills, snapshot);
if snapshot || !new.is_empty() {
vec![Event::Fills(if snapshot { Vec::new() } else { new })]
} else {
Vec::new()
}
}
Some("userFundings") if mine(data) => {
let f = parse_fundings(&data["fundings"]);
if data["isSnapshot"].as_bool().unwrap_or(false) {
self.fundings = f;
} else {
self.fundings.extend(f);
}
self.fundings.sort_by(|a, b| b.time.cmp(&a.time).then(a.coin.cmp(&b.coin)));
self.fundings.dedup_by(|a, b| a.time == b.time && a.coin == b.coin);
vec![Event::Fundings]
}
Some("activeAssetData") if mine(data) => match parse_asset_data(data) {
Some(a) => {
let coin = a.coin.clone();
self.asset_data.insert(coin.clone(), a);
vec![Event::AssetData(coin)]
}
None => Vec::new(),
},
Some("allMids") => {
let mut any = false;
for (k, v) in data["mids"].as_object().into_iter().flatten() {
if let Some(px) = num(v).filter(|p| *p > 0.0) {
self.mids.insert(k.clone(), px);
any = true;
}
}
if any { vec![Event::Mids] } else { Vec::new() }
}
Some("orderUpdates") => {
// Order updates carry no user field; the subscription is for this user only.
let u = parse_order_updates(data);
if u.is_empty() { Vec::new() } else { vec![Event::OrderUpdates(u)] }
}
_ => Vec::new(),
}
}
/// Merge fills (a snapshot replaces), newest first, de-duplicated by trade id. Returns
/// the ones that are new.
fn add_fills(&mut self, fills: Vec<Fill>, snapshot: bool) -> Vec<Fill> {
if snapshot {
self.fills.clear();
}
let mut new = Vec::new();
for f in fills {
if !self.fills.iter().any(|x| x.tid == f.tid && x.coin == f.coin && x.time == f.time) {
new.push(f.clone());
self.fills.push(f);
}
}
self.fills.sort_by(|a, b| b.time.cmp(&a.time).then(b.tid.cmp(&a.tid)));
self.fills.truncate(MAX_FILLS);
new
}
/// Every open order on every dex, as `(coin, oid)`: what the kill switch cancels.
pub fn open_order_ids(&self) -> Vec<(String, u64)> {
self.orders.values().flatten().map(|o| (o.coin.clone(), o.oid)).collect()
}
/// Account value over every dex loaded (standard accounts hold one balance per dex).
pub fn account_value(&self) -> f64 {
self.clearinghouse.values().map(|c| c.margin.account_value).sum()
}
pub fn withdrawable(&self) -> f64 {
self.clearinghouse.values().map(|c| c.withdrawable).sum()
}
pub fn positions(&self) -> impl Iterator<Item = &PerpPosition> {
self.clearinghouse.values().flat_map(|c| c.positions.iter())
}
}
#[cfg(test)]
mod tests {
use super::*;
const USER: &str = "0x00000000000000000000000000000000000f107e";
fn recorded() -> Vec<String> {
let path = format!("{}/tests/fixtures/testnet_ws_user_stream.jsonl", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(path).unwrap().lines().map(str::to_string).collect()
}
#[test]
fn subscriptions_cover_both_dexes() {
let subs = subscriptions("0xABC", ["", "xyz"], &["xyz:AAPL"]);
assert_eq!(subs.len(), 3 * 2 + 3 + 1);
assert!(subs.iter().any(|s| s.contains(r#""type":"clearinghouseState""#) && s.contains(r#""dex":"xyz""#)));
assert!(subs.iter().all(|s| !s.contains("0xABC")), "addresses go lower-case");
assert!(asset_data_subscription("0xabc", "BTC", false).starts_with(r#"{"method":"unsubscribe""#));
}
#[test]
fn a_recorded_testnet_stream_builds_the_account() {
let mut s = UserState::default();
let mut events = Vec::new();
for m in recorded() {
events.extend(s.apply(USER, &m));
}
assert_eq!(s.clearinghouse.len(), 2, "core and xyz");
assert_eq!(s.positions().count(), 3);
assert_eq!(s.open_order_ids(), [("xyz:AAPL".to_string(), 63919996139)]);
assert_eq!(s.fills.len(), 4);
assert!(s.fills.windows(2).all(|w| w[0].time >= w[1].time), "newest first");
assert_eq!(s.asset_data["xyz:AAPL"].leverage.map(|l| l.value), Some(20));
assert!(s.mids.values().all(|p| *p > 0.0) && !s.mids.is_empty());
assert!(s.account_value() > 0.0);
assert!(events.contains(&Event::Account) && events.contains(&Event::Orders));
// The same data for another user changes nothing.
let mut other = UserState::default();
for m in recorded().iter().filter(|m| !m.contains("allMids")) {
other.apply("0x0000000000000000000000000000000000000001", m);
}
assert_eq!(other, UserState::default());
}
#[test]
fn a_reconnect_snapshot_never_doubles_fills() {
let mut s = UserState::default();
let fill =
|tid: u64, time: u64| json!({"coin": "BTC", "px": "1", "sz": "1", "side": "B", "time": time, "tid": tid});
let msg = |snap: bool, fills: Vec<Value>| {
json!({"channel": "userFills", "data": {"isSnapshot": snap, "user": USER, "fills": fills}}).to_string()
};
s.apply(USER, &msg(true, vec![fill(1, 10), fill(2, 20)]));
let ev = s.apply(USER, &msg(false, vec![fill(3, 30), fill(2, 20)]));
assert_eq!(ev.len(), 1);
let Event::Fills(new) = &ev[0] else { panic!("{ev:?}") };
assert_eq!(new.iter().map(|f| f.tid).collect::<Vec<_>>(), [3]);
s.apply(USER, &msg(true, vec![fill(1, 10), fill(2, 20), fill(3, 30)]));
assert_eq!(s.fills.iter().map(|f| f.tid).collect::<Vec<_>>(), [3, 2, 1]);
assert!(s.apply(USER, "not json").is_empty() && s.apply(USER, r#"{"channel":"pong"}"#).is_empty());
}
}
+707
View File
@@ -0,0 +1,707 @@
//! Everything that signs an L1 action goes through here, and every check happens here
//! first: the market must be tradable, sizes and prices are put on the grid, and the hard
//! caps in [`crate::caps`] are enforced before a signature exists.
//!
//! An order is planned ([`plan_order`]: rounded and checked, for the confirmation), then
//! signed ([`sign_order`]: checked again against the market at that moment). A plan can only
//! come from [`plan_order`], so nothing unchecked reaches the signer. Every L1 action carries
//! `expiresAfter` ≈ now + 30 s, so a signed action that is delayed can't land late.
use std::fmt;
use alloy_primitives::Address;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::actions::*;
use crate::assets::{Asset, AssetRegistry};
use crate::caps::{self, MAX_MARKET_SLIPPAGE, Refusal};
use crate::key::{SigningKey, address_hex};
use crate::network::{Network, SIGNATURE_CHAIN_ID};
use crate::nonce::NonceManager;
use crate::rounding::{Round, round_price, round_size, to_wire};
use crate::signing::{WireSignature, sign_l1};
/// How long a signed L1 action stays valid.
pub const EXPIRES_AFTER_MS: u64 = 30_000;
/// Default agent lifetime: the most Hyperliquid allows, because the built-in wallet's
/// approval is a paid Turnkey signature, so it should be rare.
pub const DEFAULT_AGENT_DAYS: u64 = 180;
pub const MAX_AGENT_DAYS: u64 = 180;
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OrderKind {
Limit {
px: f64,
tif: Tif,
},
/// An IOC limit [`MAX_MARKET_SLIPPAGE`] through the mid.
Market,
/// Take profit or stop loss: a market order once `trigger_px` trades.
Trigger {
trigger_px: f64,
tpsl: Tpsl,
},
}
/// What the user asked for, before rounding.
#[derive(Debug, Clone, PartialEq)]
pub struct OrderIntent {
pub coin: String,
pub is_buy: bool,
pub size: f64,
pub kind: OrderKind,
pub reduce_only: bool,
}
/// The account's leverage on one market (`activeAssetData`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Leverage {
pub value: u32,
pub cross: bool,
}
/// The market as the signer sees it now: the testnet mid and the account's leverage.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct MarketCtx {
pub mid: Option<f64>,
pub leverage: Option<Leverage>,
}
/// An order rounded and checked. Only [`plan_order`] makes one.
#[derive(Debug, Clone, PartialEq)]
pub struct OrderPlan {
asset: Asset,
intent: OrderIntent,
size: f64,
price: f64,
trigger: Option<f64>,
notional: f64,
wire: OrderWire,
}
impl OrderPlan {
pub fn asset(&self) -> &Asset {
&self.asset
}
pub fn intent(&self) -> &OrderIntent {
&self.intent
}
/// Size as sent (rounded down to the lot).
pub fn size(&self) -> f64 {
self.size
}
/// The limit price sent: the order's limit, or a market order's IOC cap.
pub fn price(&self) -> f64 {
self.price
}
pub fn trigger(&self) -> Option<f64> {
self.trigger
}
/// Size × the higher of the price sent and the mid, in USD.
pub fn notional(&self) -> f64 {
self.notional
}
pub fn cloid(&self) -> &str {
self.wire.c.as_deref().unwrap_or_default()
}
pub fn wire(&self) -> &OrderWire {
&self.wire
}
/// `Limit GTC`, `Market (IOC, 1% cap)`, `Stop loss at 70000`.
pub fn kind_label(&self) -> String {
match self.intent.kind {
OrderKind::Limit { tif, .. } => format!("Limit {}", tif.label()),
OrderKind::Market => format!("Market (IOC, {:.0}% cap)", MAX_MARKET_SLIPPAGE * 100.0),
OrderKind::Trigger { tpsl: Tpsl::Tp, .. } => "Take profit (market)".into(),
OrderKind::Trigger { tpsl: Tpsl::Sl, .. } => "Stop loss (market)".into(),
}
}
}
/// A signed action, ready to post. `Debug` shows what it is, never the signature.
#[derive(Clone)]
pub struct Signed {
pub kind: &'static str,
action: Value,
pub nonce: u64,
signature: WireSignature,
pub expires_after: Option<u64>,
}
impl Signed {
/// The `/exchange` request body.
pub fn body(&self) -> Value {
json!({
"action": self.action,
"nonce": self.nonce,
"signature": self.signature,
"vaultAddress": Value::Null,
"expiresAfter": self.expires_after,
})
}
pub fn signature(&self) -> &WireSignature {
&self.signature
}
/// The same action with a damaged signature (the live check's control).
pub fn corrupted(&self) -> Signed {
let mut s = self.clone();
let last = s.signature.s.pop().unwrap_or('0');
s.signature.s.push(if last == '1' { '2' } else { '1' });
s
}
}
impl fmt::Debug for Signed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Signed")
.field("kind", &self.kind)
.field("nonce", &self.nonce)
.field("expires_after", &self.expires_after)
.finish_non_exhaustive()
}
}
/// Round and check an order for the confirmation.
pub fn plan_order(
reg: &AssetRegistry,
intent: &OrderIntent,
ctx: &MarketCtx,
cloid: &str,
) -> Result<OrderPlan, Refusal> {
let asset = reg.resolve(&intent.coin).map_err(Refusal::Invalid)?.clone();
if !valid_cloid(cloid) {
return Err(Refusal::Invalid("internal: malformed client order id".into()));
}
if !intent.size.is_finite() || intent.size <= 0.0 {
return Err(Refusal::Invalid("Enter a size greater than zero.".into()));
}
let size = round_size(intent.size, asset.sz_decimals);
if size <= 0.0 {
return Err(Refusal::Invalid(format!(
"{} {} rounds to zero ({} trades in steps of {}).",
intent.size,
asset.coin,
asset.coin,
10f64.powi(-(asset.sz_decimals as i32))
)));
}
let mid = ctx.mid.filter(|m| m.is_finite() && *m > 0.0);
// A buy's cap rounds down and a sell's floor up, so the slippage never exceeds the cap.
let through = |px: f64| {
let (raw, dir) = if intent.is_buy {
(px * (1.0 + MAX_MARKET_SLIPPAGE), Round::Down)
} else {
(px * (1.0 - MAX_MARKET_SLIPPAGE), Round::Up)
};
round_price(raw, asset.sz_decimals, dir)
};
let positive = |px: f64, what: &str| {
if px.is_finite() && px > 0.0 {
Ok(px)
} else {
Err(Refusal::Invalid(format!("The {what} price must be greater than zero.")))
}
};
let small = |what: &str| {
Refusal::Invalid(format!(
"The {what} price is too small for {} (it rounds to zero at {} decimals).",
asset.coin,
crate::rounding::price_decimals(0.0, asset.sz_decimals)
))
};
let (price, trigger, t) = match intent.kind {
OrderKind::Limit { px, tif } => {
(round_price(positive(px, "limit")?, asset.sz_decimals, Round::Nearest), None, OrderTypeWire::limit(tif))
}
OrderKind::Market => {
let m = mid.ok_or_else(|| Refusal::NoMid { coin: asset.coin.clone() })?;
(through(m), None, OrderTypeWire::limit(Tif::Ioc))
}
OrderKind::Trigger { trigger_px, tpsl } => {
let trig = round_price(positive(trigger_px, "trigger")?, asset.sz_decimals, Round::Nearest);
if trig <= 0.0 {
return Err(small("trigger"));
}
(through(trig), Some(trig), OrderTypeWire::trigger(true, wire(trig)?, tpsl))
}
};
if price <= 0.0 {
return Err(small("limit"));
}
let order = OrderWire {
a: asset.id,
b: intent.is_buy,
p: wire(price)?,
s: wire(size)?,
r: intent.reduce_only,
t,
c: Some(cloid.to_string()),
};
let plan = OrderPlan { asset, intent: intent.clone(), size, price, trigger, notional: 0.0, wire: order };
let notional = check(&plan, ctx)?;
Ok(OrderPlan { notional, ..plan })
}
fn wire(x: f64) -> Result<String, Refusal> {
to_wire(x).map_err(|e| Refusal::Invalid(e.to_string()))
}
/// The caps, against the market in `ctx`. Returns the notional. Every order needs a current
/// mid: a limit through the market fills at the market, so its limit alone can't value it.
fn check(plan: &OrderPlan, ctx: &MarketCtx) -> Result<f64, Refusal> {
let mid = ctx
.mid
.filter(|m| m.is_finite() && *m > 0.0)
.ok_or_else(|| Refusal::NoMid { coin: plan.asset.coin.clone() })?;
// Reducing a position is never blocked by its leverage; anything else needs 3× or less.
if !plan.intent.reduce_only {
let lev = ctx.leverage.ok_or_else(|| Refusal::LeverageUnknown { coin: plan.asset.coin.clone() })?;
caps::check_leverage(lev.value)?;
}
// Value at the highest of the order's price, its trigger and the mid.
let reference = [Some(plan.price), plan.trigger, Some(mid)].into_iter().flatten().fold(0.0, f64::max);
let notional = plan.size * reference;
caps::check_notional(notional)?;
if plan.intent.kind == OrderKind::Market {
caps::check_slippage((plan.price / mid - 1.0).abs())?;
}
Ok(notional)
}
fn signed<A: Serialize>(
kind: &'static str,
key: &impl SigningKey,
nonces: &NonceManager,
action: &A,
now: u64,
) -> Result<Signed, Refusal> {
let nonce = nonces.next(now);
let expires_after = Some(now + EXPIRES_AFTER_MS);
let signature = sign_l1(key, action, nonce, expires_after).map_err(|e| Refusal::Invalid(format!("{e:#}")))?;
let action = serde_json::to_value(action).map_err(|e| Refusal::Invalid(e.to_string()))?;
Ok(Signed { kind, action, nonce, signature, expires_after })
}
/// Sign a planned order, after checking it again against the market now.
pub fn sign_order(
key: &impl SigningKey,
nonces: &NonceManager,
plan: &OrderPlan,
ctx: &MarketCtx,
now: u64,
) -> Result<Signed, Refusal> {
check(plan, ctx)?;
signed("order", key, nonces, &OrderAction::new(vec![plan.wire.clone()]), now)
}
/// Sign a modify of order `oid` into `plan`, with the same checks as a new order.
pub fn sign_modify(
key: &impl SigningKey,
nonces: &NonceManager,
oid: u64,
plan: &OrderPlan,
ctx: &MarketCtx,
now: u64,
) -> Result<Signed, Refusal> {
check(plan, ctx)?;
signed("modify", key, nonces, &ModifyAction::new(oid, plan.wire.clone()), now)
}
/// Cancel one order by id.
pub fn sign_cancel(
key: &impl SigningKey,
nonces: &NonceManager,
asset: &Asset,
oid: u64,
now: u64,
) -> Result<Signed, Refusal> {
signed("cancel", key, nonces, &CancelAction::new(vec![CancelWire { a: asset.id, o: oid }]), now)
}
/// Cancel one order by client order id.
pub fn sign_cancel_by_cloid(
key: &impl SigningKey,
nonces: &NonceManager,
asset: &Asset,
cloid: &str,
now: u64,
) -> Result<Signed, Refusal> {
if !valid_cloid(cloid) {
return Err(Refusal::Invalid("malformed client order id".into()));
}
let action = CancelByCloidAction::new(vec![CancelByCloidWire { asset: asset.id, cloid: cloid.into() }]);
signed("cancelByCloid", key, nonces, &action, now)
}
/// The kill switch: cancel every order in `orders` (`(coin, oid)`), one action per dex.
/// A coin the registry doesn't know (listed after it loaded) is skipped and returned, so one
/// stale entry never stops the other cancels.
pub fn sign_cancel_all(
key: &impl SigningKey,
nonces: &NonceManager,
reg: &AssetRegistry,
orders: &[(String, u64)],
now: u64,
) -> Result<(Vec<Signed>, Vec<String>), Refusal> {
// Delisted markets stay in the registry, so their orders still cancel.
let skipped: Vec<String> = orders.iter().filter(|(c, _)| reg.get(c).is_none()).map(|(c, _)| c.clone()).collect();
let mut out = Vec::new();
for dex in reg.dexes() {
let cancels: Vec<CancelWire> = orders
.iter()
.filter_map(|(coin, oid)| reg.get(coin).filter(|a| a.dex == dex).map(|a| CancelWire { a: a.id, o: *oid }))
.collect();
if !cancels.is_empty() {
out.push(signed("cancel", key, nonces, &CancelAction::new(cancels), now)?);
}
}
Ok((out, skipped))
}
/// Set leverage on one market: 1× to 3× (and no more than the market allows).
pub fn sign_update_leverage(
key: &impl SigningKey,
nonces: &NonceManager,
asset: &Asset,
cross: bool,
leverage: u32,
now: u64,
) -> Result<Signed, Refusal> {
caps::check_leverage(leverage)?;
if leverage == 0 || leverage > asset.max_leverage {
return Err(Refusal::Invalid(format!("{} allows 1× to {}×.", asset.coin, asset.max_leverage)));
}
if cross && asset.only_isolated {
return Err(Refusal::Invalid(format!("{} is isolated margin only.", asset.coin)));
}
signed("updateLeverage", key, nonces, &UpdateLeverageAction::new(asset.id, cross, leverage), now)
}
/// The dead man's switch: cancel all open orders at `at` (at least 5 s ahead), or clear it.
pub fn sign_schedule_cancel(
key: &impl SigningKey,
nonces: &NonceManager,
at: Option<u64>,
now: u64,
) -> Result<Signed, Refusal> {
if at.is_some_and(|t| t < now + 5_000) {
return Err(Refusal::Invalid("A scheduled cancel must be at least 5 seconds ahead.".into()));
}
signed("scheduleCancel", key, nonces, &ScheduleCancelAction::new(at), now)
}
/// Use up a nonce.
pub fn sign_noop(key: &impl SigningKey, nonces: &NonceManager, now: u64) -> Result<Signed, Refusal> {
signed("noop", key, nonces, &NoopAction::default(), now)
}
/// `terminal-<device> valid_until <ms>`, the device reduced to 14 lower-case letters,
/// digits and dashes.
pub fn agent_name(device: &str, valid_until: u64) -> String {
let mut d: String = device
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.map(|c| c.to_ascii_lowercase())
.take(14)
.collect();
if d.is_empty() {
d = "device".into();
}
format!("terminal-{d} valid_until {valid_until}")
}
/// The name an agent was approved under, without its `valid_until` suffix.
pub fn agent_base_name(name: &str) -> &str {
name.split(" valid_until ").next().unwrap_or(name).trim()
}
/// The expiry in an agent name, if it has one.
pub fn agent_valid_until(name: &str) -> Option<u64> {
name.split(" valid_until ").nth(1)?.trim().parse().ok()
}
/// An `approveAgent` for `agent`, named for this device, valid `days` (1 to 180).
pub fn approve_agent(agent: &Address, device: &str, days: u64, now: u64) -> ApproveAgentAction {
let until = now + days.clamp(1, MAX_AGENT_DAYS) * DAY_MS;
approve(agent, agent_name(device, until), now)
}
fn approve(agent: &Address, name: String, now: u64) -> ApproveAgentAction {
ApproveAgentAction {
kind: "approveAgent".into(),
hyperliquid_chain: Network::Testnet.hyperliquid_chain().into(),
signature_chain_id: format!("{SIGNATURE_CHAIN_ID:#x}"),
agent_address: address_hex(agent),
agent_name: name,
nonce: now,
}
}
/// Revoke: approve a fresh address under the same name, then drop its key, so the old agent
/// is replaced and nobody holds a key for the new one.
pub fn revoke_agent(base_name: &str, now: u64) -> ApproveAgentAction {
// The key is dropped (and zeroized) at the end of this statement.
let throwaway = crate::key::AgentKey::generate().address();
approve(&throwaway, format!("{} valid_until {}", agent_base_name(base_name), now + DAY_MS), now)
}
/// A user-signed action's `/exchange` body.
pub fn user_signed_body(action: &ApproveAgentAction, signature: &WireSignature) -> Value {
json!({
"action": action,
"nonce": action.nonce,
"signature": signature,
"vaultAddress": Value::Null,
"expiresAfter": Value::Null,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::fixtures::registry;
use crate::caps::{MAX_LEVERAGE, MAX_ORDER_NOTIONAL_USD};
use crate::key::AgentKey;
use crate::signing::recover_l1;
const NOW: u64 = 1_793_667_000_000;
const CLOID: &str = "0x0000000000000000000000000000002a";
fn lev(value: u32) -> Option<Leverage> {
Some(Leverage { value, cross: true })
}
fn ctx(mid: f64) -> MarketCtx {
MarketCtx { mid: Some(mid), leverage: lev(3) }
}
fn limit(coin: &str, buy: bool, size: f64, px: f64) -> OrderIntent {
OrderIntent {
coin: coin.into(),
is_buy: buy,
size,
kind: OrderKind::Limit { px, tif: Tif::Gtc },
reduce_only: false,
}
}
#[test]
fn a_far_limit_is_rounded_and_signed() {
let reg = registry();
let k = AgentKey::generate();
let n = NonceManager::new();
// BTC (szDecimals 5) far below a 76,662 mid: 0.000256 BTC → 0.00025 at 50,000 = $12.50.
let plan = plan_order(&reg, &limit("btc", true, 0.000256, 50_000.0), &ctx(76_662.5), CLOID).unwrap();
assert_eq!((plan.size(), plan.price(), plan.asset().id), (0.00025, 50_000.0, 3));
assert!((plan.notional() - 76_662.5 * 0.00025).abs() < 1e-9, "valued at the higher of price and mid");
assert_eq!(plan.wire().p, "50000");
assert_eq!(plan.wire().s, "0.00025");
assert_eq!(plan.cloid(), CLOID);
let s = sign_order(&k, &n, &plan, &ctx(76_662.5), NOW).unwrap();
assert_eq!(s.kind, "order");
assert_eq!(s.expires_after, Some(NOW + EXPIRES_AFTER_MS));
let body = s.body();
assert_eq!(body["action"]["orders"][0]["a"], 3);
assert!(body["action"].get("builder").is_none(), "never a builder fee");
let action = OrderAction::new(vec![plan.wire().clone()]);
assert_eq!(recover_l1(&action, s.nonce, s.expires_after, s.signature()).unwrap(), k.address());
// xyz:AAPL (750009, szDecimals 3).
let aapl = plan_order(&reg, &limit("xyz:AAPL", true, 0.0512, 250.123), &ctx(322.15), CLOID).unwrap();
assert_eq!((aapl.asset().id, aapl.size(), aapl.price()), (750009, 0.051, 250.12));
assert!(format!("{s:?}").contains("order") && !format!("{s:?}").contains(&s.signature().r));
}
#[test]
fn orders_over_100_dollars_are_refused_before_signing() {
let reg = registry();
// 0.002 BTC at 76,662 is $153: refused even with a limit far below the market.
let e = plan_order(&reg, &limit("BTC", true, 0.002, 50_000.0), &ctx(76_662.5), CLOID).unwrap_err();
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
assert!(e.to_string().contains("$100"));
// A sell limit below the market fills at the market, so the mid counts.
let e = plan_order(&reg, &limit("xyz:AAPL", false, 0.32, 300.0), &ctx(322.15), CLOID).unwrap_err();
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
// Exactly at the cap is fine.
let ok = plan_order(&reg, &limit("xyz:AAPL", true, 0.4, 250.0), &ctx(250.0), CLOID).unwrap();
assert!((ok.notional() - MAX_ORDER_NOTIONAL_USD).abs() < 1e-9);
// Checked again at signing, against the market then.
let plan = plan_order(&reg, &limit("xyz:AAPL", true, 0.3, 300.0), &ctx(320.0), CLOID).unwrap();
let moved = MarketCtx { mid: Some(400.0), leverage: lev(3) };
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &plan, &moved, NOW).unwrap_err();
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
}
#[test]
fn leverage_over_3x_is_refused() {
let reg = registry();
let at = |l| MarketCtx { mid: Some(76_662.5), leverage: lev(l) };
let o = limit("BTC", true, 0.0002, 50_000.0);
let e = plan_order(&reg, &o, &at(20), CLOID).unwrap_err();
assert_eq!(e, Refusal::Leverage { value: 20 }, "Hyperliquid's default 20× is over the cap");
let unknown = MarketCtx { mid: Some(76_662.5), leverage: None };
assert!(matches!(plan_order(&reg, &o, &unknown, CLOID).unwrap_err(), Refusal::LeverageUnknown { .. }));
assert!(plan_order(&reg, &o, &at(MAX_LEVERAGE), CLOID).is_ok());
// Reducing a position is never blocked by its leverage.
let close = OrderIntent { reduce_only: true, is_buy: false, ..o.clone() };
assert!(plan_order(&reg, &close, &at(20), CLOID).is_ok());
// And Terminal never sets more than 3×.
let (k, n) = (AgentKey::generate(), NonceManager::new());
let btc = reg.resolve("BTC").unwrap();
assert_eq!(sign_update_leverage(&k, &n, btc, true, 4, NOW).unwrap_err(), Refusal::Leverage { value: 4 });
assert!(sign_update_leverage(&k, &n, btc, true, 0, NOW).is_err());
let s = sign_update_leverage(&k, &n, btc, true, 3, NOW).unwrap();
assert_eq!(s.body()["action"], json!({"type": "updateLeverage", "asset": 3, "isCross": true, "leverage": 3}));
let isolated_only = reg.resolve("xyz:COIN").unwrap();
assert!(sign_update_leverage(&k, &n, isolated_only, true, 2, NOW).is_err(), "cross on an isolated-only market");
assert!(sign_update_leverage(&k, &n, isolated_only, false, 2, NOW).is_ok());
}
#[test]
fn market_orders_are_ioc_within_1_percent() {
let reg = registry();
let m = OrderIntent { kind: OrderKind::Market, ..limit("xyz:AAPL", true, 0.1, 0.0) };
let p = plan_order(&reg, &m, &ctx(322.15), CLOID).unwrap();
assert_eq!(p.wire().t, OrderTypeWire::limit(Tif::Ioc));
assert!(p.price() <= 322.15 * 1.01 && p.price() > 322.15 * 1.009, "{}", p.price());
let sell = plan_order(&reg, &OrderIntent { is_buy: false, ..m.clone() }, &ctx(322.15), CLOID).unwrap();
assert!(sell.price() >= 322.15 * 0.99 && sell.price() < 322.15 * 0.991, "{}", sell.price());
let no_mid = MarketCtx { mid: None, leverage: lev(3) };
assert!(matches!(plan_order(&reg, &m, &no_mid, CLOID).unwrap_err(), Refusal::NoMid { .. }));
// The mid fell more than 1% below the confirmed cap: review again.
let fell = MarketCtx { mid: Some(310.0), leverage: lev(3) };
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &p, &fell, NOW).unwrap_err();
assert!(matches!(e, Refusal::Slippage { .. }), "{e}");
}
#[test]
fn triggers_are_market_stops_with_a_slippage_cap() {
let reg = registry();
let sl = OrderIntent {
kind: OrderKind::Trigger { trigger_px: 300.0, tpsl: Tpsl::Sl },
reduce_only: true,
..limit("xyz:AAPL", false, 0.1, 0.0)
};
let p = plan_order(&reg, &sl, &ctx(322.15), CLOID).unwrap();
assert_eq!(p.wire().t, OrderTypeWire::trigger(true, "300".into(), Tpsl::Sl));
assert_eq!(p.trigger(), Some(300.0));
assert_eq!(p.price(), 297.0, "the fill limit is 1% through the trigger");
}
#[test]
fn bad_orders_are_refused_with_reasons() {
let reg = registry();
let c = ctx(76_662.5);
for (o, says) in [
(limit("para:TOTAL2", true, 1.0, 1.0), "para"),
(limit("HYPE/USDC", true, 1.0, 1.0), "spot"),
(limit("BTC", true, 0.000001, 50_000.0), "rounds to zero"),
(limit("BTC", true, 0.0002, -1.0), "price"),
(limit("BTC", true, f64::NAN, 50_000.0), "size"),
] {
let e = plan_order(&reg, &o, &c, CLOID).unwrap_err().to_string();
assert!(e.contains(says), "{}: {e}", o.coin);
}
assert!(plan_order(&reg, &limit("BTC", true, 0.0002, 50_000.0), &c, "42").is_err(), "cloid format");
}
#[test]
fn cancels_schedule_cancel_and_noop() {
let reg = registry();
let (k, n) = (AgentKey::generate(), NonceManager::new());
let aapl = reg.resolve("xyz:AAPL").unwrap();
let c = sign_cancel(&k, &n, aapl, 1, NOW).unwrap();
assert_eq!(c.body()["action"], json!({"type": "cancel", "cancels": [{"a": 750009, "o": 1}]}));
let c2 = sign_cancel_by_cloid(&k, &n, aapl, CLOID, NOW).unwrap();
assert_eq!(c2.body()["action"]["cancels"][0]["cloid"], CLOID);
assert!(c2.nonce > c.nonce, "nonces increase");
let (all, skipped) = sign_cancel_all(
&k,
&n,
&reg,
&[
("BTC".into(), 1),
("xyz:AAPL".into(), 2),
("ETH".into(), 3),
("xyz:NEWLY".into(), 9),
("xyz:TSLA".into(), 4),
],
NOW,
)
.unwrap();
assert_eq!(all.len(), 2, "one cancel per dex");
assert_eq!(all[0].body()["action"]["cancels"].as_array().unwrap().len(), 2);
assert_eq!(
all[1].body()["action"]["cancels"].as_array().unwrap().len(),
2,
"the unknown coin doesn't stop the rest"
);
assert_eq!(skipped, ["xyz:NEWLY"]);
assert!(sign_cancel_all(&k, &n, &reg, &[], NOW).unwrap().0.is_empty());
assert!(sign_schedule_cancel(&k, &n, Some(NOW + 1_000), NOW).is_err(), "at least 5 s ahead");
let sc = sign_schedule_cancel(&k, &n, Some(NOW + 60_000), NOW).unwrap();
assert_eq!(sc.body()["action"], json!({"type": "scheduleCancel", "time": NOW + 60_000}));
assert_eq!(
sign_schedule_cancel(&k, &n, None, NOW).unwrap().body()["action"],
json!({"type": "scheduleCancel"})
);
let noop = sign_noop(&k, &n, NOW).unwrap();
assert_eq!(noop.body()["action"], json!({"type": "noop"}));
assert_eq!(noop.body()["expiresAfter"], NOW + EXPIRES_AFTER_MS);
assert_eq!(noop.body()["vaultAddress"], Value::Null);
}
/// Greptile #37: without a mid, a sell limit below the market was valued at its limit.
#[test]
fn orders_need_a_current_mid_to_be_valued() {
let reg = registry();
let no_mid = MarketCtx { mid: None, leverage: lev(3) };
let sell = limit("xyz:AAPL", false, 0.32, 300.0);
assert!(matches!(plan_order(&reg, &sell, &no_mid, CLOID).unwrap_err(), Refusal::NoMid { .. }));
let plan = plan_order(&reg, &limit("xyz:AAPL", true, 0.1, 250.0), &ctx(322.15), CLOID).unwrap();
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &plan, &no_mid, NOW).unwrap_err();
assert!(matches!(e, Refusal::NoMid { .. }), "checked again at signing: {e}");
}
/// Greptile #37: a price that rounds to zero was signed as "p":"0" (and panicked in debug).
#[test]
fn prices_that_round_to_zero_are_refused() {
let reg = registry();
let e = plan_order(&reg, &limit("BTC", true, 0.001, 0.00000001), &ctx(76_662.5), CLOID).unwrap_err();
assert!(e.to_string().contains("too small"), "{e}");
let stop = OrderIntent {
kind: OrderKind::Trigger { trigger_px: 0.00000001, tpsl: Tpsl::Sl },
reduce_only: true,
..limit("BTC", false, 0.001, 0.0)
};
assert!(plan_order(&reg, &stop, &ctx(76_662.5), CLOID).unwrap_err().to_string().contains("too small"));
}
#[test]
fn agents_are_named_and_expire() {
let agent: Address = "0x5e9ee1089755c3435139848e47e6635505d5a13a".parse().unwrap();
let a = approve_agent(&agent, "Micky's MacBook Pro", DEFAULT_AGENT_DAYS, NOW);
let until = NOW + DEFAULT_AGENT_DAYS * DAY_MS;
assert_eq!(a.agent_name, format!("terminal-mickysmacbookp valid_until {until}"));
assert_eq!((a.kind.as_str(), a.hyperliquid_chain.as_str(), a.nonce), ("approveAgent", "Testnet", NOW));
assert_eq!(a.signature_chain_id, "0x66eee");
assert_eq!(a.agent_address, "0x5e9ee1089755c3435139848e47e6635505d5a13a");
assert_eq!(agent_base_name(&a.agent_name), "terminal-mickysmacbookp");
assert_eq!(agent_valid_until(&a.agent_name), Some(until));
let capped = approve_agent(&agent, "", 999, NOW);
assert_eq!(agent_valid_until(&capped.agent_name), Some(NOW + MAX_AGENT_DAYS * DAY_MS));
assert!(capped.agent_name.starts_with("terminal-device "), "{}", capped.agent_name);
assert_eq!(agent_valid_until(&approve_agent(&agent, "x", 0, NOW).agent_name), Some(NOW + DAY_MS));
// Revoking reuses the name with an address nobody holds a key for.
let r = revoke_agent("terminal-mickysmacbookp", NOW + 5);
assert_eq!(agent_base_name(&r.agent_name), "terminal-mickysmacbookp");
assert_ne!(r.agent_address, a.agent_address);
assert!(crate::key::parse_address(&r.agent_address).is_ok());
let body = user_signed_body(&a, &WireSignature { r: "0x1".into(), s: "0x2".into(), v: 27 });
assert_eq!(body["nonce"], NOW);
assert_eq!(body["action"]["agentName"], a.agent_name);
}
}
+287
View File
@@ -0,0 +1,287 @@
//! Signing pinned to the official Python SDK's published test vectors
//! (`hyperliquid-python-sdk` `tests/signing_test.py`), plus an `approveAgent` vector generated
//! with SDK 0.24.0's `sign_agent` (the SDK publishes none):
//!
//! ```text
//! python3 -m venv /tmp/hl-sdk-venv && /tmp/hl-sdk-venv/bin/pip install hyperliquid-python-sdk
//! /tmp/hl-sdk-venv/bin/python -I /tmp/hl-approve-vector.py # sign_agent(wallet, action, False)
//! ```
//!
//! The library signs only for testnet, so it is checked against the testnet half of each
//! vector. The mainnet half is checked here too, by [`mainnet_signature`], a test-only copy
//! of the phantom agent with `source: "a"`. It exists only in this test module, to show the
//! action hashing (which both networks share) is byte-exact. No library code can build it.
use alloy_primitives::{Address, B256};
use alloy_sol_types::{SolStruct, eip712_domain};
use serde::Serialize;
use crate::actions::*;
use crate::key::{AgentKey, SigningKey, sign_hash};
use crate::signing::*;
/// The SDK's public test key. Never funded; never used on a network.
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
fn key() -> AgentKey {
AgentKey::from_hex(SDK_KEY).unwrap()
}
/// TEST ONLY: the mainnet phantom agent (`source: "a"`), to check the SDK's mainnet vectors.
fn mainnet_signature(k: &AgentKey, connection_id: B256) -> WireSignature {
let domain = eip712_domain! { name: "Exchange", version: "1", chain_id: 1337, verifying_contract: Address::ZERO, };
let agent = Agent { source: "a".into(), connectionId: connection_id };
let sig = sign_hash(k, &agent.eip712_signing_hash(&domain)).unwrap();
WireSignature { r: format!("{:#x}", sig.r()), s: format!("{:#x}", sig.s()), v: 27 + u8::from(sig.v()) }
}
fn sig(r: &str, s: &str, v: u8) -> WireSignature {
WireSignature { r: r.into(), s: s.into(), v }
}
/// Check one action against both halves of an SDK vector.
fn check<A: Serialize>(action: &A, nonce: u64, mainnet: WireSignature, testnet: WireSignature) {
let k = key();
assert_eq!(sign_l1(&k, action, nonce, None).unwrap(), testnet, "testnet signature (the library's)");
let id = action_hash(action, nonce, None).unwrap();
assert_eq!(mainnet_signature(&k, id), mainnet, "mainnet signature (test-only phantom agent)");
assert_eq!(recover_l1(action, nonce, None, &testnet).unwrap(), k.address(), "recovers the signer");
}
fn order(a: u32, sz: &str, px: &str, t: OrderTypeWire, c: Option<String>) -> OrderAction {
OrderAction::new(vec![OrderWire { a, b: true, p: px.into(), s: sz.into(), r: false, t, c }])
}
#[derive(Serialize)]
struct Dummy {
#[serde(rename = "type")]
kind: &'static str,
num: u64,
}
#[test]
fn test_phantom_agent_creation_matches_production() {
let o = order(4, "0.0147", "1670.1", OrderTypeWire::limit(Tif::Ioc), None);
let h = action_hash(&o, 1677777606040, None).unwrap();
assert_eq!(format!("{h:#x}"), "0x0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908");
}
#[test]
fn test_l1_action_signing_matches() {
// float_to_int_for_hashing(1000) = 1000 × 10^8
let action = Dummy { kind: "dummy", num: 100_000_000_000 };
check(
&action,
0,
sig(
"0x53749d5b30552aeb2fca34b530185976545bb22d0b3ce6f62e31be961a59298",
"0x755c40ba9bf05223521753995abb2f73ab3229be8ec921f350cb447e384d8ed8",
27,
),
sig(
"0x542af61ef1f429707e3c76c5293c80d01f74ef853e34b76efffcb57e574f9510",
"0x17b8b32f086e8cdede991f1e2c529f5dd5297cbe8128500e00cbaf766204a613",
28,
),
);
}
#[test]
fn test_l1_action_signing_order_matches() {
check(
&order(1, "100", "100", OrderTypeWire::limit(Tif::Gtc), None),
0,
sig(
"0xd65369825a9df5d80099e513cce430311d7d26ddf477f5b3a33d2806b100d78e",
"0x2b54116ff64054968aa237c20ca9ff68000f977c93289157748a3162b6ea940e",
28,
),
sig(
"0x82b2ba28e76b3d761093aaded1b1cdad4960b3af30212b343fb2e6cdfa4e3d54",
"0x6b53878fc99d26047f4d7e8c90eb98955a109f44209163f52d8dc4278cbbd9f5",
27,
),
);
}
#[test]
fn test_l1_action_signing_order_with_cloid_matches() {
check(
&order(1, "100", "100", OrderTypeWire::limit(Tif::Gtc), Some("0x00000000000000000000000000000001".into())),
0,
sig(
"0x41ae18e8239a56cacbc5dad94d45d0b747e5da11ad564077fcac71277a946e3",
"0x3c61f667e747404fe7eea8f90ab0e76cc12ce60270438b2058324681a00116da",
27,
),
sig(
"0xeba0664bed2676fc4e5a743bf89e5c7501aa6d870bdb9446e122c9466c5cd16d",
"0x7f3e74825c9114bc59086f1eebea2928c190fdfbfde144827cb02b85bbe90988",
28,
),
);
}
#[test]
fn test_l1_action_signing_tpsl_order_matches() {
check(
&order(1, "100", "100", OrderTypeWire::trigger(true, "103".into(), Tpsl::Sl), None),
0,
sig(
"0x98343f2b5ae8e26bb2587daad3863bc70d8792b09af1841b6fdd530a2065a3f9",
"0x6b5bb6bb0633b710aa22b721dd9dee6d083646a5f8e581a20b545be6c1feb405",
27,
),
sig(
"0x971c554d917c44e0e1b6cc45d8f9404f32172a9d3b3566262347d0302896a2e4",
"0x206257b104788f80450f8e786c329daa589aa0b32ba96948201ae556d5637eac",
28,
),
);
}
#[test]
fn test_schedule_cancel_action() {
check(
&ScheduleCancelAction::new(None),
0,
sig(
"0x6cdfb286702f5917e76cd9b3b8bf678fcc49aec194c02a73e6d4f16891195df9",
"0x6557ac307fa05d25b8d61f21fb8a938e703b3d9bf575f6717ba21ec61261b2a0",
27,
),
sig(
"0xc75bb195c3f6a4e06b7d395acc20bbb224f6d23ccff7c6a26d327304e6efaeed",
"0x342f8ede109a29f2c0723bd5efb9e9100e3bbb493f8fb5164ee3d385908233df",
28,
),
);
check(
&ScheduleCancelAction::new(Some(123456789)),
0,
sig(
"0x609cb20c737945d070716dcc696ba030e9976fcf5edad87afa7d877493109d55",
"0x16c685d63b5c7a04512d73f183b3d7a00da5406ff1f8aad33f8ae2163bab758b",
28,
),
sig(
"0x4e4f2dbd4107c69783e251b7e1057d9f2b9d11cee213441ccfa2be63516dc5bc",
"0x706c656b23428c8ba356d68db207e11139ede1670481a9e01ae2dfcdb0e1a678",
27,
),
);
}
/// The SDK's testnet `test_sign_usd_transfer_action` checks the hand-built EIP-712 encoding
/// that `approveAgent` uses (this crate has no transfer action; the vector only exercises
/// the encoder).
#[test]
fn user_signed_encoding_matches_test_sign_usd_transfer_action() {
let dest: Address = "0x5e9ee1089755c3435139848e47e6635505d5a13a".parse().unwrap();
let fields = [Field::Str("Testnet"), Field::Str(&format!("{dest:#x}")), Field::Str("1"), Field::U64(1687816341423)];
let hash = user_signed_hash(
"HyperliquidTransaction:UsdSend(string hyperliquidChain,string destination,string amount,uint64 time)",
&fields,
0x66eee,
);
let s = sign_hash(&key(), &hash).unwrap();
assert_eq!(format!("{:#x}", s.r()), "0x637b37dd731507cdd24f46532ca8ba6eec616952c56218baeff04144e4a77073");
assert_eq!(format!("{:#x}", s.s()), "0x11a6a24900e6e314136d2592e2f8d502cd89b7c15b198e1bee043c9589f9fad7");
assert_eq!(27 + u8::from(s.v()), 27);
}
/// Generated with the Python SDK 0.24.0 (`sign_agent(wallet, action, is_mainnet=False)`).
#[test]
fn approve_agent_matches_python_sdk() {
let a = ApproveAgentAction {
kind: "approveAgent".into(),
hyperliquid_chain: "Testnet".into(),
signature_chain_id: "0x66eee".into(),
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
agent_name: "terminal-vector valid_until 1700000000000".into(),
nonce: 1700000000000,
};
let main = crate::key::MainKey::from_hex(SDK_KEY).unwrap();
let want = sig(
"0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25",
"0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321",
28,
);
assert_eq!(sign_approve_agent(&main, &a).unwrap(), want);
assert_eq!(format!("{:#x}", main.address()), "0x14791697260e4c9a71f18484c9f997b308e59325");
assert_eq!(recover_approve_agent(&a, &want).unwrap(), main.address());
// A wallet's r ‖ s ‖ v hex (as eth_signTypedData_v4 returns it) reads as the same signature.
let rsv = format!("0x{:0>64}{:0>64}{:02x}", &want.r[2..], &want.s[2..], want.v);
assert_eq!(WireSignature::from_rsv_hex(&rsv).unwrap(), want);
let zero_one = format!("{}{:02x}", &rsv[..rsv.len() - 2], want.v - 27);
assert_eq!(WireSignature::from_rsv_hex(&zero_one).unwrap(), want, "v as 0/1");
assert!(WireSignature::from_rsv_hex("0x1234").is_err());
// The same fields addressed to mainnet are refused rather than signed.
let mainnet = ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() };
assert!(sign_approve_agent(&main, &mainnet).is_err());
// The wallet payload names the same domain and message.
let td = approve_agent_typed_data(&a).unwrap();
assert_eq!(td["domain"]["chainId"], 0x66eee);
assert_eq!(td["message"]["hyperliquidChain"], "Testnet");
assert_eq!(td["primaryType"], "HyperliquidTransaction:ApproveAgent");
}
#[test]
fn expires_after_is_part_of_the_hash() {
let n = NoopAction::default();
let a = action_hash(&n, 5, None).unwrap();
let b = action_hash(&n, 5, Some(1_700_000_030_000)).unwrap();
assert_ne!(a, b);
let s = sign_l1(&key(), &n, 5, Some(1_700_000_030_000)).unwrap();
assert_eq!(recover_l1(&n, 5, Some(1_700_000_030_000), &s).unwrap(), key().address());
assert_ne!(recover_l1(&n, 5, None, &s).unwrap(), key().address(), "a different expiry recovers someone else");
// A corrupted signature recovers a different address instead of failing: the control
// for the live check.
let mut bad = s.clone();
bad.s = format!(
"{:#x}",
alloy_primitives::U256::from_str_radix(&bad.s[2..], 16).unwrap() ^ alloy_primitives::U256::from(1)
);
assert_ne!(recover_l1(&n, 5, Some(1_700_000_030_000), &bad).unwrap(), key().address());
}
/// The same approval signed by a main wallet held elsewhere (as Turnkey's `sign_raw_payload`
/// with `HASH_FUNCTION_NO_OP` signs the digest it is given) is the Python SDK's signature.
#[test]
fn a_remote_main_wallet_reproduces_the_approve_agent_vector() {
let a = ApproveAgentAction {
kind: "approveAgent".into(),
hyperliquid_chain: "Testnet".into(),
signature_chain_id: "0x66eee".into(),
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
agent_name: "terminal-vector valid_until 1700000000000".into(),
nonce: 1700000000000,
};
let remote = k256::ecdsa::SigningKey::from_slice(&alloy_primitives::hex::decode(SDK_KEY).unwrap()).unwrap();
let master = Address::from_private_key(&remote);
let signed = futures::executor::block_on(sign_approve_agent_with(&a, &master, |digest: B256| {
let (s, recid) = remote.sign_prehash_recoverable(digest.as_slice()).unwrap();
async move { Ok(alloy_primitives::Signature::from_signature_and_parity(s, recid.is_y_odd())) }
}))
.unwrap();
let want = sig(
"0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25",
"0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321",
28,
);
assert_eq!(signed, want);
}
/// `approveAgent` is only hashed for testnet's signature chain (0x66eee): never Arbitrum One
/// (0xa4b1, the chain Hyperliquid's docs show for mainnet), and never an unparseable id.
#[test]
fn approve_agent_is_hashed_only_at_the_testnet_signature_chain() {
let a = crate::trader::approve_agent(&key().address(), "vector", 30, 1_700_000_000_000);
assert!(approve_agent_hash(&a).is_ok());
for chain in ["0xa4b1", "0x1", "0x66EEE0", "", "zz"] {
let other = ApproveAgentAction { signature_chain_id: chain.into(), ..a.clone() };
assert!(approve_agent_hash(&other).is_err(), "{chain}");
}
// The chain id may be written in either case.
assert!(approve_agent_hash(&ApproveAgentAction { signature_chain_id: "0x66EEE".into(), ..a }).is_ok());
}
+1
View File
@@ -0,0 +1 @@
{"BTC": "76662.5", "ETH": "2259.4", "SOL": "93.5475", "HYPE": "90.8835"}
+1
View File
@@ -0,0 +1 @@
{"xyz:AAPL":"322.15","xyz:ALUMINIUM":"3192.3","xyz:AMD":"518.65","xyz:AMZN":"246.01","xyz:AVGO":"495.0","xyz:BABA":"110.03","xyz:BIRD":"4.1","xyz:BRENTOIL":"99.315","xyz:BX":"120.3","xyz:CL":"94.37","xyz:COIN":"137.51","xyz:COPPER":"6.5408","xyz:CORN":"4.5","xyz:COST":"1040.3","xyz:CRCL":"79.162","xyz:CRWV":"50.63","xyz:DKNG":"21.802","xyz:DXY":"97.15","xyz:EUR":"1.1188","xyz:EWJ":"92.885","xyz:EWY":"152.68","xyz:GME":"24.095","xyz:GOLD":"4251.4","xyz:GOOGL":"308.44","xyz:HIMS":"25.339","xyz:HOOD":"84.06","xyz:HYUNDAI":"205.24","xyz:IBM":"252.49","xyz:INTC":"63.753","xyz:IREN":"43.7","xyz:JP225":"63063.0","xyz:JPY":"157.015","xyz:KIOXIA":"140.0","xyz:KORU":"15.0","xyz:KR200":"883.85","xyz:LITE":"686.0","xyz:LLY":"1402.0","xyz:META":"659.55","xyz:MRVL":"196.4","xyz:MSFT":"508.25","xyz:MSTR":"137.95","xyz:MU":"838.03","xyz:NATGAS":"2.905","xyz:NBIS":"218.0","xyz:NFLX":"68.733","xyz:NOW":"114.0","xyz:NVDA":"207.0","xyz:ORCL":"119.69","xyz:PALLADIUM":"973.05","xyz:PLATINUM":"1452.7","xyz:PLTR":"172.5","xyz:PURRDAT":"9.0","xyz:RIVN":"13.215","xyz:RKLB":"150.0","xyz:SILVER":"49.349","xyz:SKHX":"1036.1","xyz:SMSN":"162.64","xyz:SNDK":"1420.2","xyz:SOFTBANK":"31.0","xyz:SP500":"7544.2","xyz:TSLA":"475.53","xyz:TSM":"400.49","xyz:TTF":"57.0","xyz:URANIUM":"85.0","xyz:URNM":"41.88","xyz:USAR":"8.895","xyz:VIX":"57.344","xyz:WHEAT":"6.33","xyz:XLE":"67.425","xyz:XYZ100":"30368.5"}
@@ -0,0 +1 @@
{"marginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMarginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMaintenanceMarginUsed":"52.105281","withdrawable":"339.171412","assetPositions":[{"type":"oneWay","position":{"coin":"xyz:TSLA","szi":"-1.483","leverage":{"type":"cross","value":10},"entryPx":"404.349","positionValue":"668.92198","unrealizedPnl":"-69.27118","returnOnEquity":"-1.1551919884","liquidationPx":"704.9387849597","marginUsed":"66.892198","maxLeverage":10,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:NVDA","szi":"-3.038","leverage":{"type":"cross","value":20},"entryPx":"218.372","positionValue":"642.71928","unrealizedPnl":"20.696851","returnOnEquity":"0.6239477768","liquidationPx":"338.5136601423","marginUsed":"32.135964","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:AAPL","szi":"0.316","leverage":{"type":"cross","value":20},"entryPx":"344.82","positionValue":"103.648","unrealizedPnl":"-5.31512","returnOnEquity":"-0.9755814628","liquidationPx":null,"marginUsed":"5.1824","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}}],"time":1793667763833}
@@ -0,0 +1 @@
[{"coin":"xyz:AAPL","side":"A","limitPx":"346.21","sz":"0.789","oid":63919996139,"timestamp":1793214466685,"triggerCondition":"N/A","isTrigger":false,"triggerPx":"0.0","children":[],"isPositionTpsl":false,"reduceOnly":false,"orderType":"Limit","origSz":"0.866","tif":"Alo","cloid":null}]
+1
View File
@@ -0,0 +1 @@
{"universe":[{"szDecimals":2,"name":"SOL","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"APT","maxLeverage":3,"marginTableId":3},{"szDecimals":2,"name":"ATOM","maxLeverage":10,"marginTableId":55},{"szDecimals":5,"name":"BTC","maxLeverage":40,"marginTableId":54},{"szDecimals":4,"name":"ETH","maxLeverage":25,"marginTableId":53},{"szDecimals":1,"name":"MATIC","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":3,"name":"BNB","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"AVAX","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"GMT","maxLeverage":2,"marginTableId":2},{"szDecimals":1,"name":"DYDX","maxLeverage":20,"marginTableId":20},{"szDecimals":1,"name":"APE","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"OP","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"kPEPE","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"ARB","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"RLB","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":1,"name":"WLD","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"HPOS","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":2,"name":"UNIBOT","maxLeverage":2,"marginTableId":2,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":2,"name":"COMP","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"FXS","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":4,"name":"MKR","maxLeverage":10,"marginTableId":55,"isDelisted":true},{"szDecimals":2,"name":"AAVE","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"SNX","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"RNDR","maxLeverage":20,"marginTableId":20,"isDelisted":true},{"szDecimals":1,"name":"LDO","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"SUI","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"INJ","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"STX","maxLeverage":10,"marginTableId":52},{"szDecimals":0,"name":"FTM","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":0,"name":"kSHIB","maxLeverage":10,"marginTableId":52},{"szDecimals":0,"name":"OX","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"FRIEND","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"ZRO","maxLeverage":10,"marginTableId":55,"onlyIsolated":true,"marginMode":"strictIsolated"},{"szDecimals":0,"name":"BLZ","maxLeverage":5,"marginTableId":5,"isDelisted":true},{"szDecimals":1,"name":"BANANA","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"FTT","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":2,"name":"TRB","maxLeverage":5,"marginTableId":5},{"szDecimals":0,"name":"CANTO","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":0,"name":"BIGTIME","maxLeverage":3,"marginTableId":3},{"szDecimals":0,"name":"NTRN","maxLeverage":5,"marginTableId":5,"isDelisted":true},{"szDecimals":0,"name":"KAS","maxLeverage":5,"marginTableId":5},{"szDecimals":0,"name":"BLUR","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"TIA","maxLeverage":10,"marginTableId":52},{"szDecimals":2,"name":"BSV","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"TON","maxLeverage":10,"marginTableId":52,"isDelisted":true},{"szDecimals":0,"name":"ADA","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"MINA","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"POLYX","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"GAS","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"AXL","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":0,"name":"PENDLE","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"STG","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":0,"name":"FET","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"STRAX","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":1,"name":"NEAR","maxLeverage":20,"marginTableId":20},{"szDecimals":0,"name":"MEME","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"ORDI","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"BADGER","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":2,"name":"NEO","maxLeverage":20,"marginTableId":20},{"szDecimals":2,"name":"ZEN","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"FIL","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"PYTH","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"RUNE","maxLeverage":10,"marginTableId":55},{"szDecimals":1,"name":"SUSHI","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"ILV","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":0,"name":"MAV"Line truncated
+1
View File
@@ -0,0 +1 @@
{"universe":[{"szDecimals":4,"name":"xyz:XYZ100","maxLeverage":30,"marginTableId":30,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:TSLA","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:NVDA","maxLeverage":20,"marginTableId":20,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:GOLD","maxLeverage":25,"marginTableId":25,"lastFeeScaleChangeTime":"1970-01-01T00:00:00","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:HOOD","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":2,"name":"xyz:INTC","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:PLTR","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:COIN","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:META","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AAPL","maxLeverage":20,"marginTableId":20,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MSFT","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:ORCL","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:GOOGL","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AMZN","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AMD","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MU","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-01-09T17:10:11.615872064","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:SNDK","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MSTR","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","lastFeeScaleChangeTime":"2026-02-20T12:46:19.276033666","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:CRCL","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:NFLX","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:COST","maxLeverage":20,"marginTableId":20,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:LLY","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:SKHX","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:TSM","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":2,"name":"xyz:JPY","maxLeverage":50,"marginTableId":50,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},Line truncated
+1
View File
@@ -0,0 +1 @@
[null, {"name": "test", "fullName": "test dex"}, {"name": "unit", "fullName": "unit dex"}, {"name": "scam", "fullName": "Scam DEX"}, {"name": "felix", "fullName": "felix"}, {"name": "volmex", "fullName": "volmex"}, {"name": "angry", "fullName": "angry"}, {"name": "TOON", "fullName": "Platoon"}, {"name": "VOLMEX", "fullName": "Volmex Dex"}, {"name": "test21", "fullName": "test21"}, {"name": "pluto", "fullName": "pluto dex"}, {"name": "zigg", "fullName": "zigg"}, {"name": "i<3fl", "fullName": "i love @frank_liquid"}, {"name": "KNETIQ", "fullName": "Kinetiq Exchange"}, {"name": "knetiq", "fullName": "Kinetiq Exchange - KNTQ"}, {"name": "ddot", "fullName": "ddot link"}, {"name": "venone", "fullName": "VentualsOne"}, {"name": "tstdex", "fullName": "New Coin"}, {"name": "vntls", "fullName": "Ventuals"}, {"name": "oracle", "fullName": "oracle test"}, {"name": "hb", "fullName": "hb test"}, {"name": "sports", "fullName": "Sports Player Perpetuals"}, {"name": "aura", "fullName": "aura.money"}, {"name": "hybet", "fullName": "hyperbet"}, {"name": "orange", "fullName": "ORANGE"}, {"name": "sekai", "fullName": "Sekai Perp DEX"}, {"name": "flow", "fullName": "Flow DEX"}, {"name": "x", "fullName": "XYZ"}, {"name": "t", "fullName": "XYZ"}, {"name": "sekaw", "fullName": "red dex"}, {"name": "gpu", "fullName": "Global Compute Exchange"}, {"name": "qwerty", "fullName": "qwerty dex"}, {"name": "merrli", "fullName": "merrli dex"}, {"name": "dex", "fullName": "dex-full-name"}, {"name": "olvb", "fullName": "onlyvibes"}, {"name": "nq", "fullName": "nq"}, {"name": "fx", "fullName": "hyperfx"}, {"name": "nqz", "fullName": "nqz"}, {"name": "btcx", "fullName": "BTC Perp Markets"}, {"name": "woof", "fullName": "woof perpetuals"}, {"name": "shiok", "fullName": "Shiok Lah"}, {"name": "z", "fullName": "XYZ"}, {"name": "hbtwo", "fullName": "Hyperbolic Finance 2"}, {"name": "tndex", "fullName": "test new dex"}, {"name": "rrrrr", "fullName": "rrrrr"}, {"name": "hbhb", "fullName": "Hyperbolic Finance 3"}, {"name": "stocks", "fullName": "redstocks"}, {"name": "nunchi", "fullName": "nunchi"}, {"name": "sqtwr", "fullName": "Squaretower"}, {"name": "y", "fullName": "XYZ"}, {"name": "hypodd", "fullName": "HyperOdd PEX"}, {"name": "strat", "fullName": "Stratium Crypto DEX"}, {"name": "zdex", "fullName": "zdex"}, {"name": "crayon", "fullName": "crayon dex"}, {"name": "walao", "fullName": "walao dex"}, {"name": "slpy", "fullName": "sleepy dex"}, {"name": "tsts", "fullName": "test dex"}, {"name": "mbx", "fullName": "A good dex"}, {"name": "food", "fullName": "A food dex"}, {"name": "meng", "fullName": "Meong meong meong"}, {"name": "rawr", "fullName": "Rawr >w<"}, {"name": "mmmg", "fullName": "Meong2"}, {"name": "msdx", "fullName": "BTC Perp Markets"}, {"name": "wrld", "fullName": "Hyperworld"}, {"name": "hodd", "fullName": "HyperOdd Leveraged Prediction Markets"}, {"name": "xyz", "fullName": "XYZ"}, {"name": "flx", "fullName": "flx"}, {"name": "nmx", "fullName": "NM USDH DEX"}, {"name": "pyth", "fullName": "Pyth Network test 1"}, {"name": "msda", "fullName": "MSD1 BTC Perp"}, {"name": "ho", "fullName": "HyperOdd Prediction Perps"}, {"name": "flsh", "fullName": "HyperFlash"}, {"name": "hyfl", "fullName": "HyperFlash"}, {"name": "csan", "fullName": "computeshield"}, {"name": "sedx", "fullName": "sekai dex try"}, {"name": "rbbt", "fullName": "Rabbit"}, {"name": "rbte", "fullName": "RabbitE"}, {"name": "demo", "fullName": "demo dex"}, {"name": "flxn", "fullName": "flxn"}, {"name": "bsx", "fullName": "BasisX dex"}, {"name": "loh", "fullName": "Lohcrypto DEX by Tovkes"}, {"name": "xvm", "fullName": "EXCHANGE VIRTUAL MACHINE"}, {"name": "dydx", "fullName": "NEXON DEX"}, {"name": "fzx", "fullName": "Felix Exchange"}, {"name": "abdc", "fullName": "America's Best Dance Crew"}, {"name": "fam", "fullName": "Famous Dex"}, {"name": "gato", "fullName": "EL GATO THE MF"}, {"name": "bart", "fullName": "Bart Simpson Dex"}, {"name": "sss", "fullName": "SSS"}, {"name": "owal", "fullName": "OWALA"}, {"name": "gg", "fullName": "hyperliquid blockchain is my bitch"}, {"name": "fdnk", "fullName": "FDNK"}, {"name": "trve", "fullName": "Trove"}, {"name": "odd", "fullName": "HyperOdd Prediction Perps"}, {"name": "trv", "fullName": "Trove"}, {"name": "qwrn", "fullName": "Qwrn"}, {"name": "zzzz", "fullName": "Felix Exchange"}, {"name": "gass", "fullName": "gass Exchange"}, {"name": "whtm", "fullName": "Whtm"}, {"name": "rip", "fullName": "RIP"}, {"name": "sand", "fullName": "Dune Sand DEX"}, {"name": "hmax", "fullName": "hypermax"}, {"name": "slob", "fullName": "she slob on my blob till i hyperliquid"}, {"name": "hyfi", "fullName": "HYFI"}, {"name": "str", "fullName": "Stratium DEX"}, {"name": "trov", "fullName": "Trove"}, {"name": "erez", "fullName": "Erez"}, {"name": "paef", "fullName": "Paef"}, {"name": "mcd", "fullName": "Old MacDonald's Farm"}, {"name": "birb", "fullName": "BIRB BIRB BIRB BIRB BIRB"}, {"name": "plsh", "fullName": "My beloved plushies"}, {"name": "cnst", "fulLine truncated
+8
View File
@@ -0,0 +1,8 @@
{"channel":"clearinghouseState","data":{"dex":"","user":"0x00000000000000000000000000000000000f107e","clearinghouseState":{"marginSummary":{"accountValue":"0.0","totalNtlPos":"0.0","totalRawUsd":"0.0","totalMarginUsed":"0.0"},"crossMarginSummary":{"accountValue":"0.0","totalNtlPos":"0.0","totalRawUsd":"0.0","totalMarginUsed":"0.0"},"crossMaintenanceMarginUsed":"0.0","withdrawable":"0.0","assetPositions":[],"time":1793667764987}}}
{"channel":"clearinghouseState","data":{"dex":"xyz","user":"0x00000000000000000000000000000000000f107e","clearinghouseState":{"marginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMarginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMaintenanceMarginUsed":"52.105281","withdrawable":"339.171412","assetPositions":[{"type":"oneWay","position":{"coin":"xyz:TSLA","szi":"-1.483","leverage":{"type":"cross","value":10},"entryPx":"404.349","positionValue":"668.92198","unrealizedPnl":"-69.27118","returnOnEquity":"-1.1551919884","liquidationPx":"704.9387849597","marginUsed":"66.892198","maxLeverage":10,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:NVDA","szi":"-3.038","leverage":{"type":"cross","value":20},"entryPx":"218.372","positionValue":"642.71928","unrealizedPnl":"20.696851","returnOnEquity":"0.6239477768","liquidationPx":"338.5136601423","marginUsed":"32.135964","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:AAPL","szi":"0.316","leverage":{"type":"cross","value":20},"entryPx":"344.82","positionValue":"103.648","unrealizedPnl":"-5.31512","returnOnEquity":"-0.9755814628","liquidationPx":null,"marginUsed":"5.1824","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}}],"time":1793667765068}}}
{"channel":"openOrders","data":{"dex":"","user":"0x00000000000000000000000000000000000f107e","orders":[]}}
{"channel":"openOrders","data":{"dex":"xyz","user":"0x00000000000000000000000000000000000f107e","orders":[{"coin":"xyz:AAPL","side":"A","limitPx":"346.21","sz":"0.789","oid":63919996139,"timestamp":1793214466685,"triggerCondition":"N/A","isTrigger":false,"triggerPx":"0.0","children":[],"isPositionTpsl":false,"reduceOnly":false,"orderType":"Limit","origSz":"0.866","tif":"Alo","cloid":null}]}}
{"channel":"userFills","data":{"isSnapshot":true,"user":"0x00000000000000000000000000000000000f107e","fills":[{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207760467,"startPosition":"-1.042","dir":"Open Short","closedPnl":"0.0","hash":"0x3bfad0510cfa9bb33d74042cd1bbfe010900e836a7fdba85dfc37ba3cbfe759d","oid":63912181528,"crossed":false,"fee":"0.000364","tid":1069007623150225,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207763442,"startPosition":"-1.072","dir":"Open Short","closedPnl":"0.0","hash":"0xf5b2dc2fbb4da86bf72c042cd1bc2d010b00f4155640c73e997b87827a418256","oid":63912181528,"crossed":false,"fee":"0.000364","tid":971524471857229,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207766547,"startPosition":"-1.102","dir":"Open Short","closedPnl":"0.0","hash":"0x1e5e84a6252ddbe31fd8042cd1bc570107009c8bc020fab5c2272ff8e421b5cd","oid":63912181528,"crossed":false,"fee":"0.000364","tid":123747502287917,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207769432,"startPosition":"-1.132","dir":"Open Short","closedPnl":"0.0","hash":"0x2ad26b5a41f6e0bc2c4c042cd1bc81010a00833fdcf9ff8ece9b16ad00fabaa6","oid":63912181528,"crossed":false,"fee":"0.000364","tid":1077238072313899,"feeToken":"USDC","twapId":null}]}}
{"channel":"userFundings","data":{"isSnapshot":true,"user":"0x00000000000000000000000000000000000f107e","fundings":[]}}
{"channel":"activeAssetData","data":{"user":"0x00000000000000000000000000000000000f107e","coin":"xyz:AAPL","leverage":{"type":"cross","value":20},"maxTradeSzs":["20.928","20.646"],"availableToTrade":["343.2192","338.5944"],"markPx":"328.0"}}
{"channel":"allMids","data":{"dex":"xyz","mids":{"xyz:AAPL":"322.15","xyz:ALUMINIUM":"3192.3","xyz:AMD":"519.2","xyz:AMZN":"246.33","xyz:AVGO":"495.0","xyz:BABA":"110.03"}}}
+232
View File
@@ -0,0 +1,232 @@
//! This build cannot reach or sign for Hyperliquid mainnet. These tests fail if that changes.
use std::path::Path;
use tr_hl::Network;
use tr_hl::network::{API_URL, WS_URL};
/// Mainnet hosts that must never appear in this crate (the testnet hosts are
/// `api.hyperliquid-testnet.xyz`, which none of these match).
const MAINNET_HOSTS: [&str; 3] = ["api.hyperliquid.xyz", "app.hyperliquid.xyz", "wss://api.hyperliquid.xyz"];
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
sources(&p, out);
} else if p.extension().is_some_and(|x| x == "rs" || x == "toml") {
out.push(p);
}
}
}
#[test]
fn the_crate_names_no_mainnet_host() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut files = vec![root.join("Cargo.toml")];
for d in ["src", "examples", "tests"] {
if root.join(d).exists() {
sources(&root.join(d), &mut files);
}
}
assert!(files.len() > 10, "found the sources: {files:?}");
let me = file!().rsplit('/').next().unwrap();
for f in files {
// This file lists the hosts it looks for.
if f.file_name().is_some_and(|n| n == me) {
continue;
}
let text = std::fs::read_to_string(&f).unwrap();
for host in MAINNET_HOSTS {
assert!(!text.contains(host), "{} names the mainnet host {host}", f.display());
}
}
}
#[test]
fn the_endpoints_are_the_testnet_hosts() {
assert_eq!(API_URL, "https://api.hyperliquid-testnet.xyz");
assert_eq!(WS_URL, "wss://api.hyperliquid-testnet.xyz/ws");
let n = Network::default();
assert!(n.info_url().starts_with("https://api.hyperliquid-testnet.xyz/"));
assert!(n.exchange_url().starts_with("https://api.hyperliquid-testnet.xyz/"));
}
#[test]
fn there_is_one_network_and_it_signs_as_testnet() {
// Adding a variant makes this match non-exhaustive, so the test stops compiling.
match Network::default() {
Network::Testnet => {}
}
assert_eq!(Network::Testnet.phantom_source(), "b");
assert_eq!(Network::Testnet.hyperliquid_chain(), "Testnet");
assert_eq!(Network::Testnet.label(), "TESTNET");
}
#[test]
fn stored_preferences_cannot_select_mainnet() {
#[derive(serde::Deserialize)]
struct Prefs {
network: Network,
}
for v in ["\"mainnet\"", "\"Mainnet\"", "\"MAINNET\"", "\"testnet\"", "\"arbitrum\"", "0", "true", "null"] {
let p: Prefs = serde_json::from_str(&format!(r#"{{"network": {v}}}"#)).unwrap();
assert_eq!(p.network, Network::Testnet, "{v}");
}
}
#[test]
fn signatures_are_testnet_signatures() {
use tr_hl::actions::ApproveAgentAction;
use tr_hl::signing::{recover_approve_agent, sign_approve_agent};
use tr_hl::trader::approve_agent;
let main = tr_hl::MainKey::generate();
let agent = tr_hl::AgentKey::generate();
let a = approve_agent(&tr_hl::SigningKey::address(&agent), "ci", 30, 1_793_667_000_000);
assert_eq!(a.hyperliquid_chain, "Testnet");
let sig = sign_approve_agent(&main, &a).unwrap();
assert_eq!(recover_approve_agent(&a, &sig).unwrap(), tr_hl::SigningKey::address(&main));
// Re-addressed to mainnet, the same action can't be signed, and the testnet signature
// doesn't recover the signer for it.
let mainnet = ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a };
assert!(sign_approve_agent(&main, &mainnet).is_err());
assert!(recover_approve_agent(&mainnet, &sig).is_err());
}
/// A main wallet held elsewhere (Turnkey) signs a digest this crate hands it. It is only
/// ever asked for a testnet `approveAgent` at the testnet signature chain, and its answer is
/// used only if it recovers the main wallet.
#[test]
fn a_remote_main_wallet_signs_only_testnet_approvals() {
use std::cell::Cell;
use tr_hl::actions::ApproveAgentAction;
use tr_hl::signing::{recover_approve_agent, sign_approve_agent_with};
use tr_hl::trader::approve_agent;
// The remote wallet is a plain k256 key here, signing whatever 32 bytes it is given.
let main = k256::ecdsa::SigningKey::random(&mut rand_core::OsRng);
let master = alloy_primitives::Address::from_private_key(&main);
let sign = |key: &k256::ecdsa::SigningKey,
digest: alloy_primitives::B256|
-> anyhow::Result<alloy_primitives::Signature> {
let (sig, recid) = key.sign_prehash_recoverable(digest.as_slice())?;
Ok(alloy_primitives::Signature::from_signature_and_parity(sig, recid.is_y_odd()))
};
let agent = tr_hl::AgentKey::generate();
let a = approve_agent(&tr_hl::SigningKey::address(&agent), "ci", 180, 1_793_667_000_000);
assert_eq!((a.hyperliquid_chain.as_str(), a.signature_chain_id.as_str()), ("Testnet", "0x66eee"));
let asked = Cell::new(0);
let remote = |digest: alloy_primitives::B256| {
asked.set(asked.get() + 1);
let sig = sign(&main, digest);
async move { sig }
};
let sig = futures::executor::block_on(sign_approve_agent_with(&a, &master, remote)).unwrap();
assert_eq!(recover_approve_agent(&a, &sig).unwrap(), master);
assert_eq!(asked.get(), 1);
// Mainnet, or a mainnet signature chain (Arbitrum One, 0xa4b1), never reaches the signer.
for bad in [
ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() },
ApproveAgentAction { signature_chain_id: "0xa4b1".into(), ..a.clone() },
ApproveAgentAction { kind: "usdSend".into(), ..a.clone() },
] {
assert!(futures::executor::block_on(sign_approve_agent_with(&bad, &master, remote)).is_err(), "{bad:?}");
}
assert_eq!(asked.get(), 1, "the remote signer was asked to sign a non-testnet action");
// A signature from any other key is refused rather than sent.
let other = k256::ecdsa::SigningKey::random(&mut rand_core::OsRng);
let wrong = |digest: alloy_primitives::B256| {
let sig = sign(&other, digest);
async move { sig }
};
let err = futures::executor::block_on(sign_approve_agent_with(&a, &master, wrong)).unwrap_err();
assert!(err.to_string().contains("not the wallet's"), "{err}");
}
/// The built-in wallet (Turnkey's API; here its local stand-in) is never sent anything for
/// mainnet: the request is refused before it is made.
#[tokio::test]
async fn the_built_in_wallet_is_never_asked_to_sign_for_mainnet() {
use tr_hl::actions::ApproveAgentAction;
use tr_hl::signing::sign_approve_agent_with;
use tr_hl::trader::approve_agent;
let mock = tr_turnkey::mock::MockTurnkey::start(0, None).await.unwrap();
let key = tr_turnkey::SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 60);
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let session = tr_turnkey::Session::accept(key, &token, now).unwrap();
let client = tr_turnkey::Client::with_base(&mock.base).unwrap();
let master: alloy_primitives::Address = mock.address().parse().unwrap();
let sign = |a: ApproveAgentAction| {
let (client, session) = (&client, &session);
async move {
sign_approve_agent_with(&a, &master, |d| async move { client.sign_digest(session, &master, &d).await })
.await
}
};
let a = approve_agent(&tr_hl::SigningKey::address(&tr_hl::AgentKey::generate()), "ci", 180, 1_793_667_000_000);
for bad in [
ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() },
ApproveAgentAction { signature_chain_id: "0xa4b1".into(), ..a.clone() },
] {
assert!(sign(bad).await.is_err());
}
assert!(mock.requests().is_empty(), "the wallet was asked to sign a mainnet action");
// The testnet approval goes through and is the only thing signed.
assert!(sign(a).await.is_ok());
let paths: Vec<String> = mock.requests().into_iter().map(|(p, _)| p).collect();
assert_eq!(paths, ["/public/v1/submit/sign_raw_payload"]);
}
/// Code added for the built-in wallet names no mainnet host either: the Turnkey crate, the
/// app's wallet modules and the sign-in page.
#[test]
fn the_wallet_code_names_no_mainnet_host() {
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut files = Vec::new();
for d in [
"crates/turnkey/src",
"crates/turnkey/tests",
"crates/turnkey/examples",
"site/src/routes/wallet",
"site/src/lib/wallet",
] {
if repo.join(d).exists() {
sources_any(&repo.join(d), &mut files);
}
}
files.push(repo.join("crates/turnkey/Cargo.toml"));
for f in [
"app/src/app/wallet.rs",
"app/src/app/wallet_view.rs",
"app/src/app/hl_web.rs",
"app/src/app/hl.rs",
"app/src/app/hl_view.rs",
] {
if repo.join(f).exists() {
files.push(repo.join(f));
}
}
assert!(files.iter().filter(|f| f.starts_with(repo.join("crates/turnkey"))).count() >= 5, "{files:?}");
for f in files {
let text = std::fs::read_to_string(&f).unwrap();
for host in MAINNET_HOSTS {
assert!(!text.contains(host), "{} names the mainnet host {host}", f.display());
}
}
}
fn sources_any(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
sources_any(&p, out);
} else if p.extension().is_some_and(|x| ["rs", "toml", "ts", "svelte", "js", "html"].iter().any(|e| x == *e)) {
out.push(p);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
//! `approveAgent` signed by the built-in wallet, through the Turnkey client, against the
//! local Turnkey stand-in (`tr_turnkey::mock`, not Turnkey). The wallet there is the Python
//! SDK's public test key, so the signature must be the SDK's own vector.
use alloy_primitives::Address;
use tr_hl::actions::ApproveAgentAction;
use tr_hl::signing::{approve_agent_hash, sign_approve_agent_with};
use tr_turnkey::mock::MockTurnkey;
use tr_turnkey::{Client, Session, SessionKey};
/// The SDK's public test key (signing_test.py). Never funded; never used on a network.
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
}
#[tokio::test]
async fn the_built_in_wallet_reproduces_the_python_sdk_vector() {
let mock = MockTurnkey::start(0, Some(SDK_KEY)).await.unwrap();
let key = SessionKey::generate();
let session = Session::accept(
SessionKey::from_hex(&key.to_hex()).unwrap(),
&mock.mint_session(&key.public_hex(), 60),
now_secs(),
)
.unwrap();
let client = Client::with_base(&mock.base).unwrap();
let master: Address = client.ethereum_account(&session).await.unwrap().address.parse().unwrap();
assert_eq!(format!("{master:#x}"), "0x14791697260e4c9a71f18484c9f997b308e59325");
let a = ApproveAgentAction {
kind: "approveAgent".into(),
hyperliquid_chain: "Testnet".into(),
signature_chain_id: "0x66eee".into(),
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
agent_name: "terminal-vector valid_until 1700000000000".into(),
nonce: 1700000000000,
};
let sig = sign_approve_agent_with(&a, &master, |d| {
let (client, session) = (&client, &session);
async move { client.sign_digest(session, &master, &d).await }
})
.await
.unwrap();
assert_eq!(sig.r, "0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25");
assert_eq!(sig.s, "0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321");
assert_eq!(sig.v, 28);
// Turnkey was handed exactly the testnet approval's digest.
let signed: Vec<_> = mock.requests().into_iter().filter(|(p, _)| p.ends_with("/sign_raw_payload")).collect();
assert_eq!(signed.len(), 1);
let digest = approve_agent_hash(&a).unwrap();
assert_eq!(signed[0].1["parameters"]["payload"], alloy_primitives::hex::encode(digest));
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "tr-proto"
description = "Client protocol between Terminal clients and the Terminal data server"
version.workspace = true
edition.workspace = true
# Shared by the server, the desktop app and the browser build, so it must build for
# wasm32-unknown-unknown: serde, tr-core and the two codecs only, nothing async or networked.
[dependencies]
tr-core.workspace = true
serde.workspace = true
serde_json.workspace = true
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
+629
View File
@@ -0,0 +1,629 @@
//! The protocol between Terminal clients (desktop, web, CLI) and the Terminal data server
//! (`tr-gateway`), as designed in `docs/architecture/server.md` §4.
//!
//! Each message is one websocket frame. postcard over binary frames is the default encoding;
//! JSON over text frames is a debug encoding a client asks for in [`ClientMsg::Hello`]. The
//! server reads binary frames as postcard and text frames as JSON, and answers in the encoding
//! the client's `Hello` named.
//!
//! # Evolving the protocol
//!
//! postcard is not self-describing: an enum variant travels as its index and a struct as its
//! fields in order. So:
//! - enum variants are only ever appended, never reordered or removed;
//! - a struct never gains or loses a field (a new field means a new message variant);
//! - any other change bumps [`PROTOCOL_VERSION`], and the server keeps serving N and N-1.
//!
//! The `wire_tags_are_stable` test pins every variant's index. The design's later variants
//! (`DocOp`, `DocAck`/`DocConflict`, and the `Ctx`, Congress, Portfolio, Docs and Alerts
//! topics) are not here yet; each is appended after the variants below when a server serves it.
//! [`Bar`] and [`Timeframe`] come from `tr-core`, so their field and variant order is part of
//! the wire format too.
use std::fmt;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub use tr_core::{Bar, Timeframe};
/// Bumped on any change the evolution rules above don't allow.
pub const PROTOCOL_VERSION: u16 = 1;
/// Client-chosen subscription id, unique per connection.
pub type SubId = u32;
/// Client-chosen request id, unique per connection.
pub type ReqId = u32;
/// A market as a client names it: a venue-native key (`BTC`, `xyz:TSLA`, `@107`, `PURR/USDC`)
/// or a market key the server maps to one (`BTCUSD Curncy`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MarketKey(pub String);
impl MarketKey {
pub fn new(key: impl Into<String>) -> Self {
Self(key.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for MarketKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Encoding {
Postcard,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientInfo {
/// For example `terminal-desktop` or `tr-cli`.
pub name: String,
pub version: String,
}
/// Reserved for session resume (§4.3). Servers ignore it for now.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ResumeToken(pub String);
/// Client to server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ClientMsg {
/// The first message on every connection. `token` is reserved for sign-in (§8) and is
/// ignored for now; `resume` likewise.
Hello {
version: u16,
encoding: Encoding,
client: ClientInfo,
token: Option<String>,
resume: Option<ResumeToken>,
},
Subscribe {
id: SubId,
topic: TopicReq,
},
Unsubscribe {
id: SubId,
},
/// The client saw a gap in `seq`; the server answers with a fresh `Snapshot`.
Resync {
id: SubId,
},
Request {
id: ReqId,
req: Rpc,
},
Ping {
t: u64,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TopicReq {
/// Prices for many markets in one subscription, at most `max_hz` deltas a second.
Quotes { keys: Vec<MarketKey>, max_hz: u8 },
/// Snapshot: the last `history` bars, the forming bar last. Deltas: [`BarDelta`].
Bars { key: MarketKey, tf: Timeframe, history: u32 },
/// The top `depth` levels a side, bucketed to multiples of `group` when set.
Book { key: MarketKey, depth: u8, group: Option<f64> },
/// Snapshot: recent trades. Deltas: new trades, oldest first.
Trades { key: MarketKey },
}
/// One-shot requests.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Rpc {
/// Up to `limit` bars before `end` (Unix seconds; `None` = now), oldest first. The same
/// contract as `MarketData::history` in `tr-data`.
History { key: MarketKey, tf: Timeframe, end: Option<i64>, limit: u32 },
/// Every market the server can stream right now.
Markets,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RpcResult {
History(Vec<Bar>),
Markets(Vec<MarketInfo>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MarketInfo {
pub key: MarketKey,
/// Where it trades: a Hyperliquid perp dex (`""` is the core dex, `xyz`, ...) or `spot`.
pub venue: String,
pub kind: InstrumentKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InstrumentKind {
Perp,
Spot,
}
/// Opaque per-connection id, for logs and support.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(pub u64);
/// Reserved for sign-in (§8). Always `None` for now.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserInfo {
pub id: String,
pub name: String,
}
/// What the server accepts on this connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Limits {
pub max_subscriptions: u32,
pub max_quote_keys: u32,
pub max_book_depth: u8,
pub max_history: u32,
}
/// Server to client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ServerMsg {
Welcome {
version: u16,
session: SessionId,
user: Option<UserInfo>,
server_time_ms: i64,
limits: Limits,
},
/// Full state. Resets the subscription's `seq`.
Snapshot {
id: SubId,
seq: u64,
data: TopicData,
},
/// `seq` is the previous message's `seq` + 1.
Delta {
id: SubId,
seq: u64,
data: TopicDelta,
},
/// The server dropped deltas for this subscription; a `Snapshot` follows.
Resync {
id: SubId,
reason: ResyncReason,
},
Reply {
id: ReqId,
result: Result<RpcResult, ApiError>,
},
Status {
id: SubId,
status: TopicStatus,
},
Pong {
t: u64,
server_time_ms: i64,
},
Error {
code: ErrorCode,
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TopicData {
Quotes(Vec<Quote>),
Bars(Vec<Bar>),
Book(Book),
Trades(Vec<Trade>),
}
/// For state topics (quotes, book) a delta is the new value, so a lost one is harmless.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TopicDelta {
/// The quotes that changed since the last message.
Quotes(Vec<Quote>),
Bar(BarDelta),
Book(Book),
Trades(Vec<Trade>),
}
/// Bars stream as the forming bar's latest state, and a `Closed` bar with its final values
/// before the first `Forming` of the next bar. The server may drop a `Forming` that a later
/// message for the same `ts` supersedes; it never drops a `Closed`. Clients apply `Closed` by
/// replacing the bar with that `ts` (§4.3).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum BarDelta {
Forming(Bar),
Closed(Bar),
}
impl BarDelta {
pub fn bar(&self) -> &Bar {
match self {
BarDelta::Forming(b) | BarDelta::Closed(b) => b,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Quote {
/// The key as the client sent it.
pub key: MarketKey,
/// The venue market that backs it (`BTC` for `BTCUSD Curncy`).
pub source: MarketKey,
pub px: f64,
pub basis: PriceBasis,
/// The price 24 hours earlier, when the venue publishes one.
pub prev_day: Option<f64>,
/// When the server received this price (Unix milliseconds).
pub ts_ms: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PriceBasis {
Mid,
Mark,
Oracle,
Last,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Book {
/// Best first: bids descending, asks ascending.
pub bids: Vec<Level>,
pub asks: Vec<Level>,
/// The venue's time for this book (Unix milliseconds).
pub time_ms: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Level {
pub px: f64,
pub sz: f64,
/// Orders at this level (0 when grouped levels were merged and the venue gave none).
pub n: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Trade {
pub px: f64,
pub sz: f64,
/// The aggressor's side.
pub side: Side,
/// The venue's trade time (Unix milliseconds).
pub time_ms: i64,
/// Venue trade id; unique together with `time_ms`.
pub tid: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Side {
Buy,
Sell,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ResyncReason {
/// The client fell too far behind an event topic.
Overflow,
/// The server's own upstream reconnected, so its state may have jumped.
Upstream,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TopicStatus {
Live,
/// No fresh data since `since_ms` (Unix milliseconds); the last values may be old.
Stale {
since_ms: i64,
},
/// The server can't serve this subscription. It stays open but gets no data.
Unavailable {
reason: UnavailableReason,
detail: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum UnavailableReason {
UnknownMarket,
/// The server is at its upstream subscription budget.
Capacity,
Unsupported,
Upstream,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ErrorCode {
BadMessage,
UnsupportedVersion,
HelloRequired,
DuplicateId,
UnknownId,
TooManySubscriptions,
SlowConsumer,
UnknownMarket,
Upstream,
Internal,
/// The client sent messages faster than `Limits` allows; the server ignored this one.
RateLimited,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiError {
pub code: ErrorCode,
pub message: String,
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}: {}", self.code, self.message)
}
}
impl std::error::Error for ApiError {}
/// One encoded websocket frame.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Frame {
Binary(Vec<u8>),
Text(String),
}
impl Frame {
pub fn len(&self) -> usize {
match self {
Frame::Binary(b) => b.len(),
Frame::Text(t) => t.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodecError(pub String);
impl fmt::Display for CodecError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "protocol codec: {}", self.0)
}
}
impl std::error::Error for CodecError {}
/// Encode a message as one frame: postcard in a binary frame, or JSON in a text frame.
pub fn encode<T: Serialize>(msg: &T, encoding: Encoding) -> Result<Frame, CodecError> {
match encoding {
Encoding::Postcard => postcard::to_stdvec(msg).map(Frame::Binary).map_err(|e| CodecError(e.to_string())),
Encoding::Json => serde_json::to_string(msg).map(Frame::Text).map_err(|e| CodecError(e.to_string())),
}
}
/// Decode a binary (postcard) frame.
pub fn decode_binary<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CodecError> {
postcard::from_bytes(bytes).map_err(|e| CodecError(e.to_string()))
}
/// Decode a text (JSON) frame.
pub fn decode_text<T: DeserializeOwned>(text: &str) -> Result<T, CodecError> {
serde_json::from_str(text).map_err(|e| CodecError(e.to_string()))
}
/// Decode either kind of frame.
pub fn decode<T: DeserializeOwned>(frame: &Frame) -> Result<T, CodecError> {
match frame {
Frame::Binary(b) => decode_binary(b),
Frame::Text(t) => decode_text(t),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(s: &str) -> MarketKey {
MarketKey::new(s)
}
fn bar(ts: i64) -> Bar {
Bar::new(ts, 76_612.0, 76_640.5, 76_443.0, 76_521.0, 74.46557)
}
fn book(levels: usize) -> Book {
let side = |sign: f64| {
(0..levels)
.map(|i| Level { px: 76_530.0 + sign * i as f64, sz: 1.5 + i as f64 * 0.1, n: 3 + i as u32 })
.collect()
};
Book { bids: side(-1.0), asks: side(1.0), time_ms: 1_793_325_942_459 }
}
fn quote(k: &str, src: &str) -> Quote {
Quote {
key: key(k),
source: key(src),
px: 76_521.5,
basis: PriceBasis::Mid,
prev_day: Some(75_000.0),
ts_ms: 1_793_325_942_459,
}
}
fn trade(i: u64) -> Trade {
Trade { px: 76_528.0, sz: 0.41745, side: Side::Buy, time_ms: 1_793_325_941_395, tid: 1_005_251_635_753_713 + i }
}
fn client_msgs() -> Vec<ClientMsg> {
vec![
ClientMsg::Hello {
version: PROTOCOL_VERSION,
encoding: Encoding::Postcard,
client: ClientInfo { name: "tr-cli".into(), version: "0.1.1".into() },
token: None,
resume: None,
},
ClientMsg::Subscribe {
id: 1,
topic: TopicReq::Quotes { keys: vec![key("BTC"), key("xyz:TSLA")], max_hz: 4 },
},
ClientMsg::Unsubscribe { id: 1 },
ClientMsg::Resync { id: 2 },
ClientMsg::Request {
id: 7,
req: Rpc::History { key: key("BTCUSD Curncy"), tf: Timeframe::M1, end: None, limit: 500 },
},
ClientMsg::Ping { t: 42 },
]
}
fn server_msgs() -> Vec<ServerMsg> {
let limits = Limits { max_subscriptions: 64, max_quote_keys: 512, max_book_depth: 20, max_history: 5000 };
vec![
ServerMsg::Welcome {
version: PROTOCOL_VERSION,
session: SessionId(9),
user: None,
server_time_ms: 1_793_325_942_000,
limits,
},
ServerMsg::Snapshot { id: 1, seq: 0, data: TopicData::Quotes(vec![quote("BTCUSD Curncy", "BTC")]) },
ServerMsg::Delta { id: 1, seq: 1, data: TopicDelta::Bar(BarDelta::Closed(bar(60))) },
ServerMsg::Resync { id: 1, reason: ResyncReason::Overflow },
ServerMsg::Reply { id: 7, result: Ok(RpcResult::History(vec![bar(0), bar(60)])) },
ServerMsg::Status {
id: 3,
status: TopicStatus::Unavailable { reason: UnavailableReason::Capacity, detail: "budget".into() },
},
ServerMsg::Pong { t: 42, server_time_ms: 1_793_325_942_000 },
ServerMsg::Error { code: ErrorCode::BadMessage, message: "nope".into() },
]
}
#[test]
fn round_trips_in_both_encodings() {
for enc in [Encoding::Postcard, Encoding::Json] {
for m in client_msgs() {
let f = encode(&m, enc).unwrap();
assert_eq!(decode::<ClientMsg>(&f).unwrap(), m, "{enc:?}");
}
for m in server_msgs() {
let f = encode(&m, enc).unwrap();
assert_eq!(decode::<ServerMsg>(&f).unwrap(), m, "{enc:?}");
}
let extra = [
ServerMsg::Snapshot { id: 4, seq: 10, data: TopicData::Book(book(20)) },
ServerMsg::Snapshot { id: 5, seq: 0, data: TopicData::Trades(vec![trade(0), trade(1)]) },
ServerMsg::Snapshot { id: 6, seq: 0, data: TopicData::Bars(vec![bar(0)]) },
ServerMsg::Delta { id: 4, seq: 11, data: TopicDelta::Book(book(5)) },
ServerMsg::Delta { id: 5, seq: 1, data: TopicDelta::Trades(vec![trade(2)]) },
ServerMsg::Delta { id: 1, seq: 2, data: TopicDelta::Quotes(vec![quote("BTC", "BTC")]) },
ServerMsg::Reply {
id: 8,
result: Ok(RpcResult::Markets(vec![MarketInfo {
key: key("xyz:TSLA"),
venue: "xyz".into(),
kind: InstrumentKind::Perp,
}])),
},
ServerMsg::Reply {
id: 9,
result: Err(ApiError { code: ErrorCode::UnknownMarket, message: "x".into() }),
},
ServerMsg::Status { id: 1, status: TopicStatus::Stale { since_ms: 5 } },
];
for m in extra {
assert_eq!(decode::<ServerMsg>(&encode(&m, enc).unwrap()).unwrap(), m);
}
}
}
#[test]
fn encodings_pick_the_frame_kind() {
let m = ClientMsg::Ping { t: 1 };
assert!(matches!(encode(&m, Encoding::Postcard).unwrap(), Frame::Binary(_)));
assert_eq!(encode(&m, Encoding::Json).unwrap(), Frame::Text(r#"{"Ping":{"t":1}}"#.into()));
assert!(decode_binary::<ClientMsg>(&[0xff, 0xff]).is_err());
assert!(decode_text::<ClientMsg>("{}").is_err());
}
/// postcard writes an enum variant as its index. Appending variants keeps these; reordering
/// or removing one changes them and breaks every deployed client, so this test pins them.
#[test]
fn wire_tags_are_stable() {
let tag = |f: Frame| match f {
Frame::Binary(b) => b[0],
Frame::Text(_) => unreachable!(),
};
let client: Vec<u8> = client_msgs().iter().map(|m| tag(encode(m, Encoding::Postcard).unwrap())).collect();
assert_eq!(client, [0, 1, 2, 3, 4, 5]);
let server: Vec<u8> = server_msgs().iter().map(|m| tag(encode(m, Encoding::Postcard).unwrap())).collect();
assert_eq!(server, [0, 1, 2, 3, 4, 5, 6, 7]);
let t = |v: Vec<u8>| v[0];
let topics = [
TopicReq::Quotes { keys: vec![], max_hz: 1 },
TopicReq::Bars { key: key("BTC"), tf: Timeframe::M1, history: 1 },
TopicReq::Book { key: key("BTC"), depth: 1, group: None },
TopicReq::Trades { key: key("BTC") },
];
assert_eq!(topics.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
let data =
[TopicData::Quotes(vec![]), TopicData::Bars(vec![]), TopicData::Book(book(0)), TopicData::Trades(vec![])];
assert_eq!(data.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
let deltas = [
TopicDelta::Quotes(vec![]),
TopicDelta::Bar(BarDelta::Forming(bar(0))),
TopicDelta::Book(book(0)),
TopicDelta::Trades(vec![]),
];
assert_eq!(deltas.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
assert_eq!(
[BarDelta::Forming(bar(0)), BarDelta::Closed(bar(0))].map(|x| t(postcard::to_stdvec(&x).unwrap())),
[0, 1]
);
assert_eq!(
[Rpc::History { key: key("B"), tf: Timeframe::D1, end: None, limit: 1 }, Rpc::Markets]
.map(|x| t(postcard::to_stdvec(&x).unwrap())),
[0, 1]
);
let status = [
TopicStatus::Live,
TopicStatus::Stale { since_ms: 0 },
TopicStatus::Unavailable { reason: UnavailableReason::Upstream, detail: String::new() },
];
assert_eq!(status.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2]);
// tr-core types are part of the wire format: Timeframe's variant order and Bar's fields.
let tfs: Vec<u8> = Timeframe::ALL.iter().map(|tf| t(postcard::to_stdvec(tf).unwrap())).collect();
assert_eq!(tfs, [0, 1, 2, 3, 4, 5, 6, 7, 8]);
let b = postcard::to_stdvec(&Bar::new(1, 2.0, 3.0, 4.0, 5.0, 6.0)).unwrap();
assert_eq!(b.len(), 1 + 5 * 8, "ts as a varint, then five f64 in field order");
assert_eq!(&b[1..9], &2.0f64.to_le_bytes());
}
/// Frame sizes for the messages that dominate traffic (§11 estimates ~30 bytes a quote).
#[test]
fn frame_sizes() {
let size = |m: &ServerMsg, e| encode(m, e).unwrap().len();
let one_quote = ServerMsg::Delta { id: 1, seq: 1234, data: TopicDelta::Quotes(vec![quote("BTC", "BTC")]) };
let book20 = ServerMsg::Delta { id: 2, seq: 1234, data: TopicDelta::Book(book(20)) };
let one_trade = ServerMsg::Delta { id: 3, seq: 1234, data: TopicDelta::Trades(vec![trade(0)]) };
let forming = ServerMsg::Delta { id: 4, seq: 1234, data: TopicDelta::Bar(BarDelta::Forming(bar(60))) };
let rows = [("quote", &one_quote), ("book 20x2", &book20), ("trade", &one_trade), ("bar", &forming)];
for (name, m) in rows {
let (p, j) = (size(m, Encoding::Postcard), size(m, Encoding::Json));
println!("{name:>9}: postcard {p:>4} B, json {j:>5} B ({:.1}x)", j as f64 / p as f64);
assert!(p < j, "{name}: postcard should be smaller");
}
assert!(size(&one_quote, Encoding::Postcard) <= 40);
assert!(size(&one_trade, Encoding::Postcard) <= 40);
assert!(size(&forming, Encoding::Postcard) <= 50);
assert!(size(&book20, Encoding::Postcard) <= 40 * 18 + 16);
}
}
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "tr-turnkey"
description = "Built-in wallets on Turnkey: the device's session key and request stamps, the few Turnkey calls Terminal makes, and the desktop sign-in's loopback callback"
version.workspace = true
edition.workspace = true
[features]
# An in-process stand-in for Turnkey's API and sign-in page, for tests and screenshots.
# Never in a shipped build.
mock = ["dep:k256"]
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
tracing.workspace = true
alloy-primitives = { version = "1", default-features = false, features = ["std", "k256"] }
turnkey_api_key_stamper = "0.17.1"
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
base64 = "0.22"
hex = "0.4"
rand_core = { version = "0.6", features = ["getrandom"] }
zeroize = "1"
web-time = "1"
k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"], optional = true }
# The desktop sign-in listens for the browser's redirect on 127.0.0.1.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { workspace = true, features = ["net", "io-util", "time"] }
# Key generation on the web reads the browser's crypto.getRandomValues.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util", "time"] }
tr-turnkey = { path = ".", features = ["mock"] }
+215
View File
@@ -0,0 +1,215 @@
//! Desktop sign-in: the browser hands the session back to Terminal by redirecting to
//! `http://127.0.0.1:<port>/callback?state=…&session=…` (a loopback redirect, RFC 8252 §7.3).
//!
//! - The listener binds 127.0.0.1 on a port the OS picks, so nothing off this machine can
//! reach it, and it closes after one good answer or [`SIGN_IN_TIMEOUT`].
//! - `state` is 256 random bits made for this sign-in. Requests without it (another tab,
//! another program poking the port) get an error page and don't end the wait.
//! - The session token is bound to this device's key ([`crate::session::Session::accept`]
//! checks that), so even an intercepted redirect is useless to anyone else.
//! - Only `GET /callback` is answered; requests are read up to 8 KiB.
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::session::same_secret;
pub use crate::SIGN_IN_TIMEOUT;
/// Bad requests tolerated before the sign-in gives up.
const MAX_STRAY: usize = 32;
const MAX_REQUEST: usize = 8 * 1024;
/// What the browser came back with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
/// The session token for this device's key.
Session(String),
/// The page reported that the user cancelled or sign-in failed.
Failed(String),
}
/// A loopback listener waiting for one sign-in.
pub struct Callback {
listener: TcpListener,
port: u16,
}
impl Callback {
/// Listen on 127.0.0.1 at a port the OS picks.
pub async fn bind() -> Result<Self> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.context("listening on 127.0.0.1 for the sign-in")?;
let port = listener.local_addr()?.port();
Ok(Self { listener, port })
}
pub fn port(&self) -> u16 {
self.port
}
/// Wait for `GET /callback` carrying `state`, up to `timeout`.
pub async fn wait(self, state: &str, timeout: Duration) -> Result<Answer> {
tokio::time::timeout(timeout, self.serve(state))
.await
.map_err(|_| anyhow!("the browser didn't come back within {} minutes; try again", timeout.as_secs() / 60))?
}
async fn serve(self, state: &str) -> Result<Answer> {
let mut stray = 0;
loop {
let (mut sock, _) = self.listener.accept().await?;
match read_request(&mut sock).await {
Ok(Some(target)) => match judge(&target, state) {
Verdict::Done(answer) => {
let page = match &answer {
Answer::Session(_) => PAGE_DONE,
Answer::Failed(_) => PAGE_FAILED,
};
let _ = respond(&mut sock, "200 OK", page).await;
return Ok(answer);
}
Verdict::NotFound => {
let _ = respond(&mut sock, "404 Not Found", PAGE_STRAY).await;
}
Verdict::WrongState => {
let _ = respond(&mut sock, "400 Bad Request", PAGE_STRAY).await;
}
},
Ok(None) | Err(_) => {
let _ = respond(&mut sock, "400 Bad Request", PAGE_STRAY).await;
}
}
stray += 1;
if stray >= MAX_STRAY {
bail!("too many unexpected requests reached the sign-in listener; try again");
}
}
}
}
enum Verdict {
Done(Answer),
WrongState,
NotFound,
}
/// Decide what a request target (`/callback?…`) means for the sign-in with `state`.
fn judge(target: &str, state: &str) -> Verdict {
let (path, query) = target.split_once('?').unwrap_or((target, ""));
if path != "/callback" {
return Verdict::NotFound;
}
let get = |k: &str| query.split('&').find_map(|kv| kv.strip_prefix(k)?.strip_prefix('=')).map(percent_decode);
match get("state") {
Some(s) if same_secret(&s, state) => {}
_ => return Verdict::WrongState,
}
match (get("session"), get("error")) {
(Some(token), None) if !token.is_empty() => Verdict::Done(Answer::Session(token)),
(_, Some(e)) => Verdict::Done(Answer::Failed(e.chars().filter(|c| !c.is_control()).take(200).collect())),
_ => Verdict::WrongState,
}
}
/// The request target of a `GET`, or `None` for anything else.
async fn read_request(sock: &mut TcpStream) -> Result<Option<String>> {
let mut buf = Vec::with_capacity(1024);
let mut chunk = [0u8; 1024];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
let n = tokio::time::timeout(Duration::from_secs(10), sock.read(&mut chunk)).await??;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.len() > MAX_REQUEST {
return Ok(None);
}
}
let text = String::from_utf8_lossy(&buf);
let mut first = text.lines().next().unwrap_or_default().split(' ');
match (first.next(), first.next()) {
(Some("GET"), Some(target)) => Ok(Some(target.to_string())),
_ => Ok(None),
}
}
async fn respond(sock: &mut TcpStream, status: &str, body: &str) -> Result<()> {
let head = format!(
"HTTP/1.1 {status}\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\ncache-control: no-store\r\nreferrer-policy: no-referrer\r\nconnection: close\r\n\r\n",
body.len()
);
sock.write_all(head.as_bytes()).await?;
sock.write_all(body.as_bytes()).await?;
sock.shutdown().await?;
Ok(())
}
/// `%XX` and `+` decoding for a query value.
pub fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < b.len() => {
match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) {
Ok(v) => {
out.push(v);
i += 2;
}
Err(_) => out.push(b'%'),
}
}
c => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
const PAGE_DONE: &str = concat!(
"<!doctype html><meta charset=utf-8><title>Terminal</title><style>",
"body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{max-width:420px;text-align:center}b{color:#f5a623}",
"</style><main><h2>Signed in</h2><p>Your wallet is connected to Terminal. You can close this tab.</p><p><b>TESTNET</b></p></main>"
);
const PAGE_FAILED: &str = concat!(
"<!doctype html><meta charset=utf-8><title>Terminal</title><style>",
"body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{max-width:420px;text-align:center}",
"</style><main><h2>Not signed in</h2><p>Terminal got the answer. Go back to it to try again.</p></main>"
);
const PAGE_STRAY: &str = "<!doctype html><meta charset=utf-8><title>Terminal</title><p>This sign-in link isn't for this Terminal window.</p>";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_callback_with_this_state_ends_the_sign_in() {
let st = "ab".repeat(32);
let ok = format!("/callback?state={st}&session=h.p.s");
assert!(matches!(judge(&ok, &st), Verdict::Done(Answer::Session(t)) if t == "h.p.s"));
let failed = format!("/callback?error=cancelled+by+user&state={st}");
assert!(matches!(judge(&failed, &st), Verdict::Done(Answer::Failed(e)) if e == "cancelled by user"));
for bad in [
format!("/callback?state={}&session=h.p.s", "cd".repeat(32)),
"/callback?session=h.p.s".to_string(),
format!("/callback?state={}&session=h.p.s", &st[..63]),
format!("/callback?state={st}"),
format!("/callback?state={st}&session="),
] {
assert!(matches!(judge(&bad, &st), Verdict::WrongState), "{bad}");
}
assert!(matches!(judge(&format!("/other?state={st}&session=x"), &st), Verdict::NotFound));
assert!(matches!(judge("/favicon.ico", &st), Verdict::NotFound));
}
#[test]
fn query_values_are_decoded() {
assert_eq!(percent_decode("a%20b+c%2Fd"), "a b c/d");
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%zz"), "%zz");
}
}
+309
View File
@@ -0,0 +1,309 @@
//! The Turnkey calls Terminal makes, stamped with the device's session key: who am I, which
//! Ethereum account the wallet has, and signing one 32-byte digest.
//!
//! Requests follow Turnkey's public API (`POST /public/v1/query/*` and
//! `/public/v1/submit/*`, a JSON body, and an `X-Stamp` header signed over that exact body),
//! as `turnkey_client` 0.17 builds them. That client isn't used directly: it waits with
//! tokio timers, which don't run in the browser, and pulls a second `reqwest`.
//!
//! [`Client::sign_digest`] sends the digest as `alloy-signer-turnkey` does:
//! `ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2` with `PAYLOAD_ENCODING_HEXADECIMAL` and
//! `HASH_FUNCTION_NO_OP`, so Turnkey signs those 32 bytes as they are.
use std::time::Duration;
use alloy_primitives::{Address, B256, Signature, U256};
use anyhow::{Context, Result, anyhow, bail, ensure};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::session::Session;
/// Turnkey's API.
pub const API_URL: &str = "https://api.turnkey.com";
/// The longest any one request may take, on the desktop and in the browser.
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
/// The wallet account Terminal uses: the first Ethereum account, as the sign-up creates it.
pub const ETHEREUM_PATH: &str = "m/44'/60'/0'/0/0";
/// `url` if it is a plain-HTTP loopback address (`http://127.0.0.1:<port>` or
/// `http://localhost:<port>`): the only kind of override Terminal accepts, for a local mock.
pub fn loopback_only(url: &str) -> Option<String> {
let rest = url.trim().strip_prefix("http://")?;
let host_port = rest.split('/').next()?;
let (host, port) = host_port.rsplit_once(':')?;
let port_ok = port.parse::<u16>().is_ok_and(|p| p != 0);
((host == "127.0.0.1" || host == "localhost") && port_ok).then(|| url.trim().trim_end_matches('/').to_string())
}
/// Turnkey's API, unless `TR_TURNKEY_API_URL` names a local mock (desktop only).
pub fn api_url() -> String {
#[cfg(not(target_arch = "wasm32"))]
if let Some(u) = std::env::var("TR_TURNKEY_API_URL").ok().and_then(|u| loopback_only(&u)) {
tracing::warn!("turnkey: using the local mock API named by TR_TURNKEY_API_URL");
return u;
}
API_URL.to_string()
}
/// `whoami`: the user and organization a session belongs to.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WhoAmI {
pub organization_id: String,
#[serde(default)]
pub organization_name: String,
pub user_id: String,
#[serde(default)]
pub username: String,
}
/// A wallet account (an address Turnkey holds the key for).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WalletAccount {
pub wallet_id: String,
pub address: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub address_format: String,
}
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
base: String,
}
impl Client {
/// Turnkey's API (or a local mock, see [`api_url`]).
pub fn new() -> Result<Self> {
Self::with_base(&api_url())
}
/// Any base URL (tests point this at the mock).
pub fn with_base(base: &str) -> Result<Self> {
let b = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let b =
b.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).redirect(reqwest::redirect::Policy::none());
Ok(Self { http: b.build()?, base: base.trim_end_matches('/').to_string() })
}
pub fn base(&self) -> &str {
&self.base
}
/// POST `body` to `path`, stamped with the session's key. The stamp signs these exact
/// bytes, so the body is serialized once and sent as is.
async fn post(&self, s: &Session, path: &str, body: &Value) -> Result<Value> {
let text = serde_json::to_string(body)?;
let (name, value) = s.key.stamp(text.as_bytes())?;
let r = self
.http
.post(format!("{}{path}", self.base))
.timeout(REQUEST_TIMEOUT)
.header("content-type", "application/json")
.header(name, value)
.body(text)
.send()
.await
.with_context(|| format!("turnkey {path}"))?;
let status = r.status();
let text = r.text().await.with_context(|| format!("turnkey {path}"))?;
if !status.is_success() {
let msg = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|v| v["message"].as_str().map(str::to_string))
.unwrap_or_else(|| text.chars().take(200).collect());
bail!("turnkey {path}: HTTP {}: {msg}", status.as_u16());
}
serde_json::from_str(&text).with_context(|| format!("turnkey {path}: not JSON"))
}
pub async fn whoami(&self, s: &Session) -> Result<WhoAmI> {
let v = self.post(s, "/public/v1/query/whoami", &json!({"organizationId": s.organization_id()})).await?;
Ok(serde_json::from_value(v)?)
}
/// The wallet's Ethereum account at [`ETHEREUM_PATH`] (or, failing that, its first
/// Ethereum account). Its address is the user's Hyperliquid account.
pub async fn ethereum_account(&self, s: &Session) -> Result<WalletAccount> {
let org = s.organization_id();
let wallets = self.post(s, "/public/v1/query/list_wallets", &json!({"organizationId": org})).await?;
let mut accounts = Vec::new();
for w in wallets["wallets"].as_array().into_iter().flatten() {
let Some(id) = w["walletId"].as_str() else { continue };
let v = self
.post(s, "/public/v1/query/list_wallet_accounts", &json!({"organizationId": org, "walletId": id}))
.await?;
let list: Vec<WalletAccount> = serde_json::from_value(v["accounts"].clone()).unwrap_or_default();
accounts.extend(list);
}
pick_ethereum(accounts).ok_or_else(|| anyhow!("the Turnkey wallet has no Ethereum account"))
}
/// Have the wallet at `sign_with` sign `digest` as it is (no hashing, no prefix).
pub async fn sign_digest(&self, s: &Session, sign_with: &Address, digest: &B256) -> Result<Signature> {
let body = sign_raw_payload_body(s.organization_id(), sign_with, digest, now_ms());
let activity = self.submit(s, "/public/v1/submit/sign_raw_payload", &body).await?;
let result = &activity["result"]["signRawPayloadResult"];
parse_signature(result)
}
/// Submit an activity and return it once Turnkey has completed it.
async fn submit(&self, s: &Session, path: &str, body: &Value) -> Result<Value> {
let mut v = self.post(s, path, body).await?;
// A single-user organization completes at once; ask again a few times if not.
for _ in 0..3 {
match v["activity"]["status"].as_str() {
Some("ACTIVITY_STATUS_PENDING") | Some("ACTIVITY_STATUS_CREATED") => {
let id = v["activity"]["id"].as_str().unwrap_or_default().to_string();
v = self
.post(
s,
"/public/v1/query/get_activity",
&json!({"organizationId": s.organization_id(), "activityId": id}),
)
.await?;
}
_ => break,
}
}
let a = &v["activity"];
match a["status"].as_str() {
Some("ACTIVITY_STATUS_COMPLETED") => Ok(a.clone()),
Some("ACTIVITY_STATUS_FAILED") => {
bail!("Turnkey refused: {}", a["failure"]["message"].as_str().unwrap_or("no reason given"))
}
Some("ACTIVITY_STATUS_CONSENSUS_NEEDED") | Some("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED") => {
bail!("Turnkey needs another approval for this; nothing was signed")
}
Some(other) => bail!("Turnkey answered {other}"),
None => bail!("Turnkey's answer has no activity"),
}
}
}
fn now_ms() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// The Ethereum account Terminal uses from a wallet's accounts.
pub fn pick_ethereum(accounts: Vec<WalletAccount>) -> Option<WalletAccount> {
let eth: Vec<WalletAccount> =
accounts.into_iter().filter(|a| a.address_format == "ADDRESS_FORMAT_ETHEREUM").collect();
eth.iter().find(|a| a.path == ETHEREUM_PATH).cloned().or_else(|| eth.into_iter().next())
}
/// The `sign_raw_payload` request for a 32-byte digest (what `alloy-signer-turnkey`'s
/// `sign_hash` sends): the payload is the digest in lower-case hex without `0x`, and
/// `signWith` the checksummed address.
pub fn sign_raw_payload_body(org: &str, sign_with: &Address, digest: &B256, timestamp_ms: u64) -> Value {
json!({
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
"timestampMs": timestamp_ms.to_string(),
"organizationId": org,
"parameters": {
"signWith": sign_with.to_string(),
"payload": hex::encode(digest),
"encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
"hashFunction": "HASH_FUNCTION_NO_OP",
},
})
}
/// `{r, s, v}` from `signRawPayloadResult`: 32-byte hex `r` and `s`, and `v` as one hex byte
/// (`00`/`01`, or 27/28).
pub fn parse_signature(result: &Value) -> Result<Signature> {
let field = |k: &str| -> Result<Vec<u8>> {
let s = result[k].as_str().ok_or_else(|| anyhow!("the signature has no {k}"))?;
hex::decode(s.trim_start_matches("0x")).map_err(|_| anyhow!("the signature's {k} is not hex"))
};
let (r, s, v) = (field("r")?, field("s")?, field("v")?);
ensure!(r.len() == 32 && s.len() == 32 && v.len() == 1, "the signature has the wrong length");
let parity = match v[0] {
0 | 27 => false,
1 | 28 => true,
_ => bail!("the signature's v is not 0, 1, 27 or 28"),
};
Ok(Signature::new(U256::from_be_slice(&r), U256::from_be_slice(&s), parity))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_loopback_overrides_are_accepted() {
assert_eq!(loopback_only("http://127.0.0.1:8123").as_deref(), Some("http://127.0.0.1:8123"));
assert_eq!(loopback_only("http://localhost:9/x/").as_deref(), Some("http://localhost:9/x"));
for bad in [
"https://api.turnkey.com",
"http://evil.example:80",
"http://127.0.0.1.evil.example:80",
"http://127.0.0.1",
"http://127.0.0.1:0",
"http://[email protected]:80",
"file:///tmp/x",
"",
] {
assert_eq!(loopback_only(bad), None, "{bad}");
}
}
/// The body `alloy-signer-turnkey` 2.5's `sign_hash` would send for the same digest
/// (field names from `turnkey_client` 0.17's `SignRawPayloadIntentV2`).
#[test]
fn the_sign_request_matches_alloy_signer_turnkey() {
let addr: Address = "0x14791697260e4c9a71f18484c9f997b308e59325".parse().unwrap();
let digest = B256::from([0xab; 32]);
let body = sign_raw_payload_body("org-1", &addr, &digest, 1_700_000_000_000);
assert_eq!(
body,
json!({
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
"timestampMs": "1700000000000",
"organizationId": "org-1",
"parameters": {
"signWith": "0x14791697260E4c9A71f18484C9f997B308e59325",
"payload": "ab".repeat(32),
"encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
"hashFunction": "HASH_FUNCTION_NO_OP",
},
})
);
}
#[test]
fn signatures_parse_with_either_v() {
let r = "11".repeat(32);
let s = "22".repeat(32);
for (v, odd) in [("00", false), ("01", true), ("1b", false), ("1c", true)] {
let sig = parse_signature(&json!({"r": r, "s": s, "v": v})).unwrap();
assert_eq!(sig.v(), odd, "{v}");
assert_eq!(sig.r(), U256::from_be_slice(&[0x11; 32]));
}
assert!(parse_signature(&json!({"r": r, "s": s, "v": "02"})).is_err());
assert!(parse_signature(&json!({"r": "11", "s": s, "v": "00"})).is_err());
assert!(parse_signature(&json!({"r": r, "v": "00"})).is_err());
}
#[test]
fn the_ethereum_account_is_the_default_path() {
let acct = |path: &str, fmt: &str, addr: &str| WalletAccount {
wallet_id: "w".into(),
address: addr.into(),
path: path.into(),
address_format: fmt.into(),
};
let picked = pick_ethereum(vec![
acct("m/44'/501'/0'/0'", "ADDRESS_FORMAT_SOLANA", "So1"),
acct("m/44'/60'/0'/0/1", "ADDRESS_FORMAT_ETHEREUM", "0x2"),
acct(ETHEREUM_PATH, "ADDRESS_FORMAT_ETHEREUM", "0x1"),
]);
assert_eq!(picked.unwrap().address, "0x1");
assert_eq!(pick_ethereum(vec![acct("m/44'/501'/0'/0'", "ADDRESS_FORMAT_SOLANA", "So1")]), None);
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Built-in wallets on [Turnkey](https://turnkey.com), for Hyperliquid **testnet** trading.
//!
//! Each user has a Turnkey sub-organization with one Ethereum wallet, created when they first
//! sign in on Terminal's sign-in page. That wallet is their Hyperliquid account. Turnkey
//! signs for it only rarely: approving (or replacing) the device's trading key. Orders are
//! signed on the device by that trading key, never by Turnkey.
//!
//! - [`session`]: the device's P-256 key, which stamps every Turnkey request, and the
//! short session Turnkey issues for it.
//! - [`client`]: `whoami`, the wallet's Ethereum account, and signing one digest.
//! - [`callback`] (desktop): the loopback listener the browser returns the session to.
//! - `mock` (feature `mock`): a local stand-in for tests and screenshots.
//!
//! This crate knows nothing about Hyperliquid. What may be signed is decided by `tr-hl`
//! (`signing::sign_approve_agent_with`), which hands it only the digest of a testnet
//! `approveAgent`.
pub mod client;
pub mod session;
#[cfg(not(target_arch = "wasm32"))]
pub mod callback;
#[cfg(all(feature = "mock", not(target_arch = "wasm32")))]
pub mod mock;
pub use client::Client;
pub use session::{Session, SessionKey};
/// How long a sign-in waits for the browser, on the desktop and in the browser build.
pub const SIGN_IN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
/// Terminal's sign-in page.
pub const SIGN_IN_PAGE: &str = "https://terminal.rasmic.xyz/wallet";
/// The sign-in page, unless `TR_WALLET_SIGN_IN_URL` names a local mock's page (desktop only,
/// loopback only, e.g. `http://127.0.0.1:<port>/wallet`).
pub fn sign_in_page() -> String {
#[cfg(not(target_arch = "wasm32"))]
if let Some(u) = std::env::var("TR_WALLET_SIGN_IN_URL").ok().and_then(|u| client::loopback_only(&u)) {
tracing::warn!("turnkey: using the local mock sign-in page named by TR_WALLET_SIGN_IN_URL");
return u;
}
SIGN_IN_PAGE.to_string()
}
/// The page that signs a desktop in: it returns the session for `public_key` to
/// `http://127.0.0.1:<port>/callback` with `state`. Only public values are in the URL, and
/// they are in the fragment, which browsers never send to a server or in a `Referer`.
pub fn desktop_sign_in_url(page: &str, public_key: &str, state: &str, port: u16) -> String {
format!("{page}#flow=desktop&pk={public_key}&state={state}&port={port}")
}
/// The browser build's popup: the page answers with `postMessage` to its opener.
pub fn web_sign_in_url(page: &str, public_key: &str, state: &str) -> String {
format!("{page}#flow=web&pk={public_key}&state={state}")
}
/// The page in export mode: the user signs in again there, and Turnkey's export iframe shows
/// the recovery phrase. Terminal is not involved.
pub fn export_url(page: &str) -> String {
format!("{page}#mode=export")
}
+251
View File
@@ -0,0 +1,251 @@
//! **Test stand-in, not Turnkey.** A local server with the few Turnkey API routes Terminal
//! calls and a pretend sign-in page, for tests and for screenshots. Its JSON follows Turnkey's
//! API reference and `turnkey_client` 0.17's types (`whoami`, `list_wallets`,
//! `list_wallet_accounts`, `sign_raw_payload` V2); nothing here was recorded from Turnkey.
//!
//! It checks requests the way Turnkey does for these routes: the `X-Stamp` must be a valid
//! P-256 signature over the exact body, by a session key it issued and that hasn't expired,
//! for its organization. The wallet is a local secp256k1 key.
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use k256::ecdsa::SigningKey as WalletKey;
use p256::ecdsa::signature::Verifier;
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::callback::percent_decode;
use crate::session::{Claims, SESSION_SECONDS, unsigned_token};
pub const ORG_ID: &str = "00000000-mock-4000-8000-0000000000aa";
pub const USER_ID: &str = "00000000-mock-4000-8000-0000000000bb";
pub const WALLET_ID: &str = "00000000-mock-4000-8000-0000000000cc";
pub const USERNAME: &str = "test user (mock)";
struct State {
wallet: WalletKey,
/// Session public keys this mock issued, with their expiry (Unix seconds).
sessions: Vec<(String, u64)>,
/// Every API request that got past the stamp check: `(path, body)`.
requests: Vec<(String, Value)>,
}
/// A running mock. Dropping it doesn't stop the server task; tests just let it end.
#[derive(Clone)]
pub struct MockTurnkey {
pub base: String,
state: Arc<Mutex<State>>,
}
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
impl MockTurnkey {
/// Start on 127.0.0.1 (port 0: any free port) with a fresh wallet key, or `wallet_hex`.
pub async fn start(port: u16, wallet_hex: Option<&str>) -> Result<Self> {
let wallet = match wallet_hex {
Some(h) => WalletKey::from_slice(&hex::decode(h.trim_start_matches("0x"))?)?,
None => WalletKey::random(&mut rand_core::OsRng),
};
let listener = TcpListener::bind(("127.0.0.1", port)).await?;
let base = format!("http://127.0.0.1:{}", listener.local_addr()?.port());
let state = Arc::new(Mutex::new(State { wallet, sessions: Vec::new(), requests: Vec::new() }));
let me = Self { base, state };
let server = me.clone();
tokio::spawn(async move {
while let Ok((sock, _)) = listener.accept().await {
let s = server.clone();
tokio::spawn(async move {
let _ = s.handle(sock).await;
});
}
});
Ok(me)
}
/// The wallet's address (lower-case `0x…`).
pub fn address(&self) -> String {
let st = self.state.lock().unwrap();
format!("{:#x}", alloy_primitives::Address::from_private_key(&st.wallet))
}
/// Issue a session for `public_key` (what the sign-in page's `stamp_login` does).
pub fn mint_session(&self, public_key: &str, seconds: u64) -> String {
let exp = now_secs() + seconds;
self.state.lock().unwrap().sessions.push((public_key.to_ascii_lowercase(), exp));
unsigned_token(&Claims {
organization_id: ORG_ID.into(),
user_id: USER_ID.into(),
public_key: public_key.to_ascii_lowercase(),
exp,
session_type: "SESSION_TYPE_READ_WRITE".into(),
})
}
/// The API requests it accepted, in order.
pub fn requests(&self) -> Vec<(String, Value)> {
self.state.lock().unwrap().requests.clone()
}
async fn handle(&self, mut sock: TcpStream) -> Result<()> {
let (method, target, headers, body) = read(&mut sock).await?;
let (path, query) = target.split_once('?').unwrap_or((&target, ""));
let q = |k: &str| {
query
.split('&')
.find_map(|kv| kv.strip_prefix(k)?.strip_prefix('='))
.map(percent_decode)
.unwrap_or_default()
};
match (method.as_str(), path) {
("GET", "/wallet/") | ("GET", "/wallet") => {
write(&mut sock, "200 OK", "text/html; charset=utf-8", &[], &sign_in_page()).await
}
("GET", "/wallet/complete") => {
let (pk, state, port) = (q("pk"), q("state"), q("port"));
let ok = pk.len() == 66 && pk.chars().all(|c| c.is_ascii_hexdigit()) && port.parse::<u16>().is_ok();
if !ok {
return write(&mut sock, "400 Bad Request", "text/plain", &[], "bad request").await;
}
let token = self.mint_session(&pk, SESSION_SECONDS);
let to = format!("http://127.0.0.1:{port}/callback?state={state}&session={token}");
write(&mut sock, "302 Found", "text/plain", &[("location", &to)], "").await
}
("POST", p) if p.starts_with("/public/v1/") => {
let (status, reply) = match self.api(p, &headers, &body) {
Ok(v) => ("200 OK", v),
Err(e) => ("401 Unauthorized", json!({"code": 16, "message": format!("{e:#}")})),
};
write(&mut sock, status, "application/json", &[], &reply.to_string()).await
}
_ => write(&mut sock, "404 Not Found", "text/plain", &[], "not found").await,
}
}
fn api(&self, path: &str, headers: &[(String, String)], body: &[u8]) -> Result<Value> {
let stamp = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("x-stamp"))
.map(|(_, v)| v.clone())
.context("missing X-Stamp")?;
let stamp: Value = serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(stamp.trim())?)?;
anyhow::ensure!(stamp["scheme"] == "SIGNATURE_SCHEME_TK_API_P256", "unsupported stamp scheme");
let pk = stamp["publicKey"].as_str().context("stamp has no publicKey")?.to_ascii_lowercase();
let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&hex::decode(&pk)?)?;
let sig = p256::ecdsa::Signature::from_der(&hex::decode(stamp["signature"].as_str().unwrap_or_default())?)?;
key.verify(body, &sig).context("the stamp doesn't match the body")?;
let v: Value = serde_json::from_slice(body)?;
let mut st = self.state.lock().unwrap();
anyhow::ensure!(
st.sessions.iter().any(|(k, exp)| *k == pk && *exp > now_secs()),
"could not find a valid session for this public key"
);
anyhow::ensure!(v["organizationId"] == ORG_ID, "organization mismatch");
st.requests.push((path.to_string(), v.clone()));
let address = format!("{:#x}", alloy_primitives::Address::from_private_key(&st.wallet));
Ok(match path {
"/public/v1/query/whoami" => {
json!({"organizationId": ORG_ID, "organizationName": "mock sub-organization", "userId": USER_ID, "username": USERNAME})
}
"/public/v1/query/list_wallets" => json!({"wallets": [{"walletId": WALLET_ID, "walletName": "Terminal"}]}),
"/public/v1/query/list_wallet_accounts" => json!({"accounts": [{
"walletAccountId": "00000000-mock-4000-8000-0000000000dd",
"organizationId": ORG_ID,
"walletId": WALLET_ID,
"curve": "CURVE_SECP256K1",
"pathFormat": "PATH_FORMAT_BIP32",
"path": "m/44'/60'/0'/0/0",
"addressFormat": "ADDRESS_FORMAT_ETHEREUM",
"address": alloy_primitives::Address::from_private_key(&st.wallet).to_string(),
}]}),
"/public/v1/submit/sign_raw_payload" => {
let p = &v["parameters"];
anyhow::ensure!(v["type"] == "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2", "unexpected activity type");
anyhow::ensure!(p["encoding"] == "PAYLOAD_ENCODING_HEXADECIMAL", "unexpected encoding");
anyhow::ensure!(p["hashFunction"] == "HASH_FUNCTION_NO_OP", "unexpected hash function");
anyhow::ensure!(
p["signWith"].as_str().unwrap_or_default().eq_ignore_ascii_case(&address),
"signWith is not this organization's wallet"
);
let digest = hex::decode(p["payload"].as_str().unwrap_or_default())?;
anyhow::ensure!(digest.len() == 32, "the payload is not a 32-byte digest");
let (sig, recid) = st.wallet.sign_prehash_recoverable(&digest)?;
let (r, s) = sig.split_bytes();
json!({"activity": {
"id": "00000000-mock-4000-8000-0000000000ee",
"organizationId": ORG_ID,
"status": "ACTIVITY_STATUS_COMPLETED",
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
"result": {"signRawPayloadResult": {
"r": hex::encode(r),
"s": hex::encode(s),
"v": format!("{:02x}", u8::from(recid.is_y_odd())),
}},
}})
}
_ => anyhow::bail!("this mock doesn't serve {path}"),
})
}
}
type Request = (String, String, Vec<(String, String)>, Vec<u8>);
async fn read(sock: &mut TcpStream) -> Result<Request> {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let head_end = loop {
let n = sock.read(&mut chunk).await?;
anyhow::ensure!(n > 0, "connection closed");
buf.extend_from_slice(&chunk[..n]);
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break i + 4;
}
anyhow::ensure!(buf.len() < 64 * 1024, "headers too large");
};
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
let mut lines = head.lines();
let mut first = lines.next().unwrap_or_default().split(' ');
let (method, target) = (first.next().unwrap_or_default().to_string(), first.next().unwrap_or_default().to_string());
let headers: Vec<(String, String)> =
lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).collect();
let len = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
.and_then(|(_, v)| v.parse::<usize>().ok())
.unwrap_or(0);
let mut body = buf[head_end..].to_vec();
while body.len() < len {
let n = sock.read(&mut chunk).await?;
anyhow::ensure!(n > 0, "connection closed");
body.extend_from_slice(&chunk[..n]);
}
body.truncate(len);
Ok((method, target, headers, body))
}
async fn write(sock: &mut TcpStream, status: &str, ctype: &str, extra: &[(&str, &str)], body: &str) -> Result<()> {
let mut head = format!("HTTP/1.1 {status}\r\ncontent-type: {ctype}\r\ncontent-length: {}\r\n", body.len());
for (k, v) in extra {
head.push_str(&format!("{k}: {v}\r\n"));
}
head.push_str("access-control-allow-origin: *\r\nconnection: close\r\n\r\n");
sock.write_all(head.as_bytes()).await?;
sock.write_all(body.as_bytes()).await?;
sock.shutdown().await?;
Ok(())
}
/// The pretend sign-in page: one button that issues a session for the device's key. Like the
/// real page it reads its parameters from the URL fragment.
fn sign_in_page() -> String {
let style = "body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{width:420px}.b{background:#f5a623;color:#000;border-radius:9px;padding:2px 8px;font:600 12px ui-monospace,monospace}.m{color:#9a9a9a;font-size:13px}a.go{display:block;margin-top:18px;padding:10px;background:#2a6df4;color:#fff;text-align:center;border-radius:8px;text-decoration:none}input{width:100%;padding:8px;box-sizing:border-box}";
let script = "const p=new URLSearchParams(location.hash.slice(1));const ok=/^0[23][0-9a-f]{64}$/.test(p.get('pk')||'')&&/^[0-9a-f]{64}$/.test(p.get('state')||'')&&/^[0-9]{1,5}$/.test(p.get('port')||'');if(p.get('mode')==='export'){document.getElementById('f').innerHTML='<p>Export happens in Turnkey\\'s own iframe on the real page. This mock shows no recovery phrase.</p>'}else if(!ok){document.getElementById('f').textContent='Bad sign-in link.'}else{document.getElementById('go').href='/wallet/complete?pk='+p.get('pk')+'&state='+p.get('state')+'&port='+p.get('port');document.getElementById('k').textContent=p.get('pk').slice(0,10)}";
format!(
"<!doctype html><meta charset=utf-8><meta name=referrer content=no-referrer><title>Terminal wallet (mock)</title><style>{style}</style><main><h2>Terminal wallet <span class=b>TESTNET</span></h2><p class=m>MOCK TURNKEY (local test server, not Turnkey)</p><div id=f><p class=m>Sign in or create your wallet. The real page sends a code to your email (Turnkey).</p><p>Email<br><input value=\"[email protected]\"></p><a class=go id=go href=#>Continue</a><p class=m>The session is issued for this device's key <span id=k></span>…</p></div></main><script>{script}</script>"
)
}
+271
View File
@@ -0,0 +1,271 @@
//! The device's Turnkey credential: a P-256 key made here, and the session Turnkey issued for
//! its public key.
//!
//! Sign-in happens in the browser (see `docs/architecture/trading-hyperliquid.md` §4.5).
//! The browser never sees this key: it only asks Turnkey for a session bound to the key's
//! **public** half (`stamp_login` with `publicKey`), and hands back the session token. The
//! token says which organization and user the session belongs to and when it ends; it can't
//! authorize anything by itself, because every request must carry a stamp made with the
//! private key ([`SessionKey::stamp`]).
//!
//! The key is redacted in `Debug`, never serialized except through [`StoredSession`] for
//! the OS secret store, and its buffers are wiped on drop.
use std::fmt;
use anyhow::{Context, Result, bail, ensure};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use turnkey_api_key_stamper::{Stamp, TurnkeyP256ApiKey};
use zeroize::Zeroizing;
/// How long the sign-in page asks Turnkey to keep a session: 15 minutes, Turnkey's default.
/// Long enough to approve a trading key; nothing else needs a session.
pub const SESSION_SECONDS: u64 = 900;
/// A P-256 key generated on this device. Turnkey knows only its public half.
pub struct SessionKey(TurnkeyP256ApiKey);
impl SessionKey {
pub fn generate() -> Self {
Self(TurnkeyP256ApiKey::generate())
}
/// From the hex the secret store kept.
pub fn from_hex(hex: &str) -> Result<Self> {
let bytes = Zeroizing::new(hex::decode(hex.trim()).map_err(|_| anyhow::anyhow!("a session key is hex"))?);
TurnkeyP256ApiKey::from_bytes(bytes.as_slice(), None)
.map(Self)
.map_err(|_| anyhow::anyhow!("not a valid P-256 private key"))
}
/// The hex form for the secret store. Wiped when dropped; never log it.
pub fn to_hex(&self) -> Zeroizing<String> {
let bytes = Zeroizing::new(self.0.private_key());
Zeroizing::new(hex::encode(bytes.as_slice()))
}
/// The compressed public key as Turnkey names it (66 hex digits).
pub fn public_hex(&self) -> String {
hex::encode(self.0.compressed_public_key())
}
/// The `X-Stamp` header for a request body.
pub fn stamp(&self, body: &[u8]) -> Result<(String, String)> {
let h = self.0.stamp(body).map_err(|e| anyhow::anyhow!("stamping the request: {e}"))?;
Ok((h.name, h.value))
}
}
impl fmt::Debug for SessionKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let p = self.public_hex();
write!(f, "SessionKey({}…{})", &p[..6], &p[p.len() - 4..])
}
}
/// What a Turnkey session token says (its JWT payload). Turnkey signs the token, but
/// Terminal doesn't rely on that: anything the token claims is checked by Turnkey again on
/// every stamped request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Claims {
/// The user's sub-organization.
pub organization_id: String,
pub user_id: String,
/// The compressed P-256 key the session is bound to.
pub public_key: String,
/// Expiry, Unix seconds.
pub exp: u64,
#[serde(default)]
pub session_type: String,
}
impl Claims {
/// Read a session token's payload. The token's signature is not checked here (see the type).
pub fn parse(token: &str) -> Result<Self> {
let mut parts = token.trim().split('.');
let (Some(_), Some(payload), Some(_), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
bail!("the session token is not a JWT");
};
let json = BASE64_URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.context("the session token's payload is not base64url")?;
serde_json::from_slice(&json).context("the session token's payload is missing fields")
}
}
/// A session this device can use: its key, the token, and what the token says.
pub struct Session {
pub key: SessionKey,
pub token: String,
pub claims: Claims,
}
impl Session {
/// Accept a token from the sign-in page for `key`. Refuses a token bound to any other
/// key (another device's, or one a page made up), and one that has already expired.
pub fn accept(key: SessionKey, token: &str, now_secs: u64) -> Result<Self> {
let claims = Claims::parse(token)?;
ensure!(
claims.public_key.eq_ignore_ascii_case(&key.public_hex()),
"the sign-in answer is for a different key; nothing was saved"
);
ensure!(claims.exp > now_secs, "the session had already expired; sign in again");
ensure!(!claims.organization_id.is_empty(), "the session names no organization");
Ok(Self { key, token: token.trim().to_string(), claims })
}
/// A long-lived Turnkey API key used as a session (the live integration tests). It has
/// no token and doesn't expire on this side.
pub fn for_api_key(key: SessionKey, organization_id: &str) -> Self {
let claims = Claims {
organization_id: organization_id.to_string(),
user_id: String::new(),
public_key: key.public_hex(),
exp: u64::MAX,
session_type: "API_KEY".into(),
};
Self { key, token: String::new(), claims }
}
pub fn organization_id(&self) -> &str {
&self.claims.organization_id
}
/// Seconds left (0 once expired).
pub fn remaining(&self, now_secs: u64) -> u64 {
self.claims.exp.saturating_sub(now_secs)
}
pub fn expired(&self, now_secs: u64) -> bool {
self.remaining(now_secs) == 0
}
/// The form the OS secret store keeps (JSON with the key's hex). Wiped when dropped.
pub fn to_stored(&self) -> Zeroizing<String> {
let key = self.key.to_hex();
Zeroizing::new(
serde_json::to_string(&StoredSession { key: key.as_str(), token: &self.token }).expect("plain strings"),
)
}
/// Back from the secret store; `None` once expired (the caller deletes it).
pub fn from_stored(text: &str, now_secs: u64) -> Result<Option<Self>> {
let s: StoredSessionOwned = serde_json::from_str(text).context("the stored session is unreadable")?;
let hex = Zeroizing::new(s.key);
let key = SessionKey::from_hex(&hex)?;
match Self::accept(key, &s.token, now_secs) {
Ok(session) => Ok(Some(session)),
Err(_) if Claims::parse(&s.token).is_ok_and(|c| c.exp <= now_secs) => Ok(None),
Err(e) => Err(e),
}
}
}
impl fmt::Debug for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Session").field("key", &self.key).field("org", &self.claims.organization_id).finish()
}
}
#[derive(Serialize)]
struct StoredSession<'a> {
key: &'a str,
token: &'a str,
}
#[derive(Deserialize)]
struct StoredSessionOwned {
key: String,
token: String,
}
/// 32 random bytes as hex: the sign-in's `state`, which the callback must echo.
pub fn random_state() -> String {
let mut b = [0u8; 32];
rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut b);
hex::encode(b)
}
/// Equal in constant time (for `state`).
pub fn same_secret(a: &str, b: &str) -> bool {
a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// A session token for tests and the mock: the claims, unsigned.
#[cfg(any(test, feature = "mock"))]
pub fn unsigned_token(c: &Claims) -> String {
let h = BASE64_URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"JWT"}"#);
let p = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(c).expect("claims"));
format!("{h}.{p}.mock-signature")
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(key: &SessionKey, exp: u64) -> Claims {
Claims {
organization_id: "b0c1e8d6-0000-4000-8000-000000000001".into(),
user_id: "u-1".into(),
public_key: key.public_hex(),
exp,
session_type: "SESSION_TYPE_READ_WRITE".into(),
}
}
#[test]
fn a_session_is_accepted_only_for_this_key_and_before_it_expires() {
let key = SessionKey::generate();
let token = unsigned_token(&claims(&key, 2_000));
let other = SessionKey::generate();
let theirs = unsigned_token(&claims(&other, 2_000));
assert!(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &theirs, 1_000).is_err());
assert!(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, 2_000).is_err(), "expired");
let s = Session::accept(key, &token, 1_000).unwrap();
assert_eq!(s.remaining(1_000), 1_000);
assert_eq!(s.organization_id(), "b0c1e8d6-0000-4000-8000-000000000001");
}
#[test]
fn the_stored_form_round_trips_and_lapses() {
let key = SessionKey::generate();
let public = key.public_hex();
let token = unsigned_token(&claims(&key, 2_000));
let s = Session::accept(key, &token, 1_000).unwrap();
let stored = s.to_stored();
let back = Session::from_stored(&stored, 1_500).unwrap().unwrap();
assert_eq!(back.key.public_hex(), public);
assert_eq!(back.token, token);
assert!(Session::from_stored(&stored, 2_000).unwrap().is_none(), "expired sessions come back as none");
}
#[test]
fn debug_never_shows_the_private_key() {
let key = SessionKey::generate();
let hex = key.to_hex();
let token = unsigned_token(&claims(&key, 2_000));
let s = Session::accept(SessionKey::from_hex(&hex).unwrap(), &token, 1_000).unwrap();
for text in [format!("{key:?}"), format!("{s:?}")] {
assert!(!text.contains(hex.as_str()), "{text}");
assert!(!text.contains(&hex[..16]), "{text}");
}
}
#[test]
fn tokens_must_be_jwts_with_the_fields() {
for bad in ["", "a.b", "a.b.c.d", "x.!!!.y", "x.e30.y"] {
assert!(Claims::parse(bad).is_err(), "{bad}");
}
}
#[test]
fn states_are_random_and_compared_in_full() {
let (a, b) = (random_state(), random_state());
assert_eq!(a.len(), 64);
assert_ne!(a, b);
assert!(same_secret(&a, &a.clone()));
assert!(!same_secret(&a, &b) && !same_secret(&a, &a[..63]));
}
}
+153
View File
@@ -0,0 +1,153 @@
//! The desktop sign-in and the Turnkey calls, end to end against the local mock
//! (`tr_turnkey::mock`, a stand-in for Turnkey; see its docs). Live Turnkey checks are at the
//! bottom, `#[ignore]`d and skipped unless the environment names a Turnkey organization.
use std::time::Duration;
use alloy_primitives::{Address, B256};
use tr_turnkey::callback::{Answer, Callback};
use tr_turnkey::mock::{MockTurnkey, USERNAME};
use tr_turnkey::session::{SESSION_SECONDS, random_state};
use tr_turnkey::{Client, Session, SessionKey, desktop_sign_in_url};
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
}
/// What the browser does: open the page's continue link and follow its redirect to the
/// loopback callback.
async fn browser_signs_in(mock: &MockTurnkey, pk: &str, state: &str, port: u16) -> reqwest::Response {
let url = format!("{}/wallet/complete?pk={pk}&state={state}&port={port}", mock.base);
reqwest::get(url).await.unwrap()
}
#[tokio::test]
async fn the_desktop_signs_in_through_the_browser_and_finds_its_wallet() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let pk = key.public_hex();
let state = random_state();
let cb = Callback::bind().await.unwrap();
let port = cb.port();
let url = desktop_sign_in_url(&format!("{}/wallet/", mock.base), &pk, &state, port);
assert!(url.contains(&format!("pk={pk}")) && url.contains("flow=desktop"), "{url}");
assert!(!url.contains(&key.to_hex()[..]), "the private key never goes in the URL");
let waiter = tokio::spawn({
let state = state.clone();
async move { cb.wait(&state, Duration::from_secs(20)).await }
});
let page = browser_signs_in(&mock, &pk, &state, port).await;
assert_eq!(page.status(), 200, "the loopback answered the redirected browser");
assert!(page.text().await.unwrap().contains("Signed in"));
let Answer::Session(token) = waiter.await.unwrap().unwrap() else { panic!("no session") };
let session = Session::accept(key, &token, now_secs()).unwrap();
assert!(session.remaining(now_secs()) <= SESSION_SECONDS);
let client = Client::with_base(&mock.base).unwrap();
let me = client.whoami(&session).await.unwrap();
assert_eq!(me.username, USERNAME);
let account = client.ethereum_account(&session).await.unwrap();
assert_eq!(account.address.to_ascii_lowercase(), mock.address());
assert_eq!(account.path, "m/44'/60'/0'/0/0");
}
#[tokio::test]
async fn a_request_without_this_sign_ins_state_does_not_end_it() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let state = random_state();
let cb = Callback::bind().await.unwrap();
let port = cb.port();
let waiter = tokio::spawn({
let state = state.clone();
async move { cb.wait(&state, Duration::from_secs(20)).await }
});
// Another tab (or program) with the wrong state, and a stray path.
let stray = browser_signs_in(&mock, &key.public_hex(), &random_state(), port).await;
assert_eq!(stray.status(), 400);
let favicon = reqwest::get(format!("http://127.0.0.1:{port}/favicon.ico")).await.unwrap();
assert_eq!(favicon.status(), 404);
assert!(!waiter.is_finished());
// The real one still gets through.
browser_signs_in(&mock, &key.public_hex(), &state, port).await;
assert!(matches!(waiter.await.unwrap().unwrap(), Answer::Session(_)));
}
#[tokio::test]
async fn the_sign_in_gives_up_after_its_timeout() {
let cb = Callback::bind().await.unwrap();
let err = cb.wait(&random_state(), Duration::from_millis(200)).await.unwrap_err();
assert!(err.to_string().contains("didn't come back"), "{err}");
}
#[tokio::test]
async fn a_session_for_another_key_or_one_turnkey_never_issued_is_refused() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let mine = SessionKey::generate();
let theirs = SessionKey::generate();
// A page that hands back a session for someone else's key: refused before any request.
let token = mock.mint_session(&theirs.public_hex(), 60);
assert!(Session::accept(SessionKey::from_hex(&mine.to_hex()).unwrap(), &token, now_secs()).is_err());
// Turnkey (here the mock) refuses stamps from keys it issued no session for.
let unknown = SessionKey::generate();
let pk = unknown.public_hex();
let fake = tr_turnkey::session::unsigned_token(&tr_turnkey::session::Claims {
public_key: pk,
..tr_turnkey::session::Claims::parse(&token).unwrap()
});
let s = Session::accept(unknown, &fake, now_secs()).unwrap();
let err = Client::with_base(&mock.base).unwrap().whoami(&s).await.unwrap_err();
assert!(err.to_string().contains("HTTP 401"), "{err}");
assert!(mock.requests().is_empty());
}
/// `sign_digest` sends what `alloy-signer-turnkey` sends, and the signature recovers the
/// wallet.
#[tokio::test]
async fn the_wallet_signs_exactly_the_digest_it_is_given() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 60);
let session = Session::accept(key, &token, now_secs()).unwrap();
let client = Client::with_base(&mock.base).unwrap();
let wallet: Address = mock.address().parse().unwrap();
let digest = B256::from([7u8; 32]);
let sig = client.sign_digest(&session, &wallet, &digest).await.unwrap();
assert_eq!(sig.recover_address_from_prehash(&digest).unwrap(), wallet);
let (path, body) = mock.requests().pop().unwrap();
assert_eq!(path, "/public/v1/submit/sign_raw_payload");
let mut want = tr_turnkey::client::sign_raw_payload_body(tr_turnkey::mock::ORG_ID, &wallet, &digest, 0);
want["timestampMs"] = body["timestampMs"].clone();
assert_eq!(body, want);
// Another address is not this organization's wallet: Turnkey refuses, nothing is signed.
let other = Address::from([1u8; 20]);
assert!(client.sign_digest(&session, &other, &digest).await.is_err());
}
// ---- live Turnkey (needs an organization and an API key from Micky) ------------------------
/// `TURNKEY_ORGANIZATION_ID`, `TURNKEY_API_PRIVATE_KEY` (hex P-256) and
/// `TURNKEY_SIGN_WITH` (an Ethereum address in that organization). Skips when unset.
fn live() -> Option<(Session, Address)> {
let org = std::env::var("TURNKEY_ORGANIZATION_ID").ok()?;
let key = SessionKey::from_hex(&std::env::var("TURNKEY_API_PRIVATE_KEY").ok()?).ok()?;
let with: Address = std::env::var("TURNKEY_SIGN_WITH").ok()?.parse().ok()?;
Some((Session::for_api_key(key, &org), with))
}
#[tokio::test]
#[ignore = "live Turnkey: set TURNKEY_ORGANIZATION_ID, TURNKEY_API_PRIVATE_KEY, TURNKEY_SIGN_WITH"]
async fn live_turnkey_signs_a_digest_that_recovers_the_wallet() {
let Some((session, with)) = live() else {
eprintln!("skipped: no Turnkey organization in the environment");
return;
};
let client = Client::new().unwrap();
client.whoami(&session).await.unwrap();
let digest = B256::from([0x42; 32]);
let sig = client.sign_digest(&session, &with, &digest).await.unwrap();
assert_eq!(sig.recover_address_from_prehash(&digest).unwrap(), with);
}
+64
View File
@@ -0,0 +1,64 @@
# tr-gateway on Freestyle
The Terminal data server runs on a Freestyle VM at **https://terminal-gateway.style.dev**. Clients connect to `wss://terminal-gateway.style.dev/ws`, for example the desktop app:
```sh
TR_SERVER_URL=wss://terminal-gateway.style.dev cargo run --release
```
The desktop reads `TR_SERVER_URL` at startup, and the site's deploy workflow reads the repository variable of the same name (`server/README.md`), so moving to another domain changes no code.
## What runs where
- One VM, slug `terminal-gateway`, from the `freestyle/ubuntu-sm` snapshot: 2 vCPU, 4 GiB of memory, 16 GB of disk.
- `tr-gateway` runs under systemd (`tr-gateway.service`) as the unprivileged user `trgw`, bound to `0.0.0.0:8080`. systemd restarts it whenever it exits.
- Freestyle's edge terminates TLS on 443 and forwards to port 8080, so the gateway itself serves plain HTTP and websockets.
- The source lives in `/opt/terminal/src`. `/opt/terminal/DEPLOYED_COMMIT` holds the commit that's running, and the CLI example is installed as `tr-cli`.
## Why this size
The VM builds the gateway from source on every deploy, so the 16 GB disk holds the Rust toolchain and the build cache. On 2 vCPU a first build takes about 4 minutes, and later ones are incremental. The gateway itself is one process with a few upstream websockets: on a Mac against the live feed, with one client watching BTC's quotes, book, trades and 1m bars, its resident memory was about 15 MiB. A bigger VM buys nothing until there are many more clients.
## Deploy or update
From a machine with Node (for `npx`), `jq`, `curl` and a Freestyle API key:
```sh
export FREESTYLE_API_KEY=... # never commit or paste it
deploy/freestyle/deploy.sh master # or a branch, tag or full commit
```
`deploy.sh` creates the VM if it's missing and publishes the domain with a TLS rule if that's missing. It then copies `vm-setup.sh` to the VM and runs it at the ref you give. The VM builds that ref from GitHub, so push it first. `vm-setup.sh` installs the build tools and Rust (keeping Rust on the latest stable, like CI), builds `tr-gateway` and the CLI with `--locked`, installs them, writes the systemd unit and restarts the service. It waits for `/readyz` on the VM, and `deploy.sh` then waits for `/readyz` through the public URL.
A deploy restarts the gateway, which drops every client connection. Clients reconnect and resubscribe on their own. A subscription made while the gateway is still starting gets `Stale`, then `Live` and its snapshot once the gateway has its instrument list and mids (`server/README.md`).
## Check health
```sh
curl -fsS https://terminal-gateway.style.dev/healthz # the process is up
curl -fsS https://terminal-gateway.style.dev/readyz # upstream connected, every venue's mids in
cli() { cargo run -q --release -p tr-gateway --example cli -- --url wss://terminal-gateway.style.dev "$@"; }
TR_METRICS_TOKEN=... cli stats # the token only when the gateway has one
cli watch --quotes BTC --book BTC --trades BTC --bars BTC:1m --secs 30
```
On the VM, `systemctl status tr-gateway` and `journalctl -u tr-gateway` show the service and its logs, and `cat /opt/terminal/DEPLOYED_COMMIT` shows what's deployed. The gateway's logs never carry addresses.
## Cost
2 vCPU and 4 GiB running around the clock come to about $80 a month of Freestyle usage above the free allowance.
## Settings and the operator token
`/stats` (JSON) and `/metrics` (Prometheus text) show counts, budgets, latency and the upstream subscriptions, which reveal the markets clients are watching. They carry no client addresses or user data. With `TR_METRICS_TOKEN` set they answer 404 to anyone without the token, and `/healthz` and `/readyz` stay public.
Settings for the service go in `/etc/tr-gateway/env` on the VM, one `NAME=value` a line, readable only by root. `vm-setup.sh` creates the directory but never writes or prints the file, so a deploy keeps it. To create a token on the VM without it crossing the network or a terminal:
```sh
npx -y freestyle@0.2.21 vm exec terminal-gateway --linux-user root -- bash -c \
'umask 077; f=/etc/tr-gateway/env; grep -q ^TR_METRICS_TOKEN= "$f" 2>/dev/null || echo "TR_METRICS_TOKEN=$(openssl rand -hex 32)" >> "$f"; systemctl restart tr-gateway'
```
Reading it back for `cli stats` or a scraper prints it, so do that off stream. Without the token, `cli stats` says the server answered 404.
The unit also caps the service at 3 GiB of memory (reclaiming hard past 2 GiB) and 512 tasks, and runs it with most of systemd's sandboxing on: no capabilities, a read-only system, no devices, no kernel tunables or modules, and only IP, Unix and netlink sockets. `systemd-analyze security` rates it 3.1 (OK), down from 8.3 (exposed) before.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Deploy or update tr-gateway on a Freestyle VM, published at https://$DOMAIN (wss://$DOMAIN/ws).
# The VM builds the ref from GitHub, so push it first.
# Needs the Freestyle CLI auth in the environment (FREESTYLE_API_KEY); nothing is printed from it.
# Usage: deploy/freestyle/deploy.sh [git-ref] (default: master)
# Env: TR_VM_SLUG (default terminal-gateway), TR_DOMAIN (default terminal-gateway.style.dev),
# TR_VM_SNAPSHOT (default freestyle/ubuntu-sm: 2 vCPU / 4 GiB / 16 GB),
# TR_FREESTYLE_CLI (default [email protected], the npm package that runs the CLI)
set -euo pipefail
REF="${1:-master}"
SLUG="${TR_VM_SLUG:-terminal-gateway}"
DOMAIN="${TR_DOMAIN:-terminal-gateway.style.dev}"
SNAP="${TR_VM_SNAPSHOT:-freestyle/ubuntu-sm}"
CLI="${TR_FREESTYLE_CLI:-freestyle@0.2.21}"
HERE="$(cd "$(dirname "$0")" && pwd)"
[ -n "${FREESTYLE_API_KEY:-}" ] || { echo "set FREESTYLE_API_KEY" >&2; exit 1; }
for tool in npx jq curl; do
command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 1; }
done
fs() { npx -y "$CLI" "$@"; }
if ! fs --output json vm get "$SLUG" >/dev/null 2>&1; then
echo "creating VM $SLUG from $SNAP"
fs --output json vm create --snapshot-id "$SNAP" --slug "$SLUG" --display-name "Terminal data server (tr-gateway)" \
--metadata app=terminal --metadata role=tr-gateway --no-ssh >/dev/null
fi
VM_ID="$(fs --output json vm get "$SLUG" | jq -r .id)"
if ! fs --output json tls list | jq -e --arg d "$DOMAIN" '.rules[]? | select(.domain==$d)' >/dev/null; then
echo "publishing https://$DOMAIN -> $SLUG:8080"
fs --output json tls create --domain "$DOMAIN" --from public --to "vm=$VM_ID,port=8080" >/dev/null
fi
fs vm scp "$HERE/vm-setup.sh" "$SLUG:/root/vm-setup.sh" >/dev/null
# The first build takes about 4 minutes on 2 vCPU; later ones are incremental.
fs vm exec "$SLUG" --linux-user root --timeout-ms 1800000 -- bash /root/vm-setup.sh "$REF"
# The VM said it's ready; check the public path through Freestyle's edge too.
deadline=$((SECONDS + 60))
while [ "$SECONDS" -lt "$deadline" ]; do
if curl -fsS --connect-timeout 5 --max-time 10 "https://$DOMAIN/readyz" >/dev/null 2>&1; then
echo "ready: wss://$DOMAIN/ws"
exit 0
fi
sleep 2
done
echo "deployed, but https://$DOMAIN/readyz is not answering 200 yet" >&2
exit 1
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Runs as root inside a Freestyle Ubuntu VM. Builds tr-gateway from a git ref and
# runs it under systemd on 0.0.0.0:8080; Freestyle's edge terminates TLS on 443.
# Usage: vm-setup.sh [git-ref] (a branch, tag or full commit; default: master)
set -euo pipefail
REF="${1:-master}"
REPO="https://github.com/michaelshimeles/terminal.git"
SRC=/opt/terminal/src
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq build-essential pkg-config libssl-dev git curl ca-certificates >/dev/null
if ! id trgw >/dev/null 2>&1; then useradd --system --create-home --home-dir /var/lib/trgw --shell /usr/sbin/nologin trgw; fi
# CI builds with the latest stable Rust, so the VM keeps up with it.
export RUSTUP_HOME=/opt/rust/rustup CARGO_HOME=/opt/rust/cargo PATH=/opt/rust/cargo/bin:$PATH
if command -v cargo >/dev/null; then
rustup update --no-self-update stable >/dev/null
else
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable --no-modify-path
fi
mkdir -p /opt/terminal
if [ ! -d "$SRC/.git" ]; then
git clone --quiet "$REPO" "$SRC"
fi
git -C "$SRC" fetch --quiet origin "$REF"
git -C "$SRC" checkout --quiet --detach FETCH_HEAD
cd "$SRC"
echo "building $(git rev-parse HEAD)"
cargo build --release --locked -p tr-gateway --bin tr-gateway --example cli
install -m 0755 target/release/tr-gateway /usr/local/bin/tr-gateway.new
mv /usr/local/bin/tr-gateway.new /usr/local/bin/tr-gateway
install -m 0755 target/release/examples/cli /usr/local/bin/tr-cli
git rev-parse HEAD > /opt/terminal/DEPLOYED_COMMIT
# Settings and secrets (TR_METRICS_TOKEN and the like) go in /etc/tr-gateway/env, which only
# root reads. This script never writes or prints that file; see README.md.
install -d -m 0700 -o root -g root /etc/tr-gateway
cat > /etc/systemd/system/tr-gateway.service <<'UNIT'
[Unit]
Description=Terminal data server (tr-gateway)
After=network-online.target
Wants=network-online.target
# Keep restarting however often it fails; systemd would otherwise give up after 5 quick tries.
StartLimitIntervalSec=0
[Service]
User=trgw
Environment=TR_GATEWAY_BIND=0.0.0.0:8080
Environment=RUST_LOG=info
# Optional; read by systemd as root before it drops to trgw. Overrides the lines above.
EnvironmentFile=-/etc/tr-gateway/env
ExecStart=/usr/local/bin/tr-gateway
Restart=always
RestartSec=2
# SIGTERM closes every session and upstream connection first; that takes a second or two.
TimeoutStopSec=15
LimitNOFILE=65536
# The gateway uses tens of MiB. Past MemoryHigh the kernel reclaims hard; past MemoryMax it's
# killed and restarted, before the VM itself runs out.
MemoryHigh=2G
MemoryMax=3G
TasksMax=512
UMask=0077
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
CapabilityBoundingSet=
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable tr-gateway >/dev/null 2>&1
systemctl restart tr-gateway
# Wait until the process is up (/healthz), then until it has the instrument list and
# every venue's mids (/readyz).
wait_for() {
local deadline=$((SECONDS + $2))
while [ "$SECONDS" -lt "$deadline" ]; do
if curl -fsS --connect-timeout 2 --max-time 5 "http://127.0.0.1:8080/$1" >/dev/null 2>&1; then return 0; fi
sleep 1
done
echo "/$1 did not answer 200 within $2 s" >&2
systemctl --no-pager status tr-gateway >&2 || true
journalctl --no-pager -u tr-gateway -n 30 >&2 || true
return 1
}
wait_for healthz 30
wait_for readyz 90
echo "deployed $(cat /opt/terminal/DEPLOYED_COMMIT); ready"
+804
View File
@@ -0,0 +1,804 @@
# Terminal data server: design
Status: **draft for review**. The first server code runs on an interim public feed (see [Interim: public feed](#interim-public-feed)).
Author: Grok Bot, for Micky. Research date: 2026-10-29. Times are UTC unless marked.
Terminal today fetches all of its own data: Yahoo, Binance and Coinbase websockets, Treasury.gov, Senate eFD and the House Clerk, and SnapTrade. Each desktop process opens its own connections. The browser build serves sample data wherever a source blocks cross-origin requests (`TODO(web proxy)` in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs`). This doc proposes the replacement Micky asked for: **one Rust server that every client (desktop, web, later mobile) connects to, so market data and user state sync in real time**. Hyperliquid is the first market-data source, and the design leaves a clean provider interface for licensed equities and options later.
**Decision (Micky, 2026-10-29): Terminal runs on our own Hyperliquid non-validator node(s) and our own systems from day one.** Live data comes only from the node's output files and its local info server: books, trades, candles, mids, marks, oracle prices and funding. The public Hyperliquid API is used for nothing live. At most, it serves a one-time history backfill (§5.6).
## Interim: public feed
On 2026-10-29 Micky approved starting on Hyperliquid's public API, because a node box (about $735 a month, §6.3) costs too much for now. Until the node is up, this is what runs:
- `tr-gateway` (`server/`) with `HlPublicProvider`, which implements the `Provider` trait (§3.1) over `wss://api.hyperliquid.xyz/ws` and the public `info` endpoint. Clients speak `tr-proto` (§4) and can't tell providers apart, so `HlNodeProvider` replaces it later with no client or protocol change.
- One set of upstream connections serves every client. Mids for the core dex and for every HIP-3 dex with listed markets stay subscribed. The gateway subscribes a market's book, trades and candles only while a client watches it, and drops them 20 s after the last one leaves.
- A book takes two `l2Book` subscriptions. The default one has 20 levels a side but arrives only every ~5.4 s (p95 5.6 s); `fast: true` has 5 levels a side and arrives every ~0.5 s (p95 0.9 s) [observed for BTC, 2026-11-02]. The gateway puts the fast levels on top of the default book's deeper ones, so books refresh about every 0.5 s and keep 20 levels. Each watched book counts twice against the subscription budget. If the budget only has room for the default one, the book refreshes every ~5 s as before.
- The gateway keeps its own budgets under Hyperliquid's per-IP limits [doc: RL]: at most 8 of 10 connections, 950 of 1000 subscriptions, 1800 of 2000 sent messages a minute and 1100 of 1200 REST weight a minute (defaults 3, 900, 1000 and 800). It refuses a client subscription past the budget, and it never rotates IPs or uses proxies [doc: TOU §3.1.8].
- Hyperliquid closes the whole websocket when asked for a coin it doesn't list, with no error message [observed 2026-10-29]. The gateway only subscribes coins that have a live mid, and keeps the permanent mids on a connection of their own. Delisted markets keep their mids, so the gateway also checks `isDelisted` in `meta` and serves none of them (§7.5).
- History comes from `candleSnapshot`, so it stops at the most recent 5000 bars per interval [doc: INFO]. For 1m bars that is about 3.5 days. Node-built candles (§5.3) lift the cap.
- It makes read-only public calls only. No `exchange` endpoint, no user subscriptions, no orders.
- The desktop opts in with `TR_SERVER_URL` (§4.6). Crypto keys such as `BTCUSD Curncy` then come from Hyperliquid core perps, and the order book pane names the source. HIP-3 perps such as `xyz:AAPL` come only through the server, as instruments of their own labelled `PERP · HL xyz` (§7). Everything else is unchanged.
- The web build uses the server for the same crypto markets. `scripts/build-web.sh` builds in `wss://terminal-gateway.style.dev/ws` unless `TR_SERVER_URL` says otherwise. While the server is unreachable, the page falls back to Binance and Coinbase as before.
- It runs on one Freestyle VM (2 vCPU, 4 GiB) at `wss://terminal-gateway.style.dev/ws`, behind Freestyle's TLS edge. `deploy/freestyle/README.md` covers deploys, health checks and cost, which is about $80 a month above the free allowance.
How facts are marked:
- **[doc: …]** is a fact from official documentation. Sources are listed in [References](#references).
- **[observed]** is a fact from read-only calls I made against `api.hyperliquid.xyz` on 2026-10-29 around 17:35 UTC, or from reading the public `order_book_server` source. These are snapshots, not guarantees.
- **[unverified]** is an assumption or estimate that nobody has checked yet. It needs confirming before we build on it.
- **[target]** is a goal we set for ourselves. Nobody has measured it yet.
---
## 1. Goals and non-goals
### Goals
1. **Our own node, our own systems.** We run Hyperliquid non-validator nodes in Tokyo (§6). Our `tr-ingest` builds every order book, trade tape, candle and price from the node's outputs (§5). There are no live dependencies on `api.hyperliquid.xyz`, so its rate limits don't apply to live data.
2. **One source, many clients.** Ingest computes everything once. The gateway fans it out, so cost scales with instruments, not with clients (§5.9).
3. **The same data everywhere.** Desktop, web (WASM) and mobile see the same quotes, bars, books and user documents, with live updates.
4. **Hyperliquid first:** core perps, spot, and HIP-3 builder dexes (trade.xyz `xyz:` stocks, indices, FX and commodities, and the other dexes).
5. **A provider interface clean enough** that a licensed equities and options vendor (for example Intrinio, Twelve Data or Massive) slots in later with no protocol change. Each provider also carries a display-rights policy (§3.1).
6. **User sync:** accounts, layouts, watchlists, drawings, preferences and the demo (paper) portfolio, with explicit conflict rules.
7. **The web build gets real data.** This removes the `TODO(web proxy)` stubs.
8. **Candles at every interval with no history cap,** built from trades on our node (§5.3).
9. **The terminal stays snappy.** The protocol is binary and conflated, and the UI thread never waits on the network. This keeps the existing worker model in `app/src/worker.rs`.
### Non-goals (for this design)
- **No trading through the server.** The server is data only. It never signs, routes or places orders on Hyperliquid or anywhere else. The SnapTrade order paths in `crates/broker/src/trading.rs` stay client-side on the desktop, with the user's local credentials. They are unchanged and out of scope (§9). The reasons are the legal flag in §13 (Hyperliquid's Terms of Use) and Micky's steer. The demo account is paper trading against our own quotes and never touches a venue.
- No licensed stock or options data yet. Only the interface for it is in scope (§3.1).
- No public API for third parties. The protocol is for our own clients.
- No running of a Hyperliquid **validator**. We run non-validators only (§6).
- No live use of the public Hyperliquid API (the one-time backfill in §5.6 is the only exception).
- No multi-region active-active for v1.
---
## 2. Current data layer (what we map onto)
| Area | Today | Code |
|---|---|---|
| Provider trait | `MarketData` (async): `history`, `quotes`, `live`, `search`, `news`, `options`, `order_book`, `yield_curves`. Wrapped in `Fallback`, which serves sample data on error. | `crates/data/src/lib.rs` |
| Keys | Bloomberg-style market keys from `Security { ticker, class }`: `AAPL US Equity`, `BTCUSD Curncy`, `SPX Index`, `CL1 Comdty`, `US10Y Govt`. `AssetClass` has Equity, Etf, Index, Fx, Crypto, Govt and Cmdty. The yellow keys are Equity, Index, Curncy, Govt and Comdty. | `crates/core/src/security.rs`, `catalog.rs` |
| Data types | `Bar {ts, open, high, low, close, volume}` (ts is the open time in Unix seconds), `Series`, `Timeframe` (M1 to MN1), `Quote {symbol,last,prev_close,ts,spark}`, `OrderBook {bids,asks,source,simulated}`, `OptionChain`, `YieldCurves`, `NewsItem`, `SearchHit`. All of them derive serde. | `crates/core/src/bar.rs`, `timeframe.rs`, `crates/data/src/lib.rs` |
| Live data | `LiveFeed = mpsc::Receiver<Bar>` holds the latest state of the forming bar. Crypto comes from Binance kline websockets, with Coinbase as the fallback. | `crates/data/src/crypto.rs`, `ws.rs` |
| Runtime | tokio on the desktop. On wasm32, `rt::spawn` runs tasks on the browser event loop. | `crates/data/src/rt.rs` |
| Worker | The UI sends `Request` (History, MoreHistory, Quotes, OrderBook, Options, News, Search, Congress queries, Portfolio, Broker*) and drains `Response`, so it never blocks. | `app/src/worker.rs` |
| Bar cache | SQLite `BarCache` (desktop only). | `crates/data/src/cache.rs` |
| Congress | Senate eFD and House Clerk ingest into SQLite, refreshed every 30 minutes (`CONGRESS_REFRESH_MIN`). The web build gets an empty stub. | `crates/congress/` |
| Brokerage | SnapTrade, read-only. The partner keys and the per-user secret live in the Keychain or a 0600 file. | `crates/broker/src/snaptrade.rs`, `secrets.rs` |
| Demo | `DemoBook` starts with $100k (`STARTING_CASH`) and is saved as local JSON. Orders fill at the terminal's live quote. | `crates/broker/src/demo.rs` |
| User docs | Layouts (`LayoutDoc` JSON, which holds the panes, their drawings and the watchlist panes), prefs, themes and palette history. They are files on the desktop and `localStorage` in the browser. | `app/src/storage.rs`, `layouts.rs`, `prefs.rs` |
The plan keeps `MarketData` as the *client-side* trait. A new `RemoteMarkets` implementation forwards each call to the server (§4.6), so the panes, the worker and the chart code don't change. On the server, a new, richer `Provider` trait (§3.1) sits behind the ingest adapters.
---
## 3. Components
```
Tokyo (node hosts, x2 for failover) Gateway host(s)
+----------------------------------------------+ +----------------------------------------------+
| hl-visor -> hl-node (non-validator) | | tr-gateway |
| ~/hl/data/node_fills_by_block/... | | hot store (watch/broadcast), refcounts |
| ~/hl/data/node_order_statuses_by_block/... | internal | auth, subs, conflation, backpressure |--> desktop
| ~/hl/data/node_raw_book_diffs_by_block/... | stream | user sync (docs, drawings, demo book) |--> web (WASM)
| ~/hl/data/hip3_oracle_updates/..., misc_.. |--------->| Congress, SnapTrade (read-only), others |--> mobile
| localhost:3001/info (subset, fileSnapshot) | tr-proto | persistence: Postgres/SQLite |
| tr-ingest (same host) | over +----------------------------------------------+
| file tailers -> book builder (L4->L2/BBO) | WG/mTLS
| trade tape -> candle builder (all TFs) |
| prices: mid, last, oracle, mark, funding |
| candle store (no cap), checkpoints |
+----------------------------------------------+
```
`tr-ingest` has to run **on the node host**, because it reads the node's local files and its `localhost` info server. The reference order-book server is also meant to run on the same machine as the node [doc: OBS]. `tr-gateway` can share the host or sit on a separate small machine (§11).
### 3.1 Ingest adapters behind a `Provider` trait
```rust
/// Server-side source of market data. One instance per upstream (Hyperliquid, a licensed
/// equities vendor, ...). The registry asks each provider what it can serve.
#[async_trait]
pub trait Provider: Send + Sync + 'static {
fn id(&self) -> ProviderId; // "hyperliquid", "intrinio", ...
fn capabilities(&self) -> Capabilities; // channels × asset classes it serves
/// Display rights for data from this provider. Enforced by the gateway, not the client.
fn display_policy(&self) -> DisplayPolicy;
/// Instruments this provider lists right now (refreshed periodically; delistings appear here).
async fn instruments(&self) -> Result<Vec<InstrumentDef>>;
/// Historical bars, newest `limit` before `end` (same contract as MarketData::history).
async fn history(&self, inst: &InstrumentId, tf: Timeframe, end: Option<i64>, limit: usize)
-> Result<Vec<Bar>>;
/// Start an upstream stream; events go to `sink` until the handle is dropped.
async fn subscribe(&self, ch: Channel, inst: &InstrumentId, sink: UpstreamSink)
-> Result<UpstreamHandle>;
}
pub struct DisplayPolicy {
/// May we show this data to the general public (any signed-in or anonymous user)?
pub public_display: bool,
/// If not public: which entitlement unlocks it (e.g. a licensed seat, a BYO key, staff).
pub entitlement: Option<Entitlement>,
/// Required delay before display to non-entitled users (licensed vendors often have one).
pub delay: Option<Duration>,
/// Attribution string the UI must show ("Data: Hyperliquid", "trade.xyz oracle", ...).
pub attribution: &'static str,
}
```
Rules:
- **Secondary crypto exchanges are off by default for public display.** Coinbase and OKX ban public display without written consent, and Kraken requires permission (provider research from the parent agent, which I have not re-checked here). Binance's terms are not reviewed [unverified]. So the existing `CryptoProvider` (Binance and Coinbase) moves server-side with `public_display: false`. It is only served to entitled users (staff, for example) until each venue's rights are confirmed in writing.
- Yahoo Finance and the RSS news sources get `public_display: false` until reviewed [unverified]. Treasury.gov is US government data and is *likely* fine [unverified].
- The gateway checks `DisplayPolicy` on every subscribe (§4). A client can't opt itself in.
- Licensed stocks and options (Intrinio, Twelve Data, Massive or another vendor) arrive later as one more `Provider`. Their entitlement and delay map straight onto `DisplayPolicy`.
Adapters at launch:
| Adapter | Source | Notes |
|---|---|---|
| `HlNodeProvider` | Our node's output files plus its local info server (`localhost:3001/info`) | §5 and §6. The only live Hyperliquid source. |
| `HlBackfill` (one-time job) | S3 node archives and/or public `candleSnapshot` | §5.6. Not a live provider. |
| `CongressProvider` | Senate eFD and House Clerk | Reuses `tr-congress` unchanged (§9) |
| `SnapTradeProvider` | SnapTrade, read-only server mode | Per-user and private, so it is not a market-data topic. Desktop local mode is unchanged (§9). |
| `CryptoProvider` (existing) | Binance and Coinbase | Off for public display |
| `TreasuryProvider` (existing) | Treasury.gov, with FRED as fallback | Yield curves |
### 3.2 Normalizer
This converts provider payloads into the shared types in `tr-core` and `tr-data`, plus a few new fields:
- **Numbers.** Node outputs carry prices and sizes as decimal **strings** (for example `"px": "51.367"`) [doc: L1]. The public API does the same, including websocket candles, which the docs type as numbers [observed]. Parsers accept either. The normalizer parses to `f64` for the existing types. It keeps the original string only where exact decimals matter: tick or lot rounding in the demo book.
- **Time.** Node events carry `time` as an ISO-like string (for example `"2024-07-26T08:26:25.899"`) [doc: L1]. Batched output adds `block_time` and `block_number` [doc: NODE]. We treat these as UTC [unverified: no zone suffix in the examples]. `Bar.ts` is the bar's open time in seconds, as today.
- **Intervals.** We build every app `Timeframe` (M1, M5, M15, M30, H1, H4, D1, W1, MN1) ourselves, plus any extra interval we choose (§5.3). Hyperliquid's own intervals (`1m` to `1M` [doc: INFO]) only matter for the one-time backfill.
- **Books.** Our L2 aggregation (§5.2) becomes `OrderBook { bids, asks, source: "Hyperliquid (own node)", simulated: false }`. L4 (per-order) views get a new type.
- **Price basis.** This is new and required for the perp-as-proxy mapping (§7). Every quote carries `basis: Mid | Mark | Oracle | Last`, `venue`, `instrument_kind: Spot | Perp | Equity | …`, plus `session: Regular | Extended | Closed | Internal24x7` when the provider knows it.
### 3.3 In-memory hot store with fan-out
- The store is a **topic table** keyed by `Topic` (for example `Quote(MarketKey)`, `Bars(MarketKey, Timeframe)`, `Book(MarketKey, depth, group)`, `Trades(MarketKey)`, `Ctx(MarketKey)`, `Congress(Latest)`, `Doc(UserId, DocId)`).
- **State topics** (quote, book, ctx) use `tokio::sync::watch` style "latest value wins". A slow reader skips intermediate values, which conflates naturally.
- **Bars are a hybrid, not a pure state topic.** Updates to the *forming* bar may be conflated, but only within the same bar `ts`. When the bar `ts` advances, the server first emits a non-conflatable `BarClosed { bar }` event with the previous bar's final OHLCV, then the new forming bar. `BarClosed` goes through the event ring like trades, so a slow client can never skip a closed bar's final values. If it overflows, the client gets `Resync` and a fresh history snapshot (§4.3).
- **Event topics** (trades, congress alerts, document ops) use a bounded `broadcast` ring with a per-topic sequence number. A reader that falls behind the ring gets `Resync` (§4.3).
- Each topic keeps a **refcount** of client subscribers. Ingest maintains *every* book, tape and candle regardless of demand, because it has to apply every diff to stay correct. The gateway's refcount only decides which per-coin topics it materialises and serialises for clients. Topics are dropped after a **linger** period (start at 60 s [target]) once the count returns to 0.
- Snapshots are served from memory, so a new client gets the current book or quote immediately.
### 3.4 Persistence (candles and history)
- **Candles:** built by ingest from node trades (§5.3) and stored with **no cap**. We keep 1m bars forever, plus materialised higher intervals. Each bar records its provenance: `node:<host>`, `backfill:s3`, `backfill:api` or `repaired`.
- **Storage:** v1 uses SQLite (WAL) on the node host's NVMe for the candle store, reusing the `BarCache` schema ideas, and replicates closed bars to the gateway's database. Postgres or Timescale can come once there are several gateway instances. Size: ~1,000 active instruments × 1,440 1m bars/day ≈ 1.4M rows/day, roughly **70–150 MB/day or 25–55 GB/year** at 50–100 bytes per row [unverified, rough; this is an upper bound because quiet minutes write no row].
- **Trade tape:** we keep the deduplicated trades (§5.3) for 30 days [target] for the tape and for rebuilding bars. Raw node files are rotated away (§6.4).
- **Also persisted** (gateway side): congress trades, user documents, demo books and audit logs (§8).
### 3.5 Client gateway
- **Transport:** WebSocket over TLS (axum with tokio-tungstenite on the server). The web client uses the browser `WebSocket` that `crates/data/src/ws.rs` already wraps.
- **Per-connection task:** auth handshake, a subscription set, a bounded outbound queue, a conflation timer and heartbeats.
- **Enforcement:** auth, per-user limits (subscriptions per connection, messages per second), and `DisplayPolicy`.
- **HTTP endpoints:** `/healthz`, `/readyz` and `/metrics` (internal), plus OAuth callback routes (§8).
---
## 4. Client protocol
### 4.1 Shared crate: `crates/proto` (`tr-proto`)
The protocol is one crate, used by the server, the desktop app and the WASM build. It depends only on `serde` and `tr-core`, so it compiles for `wasm32-unknown-unknown` (CI checks this). Every message type lives here, and the server and clients cannot drift apart because they compile the same definitions.
### 4.2 Message schema
```rust
pub const PROTOCOL_VERSION: u16 = 1;
/// Client → server
pub enum ClientMsg {
Hello { version: u16, encoding: Encoding, client: ClientInfo, token: Option<String>, resume: Option<ResumeToken> },
Subscribe { id: SubId, topic: TopicReq }, // client-chosen id, unique per connection
Unsubscribe { id: SubId },
Resync { id: SubId }, // client detected a gap; ask for a fresh snapshot
Request { id: ReqId, req: Rpc }, // one-shot: History{key,tf,end,limit}, Search, Options…
DocOp { id: ReqId, op_id: OpId, doc: DocId, base_rev: u64, op: DocOperation }, // user-sync writes (§8); op_id is a client UUID, stable across retries
Ping { t: u64 },
}
/// Server → client
pub enum ServerMsg {
Welcome { version: u16, session: SessionId, user: Option<UserInfo>, server_time_ms: i64, limits: Limits },
Snapshot { id: SubId, seq: u64, data: TopicData }, // full state; resets the subscription's seq
Delta { id: SubId, seq: u64, data: TopicDelta }, // seq = previous + 1
Resync { id: SubId, reason: ResyncReason }, // server dropped deltas; a Snapshot follows
Reply { id: ReqId, result: Result<RpcResult, ApiError> },
DocAck { id: ReqId, doc: DocId, rev: u64 } | DocConflict { id: ReqId, doc: DocId, current: DocSnapshot },
Status { id: SubId, status: TopicStatus }, // Stale{since}, Delisted, Unavailable{reason}, DisplayRestricted
Pong { t: u64, server_time_ms: i64 },
Error { code: ErrorCode, message: String },
}
pub enum TopicReq {
Quotes { keys: Vec<MarketKey>, max_hz: u8 }, // watchlists / tickers: one sub, many keys
Bars { key: MarketKey, tf: Timeframe, history: usize },// snapshot = last `history` bars, deltas = forming bar / new bar
Book { key: MarketKey, depth: u8, group: Option<f64> },
Trades { key: MarketKey },
Ctx { key: MarketKey }, // funding, OI, mark/oracle (perps), supply (spot)
Congress(CongressTopic), Portfolio, Docs { kinds: Vec<DocKind> }, Alerts,
}
```
### 4.3 Snapshot + delta, sequencing and resync
- Every subscription has its own `seq`. A `Snapshot` sets it, and each `Delta` increments it by 1.
- **The client checks continuity.** On a gap, or on a `Delta` before its `Snapshot`, the client sends `Resync`. The server answers with a new `Snapshot`.
- **The server sends `Resync` itself** when an event topic's ring overflowed for that client (§5.8), or after ingest recovers from a gap or a node failover (§5.5, §6.6), because the server's own state may have jumped.
- **For state topics a delta is just the new value** (a quote or a book), so a lost delta is harmless and those topics never need a resync on overflow. Bars deltas are `Forming(bar)` (conflatable within one `ts`) or `Closed(bar)` (never conflated, always delivered before the first `Forming` of the next `ts`). The client applies `Closed` by *replacing* the bar with that `ts`, which repairs any partial bar it had. Today's `Series::apply_live` only appends a newer bar, so `RemoteMarkets` must apply `Closed` with a replace before forwarding to the existing `LiveFeed` consumer. A test in phase 5 covers a slow reader across a boundary.
- **Reconnect:** `Hello.resume` holds the previous session's resume token. The server re-attaches the subscriptions and sends a fresh `Snapshot` for each one. Clients never replay old deltas.
### 4.4 Encoding
| Option | Pros | Cons |
|---|---|---|
| **postcard** (serde, compact varint) | Smallest frames. no_std-friendly, so it builds fine for WASM. Same serde derives as today. | Not self-describing: fields can't be added to an existing struct without a version bump. |
| bincode 2 | Fast and mature | Larger than postcard for small numbers. Same evolution caveat. |
| JSON (serde_json) | Debuggable, browser devtools-friendly | 3–5× larger, and parse cost on the WASM main thread [unverified ratio] |
| MessagePack (rmp-serde) | Self-describing and compact-ish | Middle ground. Another dependency. |
**Recommendation: postcard over binary WebSocket frames for every client, with JSON as a negotiated debug encoding** (`Hello.encoding`). The web client is Rust compiled to WASM, so there is *no JavaScript consumer*: the usual "JSON for the web" argument doesn't apply here. Evolution rules: append-only enum variants, a new message variant (never a changed struct) for new fields, and `PROTOCOL_VERSION` checked in `Hello`/`Welcome`. The server supports N and N−1.
Compression: the browser and tungstenite support for permessage-deflate needs checking [unverified]. postcard plus conflation may make it unnecessary. We measure in phase 6.
### 4.5 Latency targets
| Path | Target |
|---|---|
| Node writes a block's events → ingest has applied them (book, tape, bar) | **< 5 ms / < 20 ms** p50/p99 [target], using `--disable-output-file-buffering` [doc: LAT] |
| Ingest event → written to client sockets by the gateway (same region, p50 / p99) | **< 5 ms / < 25 ms** [target] |
| Subscribe → Snapshot for a hot topic (already in memory) | **< 50 ms** plus RTT [target] |
| Subscribe → Snapshot for a cold topic | **< 100 ms** plus RTT [target]. Ingest already holds every book. |
| Quote fan-out cadence to UI | Default 4 Hz per watchlist. The visible chart's bar and book stream at block speed. [target] |
For comparison, the public `l2Book` feed is pushed "on each block that is at least 0.5 since last push" [doc: WS-SUB]. In practice that holds only for `fast: true` (5 levels a side, ~0.5 s apart). The default 20-level book comes ~5.4 s apart [observed for BTC, 2026-11-02]. Our own builder emits on every block. The docs say node outputs are "faster and more granular data than the API" [doc: LAT], but give no numbers [unverified until we measure]. For North American users, the Tokyo-to-client network hop dominates end to end [unverified RTT; to measure].
### 4.6 Client integration
- **`tr-data::remote::RemoteMarkets`** implements `MarketData` over one shared connection. `history` maps to an RPC, `quotes` to a short-lived or shared `Quotes` subscription, and `live` to a `Bars` subscription bridged into the existing `mpsc::Receiver<Bar>`.
- **Selection:** `TR_SERVER_URL` (or a settings toggle) picks `RemoteMarkets`. The direct providers stay as the offline and dev fallback (`TR_PROVIDER=sample` keeps working).
- **Web build:** `for_browser()` puts `RemoteMarkets` in front of the browser's own sources when the page was built with a server URL (`TR_SERVER_URL`, read by `scripts/build-web.sh`; production uses `wss://terminal-gateway.style.dev/ws`). The client runs on the page's event loop over the browser's `WebSocket`, with postcard in binary frames like the desktop. `?server=ws://localhost:8787/ws` points a page at a server on the same machine, and `?server=off` turns the server off. The server serves crypto and HIP-3 perps so far, so the `TODO(web proxy)` stubs in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs` stay until it serves the rest.
---
## 5. Ingest from our node
Everything live comes from our own non-validator node (§6) through `tr-ingest`, which runs on the node host.
### 5.1 What the node writes, and the flags we run
`hl-visor run-non-validator` writes to `~/hl/data`. The output we use is opt-in via flags [doc: NODE]:
| Flag | Output | We use it for |
|---|---|---|
| `--write-fills` | `node_fills` (API fills format). It "overrides `--write-trades` if both are set" and also writes TWAP statuses. HIP-3 fills include `deployerFee`. | The trade tape and candles (§5.3). Fees and user fields are dropped before anything leaves ingest. |
| `--write-order-statuses` | `node_order_statuses`, every order status update, "a substantial amount of data" | L4 order metadata (tif, cloid, timestamp) and order lifecycle (§5.2) |
| `--write-raw-book-diffs` | `node_raw_book_diffs`: per-order `new {sz}`, `update {origSz, newSz}` or `remove` with `user, oid, coin, side, px` [doc: L1] | Applying changes to the L4 book (§5.2) |
| `--write-hip3-oracle-updates` | `hip3_oracle_updates`: every HIP-3 deployer `setOracle` | HIP-3 oracle and mark inputs (§5.4) |
| `--write-misc-events` | `misc_events`, including a `Funding` event `{coin, usdc, szi, fundingRate, nSamples}` and ledger updates [doc: L1] | Realised funding (§5.4) |
| `--batch-by-block` | Writes `*_by_block` files, one line per block: `{local_time, block_time, block_number, events}` | Block-aligned processing, gap detection, deduplication across two nodes |
| `--disable-output-file-buffering` | Flushes each line as soon as it's written | Latency [doc: LAT] |
| `--serve-info` | Local info server at `localhost:3001/info` with a subset of info requests, plus `fileSnapshot` | The registry, L4 snapshots, freshness checks, marks (§5.4) |
| `--replica-cmds-style actions-and-responses` (optional) | `replica_cmds`: every L1 transaction | Core oracle derivation, if needed (§5.4). Off unless phase 1 shows we need it, because it is large [unverified size]. |
Every output is hourly-partitioned under `{dir}/hourly/{date}/{hour}` [doc: NODE]. Ingest tails the current file with a checkpoint `(file, byte offset, last block_number)`, written after the derived state has been persisted. So a restart resumes exactly where it stopped. The docs say batching by block costs "a few milliseconds" against the streaming mode [doc: OBS, LAT]. v1 keeps `--batch-by-block` for correctness, and we revisit it once we measure.
### 5.2 Order books: can we rebuild full L2 and L4? Verdict: yes, with caveats
**Yes for core perps.** The node's own docs describe this exact use:
- L4 snapshots hold "the entire order book with full information about the orders for each level. This can be used as a checkpoint upon which the order statuses stream may be applied, allowing users to stream an L4 book in realtime" [doc: L1].
- The latency guide says node outputs let us "construct the book" locally, with "faster and more granular data than the API" [doc: LAT].
- The reference [`order_book_server`](https://github.com/hyperliquid-dex/order_book_server) does it in Rust. It serves `l2Book` (up to 100 levels), `trades` and a new `l4Book` over a websocket, built from the `*_by_block` files for fills, order statuses and raw book diffs [doc: OBS].
**How the rebuild works** (the reference server's approach, which we copy) [doc: OBS; observed in source]:
1. Checkpoint: POST `{"type":"fileSnapshot","request":{"type":"l4Snapshots","includeUsers":true,"includeTriggerOrders":false},"outPath":…,"includeHeightInOutput":true}` to the local info server. It writes the current L4 book for every coin, at a known height, and "uses the latest state instead of an older snapshot file" [doc: NODE]. As an offline alternative, `hl-node compute-l4-snapshots` can build one from a `periodic_abci_states` file (written every 10,000 blocks) [doc: NODE].
2. Apply every raw book diff and order status from the `*_by_block` files with `block_number` above the snapshot height, in order. `new` inserts an order (`oid`, side, `px`, size) at the back of its price level. `update` changes its size. `remove` deletes it. Order statuses give the metadata the diffs lack (timestamp, tif, cloid, order type).
3. L2 is an aggregation of L4: Σ size and order count `n` per price. We do any number of levels and our own price grouping, without the API's 20-level cap [doc: INFO]. BBO and mid come out of L2.
4. Self-check: the reference server periodically takes a fresh snapshot, compares it with its in-memory book, and exits on a mismatch. It also exits if no events arrive for 5 s [observed in source]. We do the same, but rebuild that coin instead of exiting.
**Caveats and gaps (stated plainly):**
- **HIP-3 coverage is likely but not documented.** Nothing in the node docs says explicitly whether HIP-3 dex books appear in `node_raw_book_diffs`, `node_order_statuses` or `l4Snapshots`. The evidence points to yes:
- the fills docs mention `deployerFee` "for HIP-3 fills" [doc: NODE];
- HIP-3 markets trade on HyperCore's matching engine [doc: HIP3];
- the reference server takes its coin universe from the snapshot and has no dex filter [observed in source].
**[unverified]**. It's the phase 1 acceptance test (§12): grep a day of diffs and a snapshot for `xyz:`, `flx:` and so on, and compare one `xyz:` book against the public `l2Book` once. If HIP-3 books are missing, that is a real gap. We'd have no source for HIP-3 books except the public API, which goes against the decision. We'd come back to Micky before going further.
- **Spot:** the reference README says it "does not support spot order books", but its code has `is_spot` handling (`@…` and `PURR/USDC`) and an `ignore_spot` switch [doc: OBS; observed in source]. Spot diffs should be in the same files [unverified]. Our builder treats spot like perps and verifies it the same way.
- **Trigger orders** (stop and take-profit) aren't resting liquidity. The reference server leaves them out [doc: OBS]. Snapshots can include them with `isTrigger`, at the end of each level [doc: L1]. We leave them out of L2 too.
- **Queue position:** "ALO priority fees or other edge cases" can break naive time priority within a level [doc: LAT]. L2 is unaffected. L4 order position within a level is best-effort [unverified]. The snapshot self-check catches drift.
- **The reference code is unmaintained.** It is "not actively maintained" [doc: LAT], "not written by the core team" and provided as-is [doc: OBS]; its last commit was 2026-07-15 [observed]. So we write our own `tr-book` crate, using it as a reference, and we test it against recorded node files.
### 5.3 Trades and candles at every interval, no cap
- **Tape:** with `--write-fills`, each match shows up as two fills, one per side. Ingest dedupes them into one public trade, keyed by `(coin, tid)` [unverified that node fills carry `tid`; the trades schema carries `hash` plus buyer and seller `oid`s, which works as a fallback key, doc: L1]. The aggressor side comes from the taker fill.
- **1m bars** are built from the deduplicated trades by UTC minute: OHLC from trade prices, volume Σ sz (base), notional Σ px·sz, and trade count `n`. A bar closes when the first block with a `block_time` at or after the minute boundary has been applied. That makes closes deterministic and identical on both nodes.
- **Higher intervals** are aggregated from 1m. OHLCV aggregation is exact, so 5m, 15m, 1h, 4h, 1d, 1w and 1M (and any custom interval) are consistent by construction. Weeks start Monday 00:00 UTC and months are calendar months [target; Hyperliquid's own boundary convention is undocumented, unverified].
- **Minutes with no trades** produce no bar row. Clients render the gap, which matches how sparse HIP-3 markets look today.
- **No cap:** every bar since our node started is kept (§3.4). This replaces the public API's "most recent 5000 candles" limit [doc: INFO].
- Live bars reach clients as `Forming` and `Closed` (§4.3).
### 5.4 Mids, marks, oracle prices and funding
| Value | Where it comes from | Confidence |
|---|---|---|
| **Mid** | Our book: `(best bid + best ask) / 2`. One-sided or empty books publish no mid, and clients fall back to mark with a label. | Derived. Exact for two-sided books. |
| **Last trade** | Our tape (§5.3) | Exact |
| **HIP-3 oracle and mark inputs** | `--write-hip3-oracle-updates` records each deployer `setOracle`, which carries `oraclePxs`, `markPxs` and `externalPerpPxs` per coin [doc: NODE, HIP3-ACT]. The oracle price is used as-is. | Exact for the oracle |
| **HIP-3 mark** | The protocol forms the final mark from the deployer's `markPxs` with documented rules: the mark is clamped to move at most 1% per update, and stale marks fall back to the local mark after 10 s [doc: HIP3-ACT]. We implement those rules and reconcile the result against `activeAssetData` (below). | The final combination is [unverified]. To validate in phase 1. |
| **Core perp oracle** | Validators publish oracle prices about every 3 s. Each is a weighted median of CEX spot prices (Binance 3, OKX 2, Bybit 2, Kraken, Kucoin, Gate, MEXC and Hyperliquid spot 1 each), and the final price is a stake-weighted median of the validators' submissions [doc: ORACLE]. On the node, these validator actions should be in `replica_cmds` [unverified; the action schema is undocumented]. | Derivable [unverified]. Fallback: the abci state (below). |
| **Core perp mark** | The median of (a) oracle + a 150 s EMA of (Hyperliquid mid − oracle), (b) the median of best bid, best ask and last trade, and (c) a weighted median of Binance, OKX, Bybit, Gate and MEXC perp mids [doc: MARK]. We can compute (a) and (b), but **not (c)** from node data. So we **read** mark rather than derive it. The local info server supports `activeAssetData`, which returns `markPx` per coin [doc: NODE, PERP]. Ingest polls it locally (no rate limit) every 1 s for watched coins and every 3 s for the rest [target]. Mark only updates with the oracle, about every 3 s [doc: MARK]. | [unverified that the local server returns a live `markPx`]. Fallback: a computed approximation from (a) and (b), labelled "est.". |
| **Funding (realised)** | Hourly `Funding` events in `misc_events` carry `coin` and `fundingRate` [doc: L1] | Exact |
| **Funding (predicted)** | Computed with the documented formula: the hourly rate is ⅛ of the 8 h rate, F = average premium + clamp(interest − premium, ±0.0005), interest is 0.01% per 8 h, the premium is sampled every 5 s from impact prices against the oracle, and funding is capped at 4% per hour [doc: FUNDING]. Impact prices come from our own book. HIP-3 dexes can set their own multipliers and interest rates [doc: HIP3-ACT] [unverified where these show up in node output]. | Derived |
| **Open interest, 24h volume, prev-day price** | Volume and the previous day's price come from our candles. Open interest isn't in the documented outputs. We'd derive it from the `periodic_abci_states` state, translated with `translate-abci-state` [doc: NODE], at that file's cadence, or track it live from fills (`start_pos`) [unverified; later]. | Partial |
Reconciliation: every 10,000 blocks, a job translates the `periodic_abci_states` file and checks our oracle, mark, funding and open interest against it [unverified that every field is present]. Mismatches raise an alert.
### 5.5 Catch-up, gaps and failover inside ingest
- **Restart:** ingest resumes from its checkpoint (§5.1). Books are never trusted across a restart: it takes a fresh L4 snapshot (§5.2) and replays diffs after that height.
- **Gaps:** `block_number` must increase by exactly 1. A gap or a stall (no new block for 5 s [target], the same threshold the reference server uses) marks every topic `Stale`. Ingest re-snapshots the books, and asks the peer node's ingest for the missing blocks' trades so bars are complete (§6.6). If both nodes miss a range, a repair job fills the trades later from the S3 archive (§5.6), and the affected bars are flagged `repaired`.
- **Node restart:** see §6.5.
### 5.6 The one-time history backfill (the only public data use)
Node-built bars cover everything from the first full minute after our node is live. History before that is a one-time job, run once and then never again:
1. **Primary: S3 node archives, rebuilt into trades and then candles with the same code as live.** The bucket `hl-mainnet-node-data` holds `node_fills_by_block` (written with `--write-fills --batch-by-block`), older `node_fills` (API format) and `node_trades` (a different, older format), plus `misc_events_by_block` (funding and more), `explorer_blocks` and `replica_cmds` [doc: HIST]. The archives are **requester-pays**: we pay the S3 transfer, which needs our own AWS account [doc: HIST]. The **start date and total size are not documented** [unverified]. Listing the bucket needs AWS credentials, which I don't have, so phase 1 measures them first. This route gives true 1m (and finer) history and is consistent with live bars.
2. **Secondary: one public `candleSnapshot` sweep**, for instruments or ranges the archive lacks. It returns only the most recent 5000 candles per interval [doc: INFO]: 1m ≈ 3.5 days, 1h ≈ 208 days, 1d back to listing (BTC 1d starts 2020-08-19) [observed]. The weight math for a one-off sweep: ~900 instruments (552 perp entries including delisted, plus 347 spot pairs [observed, §5.7]) × 5 intervals (1m, 15m, 1h, 4h, 1d) × ~104 weight (20 + 5000/60) ≈ 468k weight. That is ≈ 7.8 hours at a 1000/min self-limit, under the 1200/min IP limit [doc: RL]. It runs once, from one IP, with no evasion of limits.
3. **Optional: `hyperliquid-archive/asset_ctxs/[date].csv.lz4`** for daily historical funding, open interest and mark context. It's uploaded about monthly, "with no guarantee", and has no candles or spot asset data [doc: HIST].
**Stitching:** for each `(instrument, interval)`, node-built bars win for every bar at or after node start. Backfill bars fill the earlier range, and each bar keeps its provenance. Where S3-rebuilt and `candleSnapshot` bars overlap, we compare them as a test of our aggregation. Any differences are logged, and the S3 bars win.
### 5.7 Addressing (HIP-3, spot and outcomes)
- `perpDexs` returns `[null, {name, fullName, deployer, oracleUpdater, feeRecipient, assetToStreamingOiCap, …}, …]`. `null` is the first (core) perp dex [doc: PERP]. I observed 11 entries: `null`, `xyz` (XYZ), `flx` (Felix Exchange), `vntl` (Ventuals), `hyna` (HyENA), `km` (Markets by Kinetiq), `abcd` (ABCDEx), `cash` (dreamcash), `para` (Paragon), `mkts` (Markets By Kinetiq) and `io` (EntropyIO). The objects also carried `subDeployers`, `assetToFundingMultiplier`, `assetToFundingInterestRate` and `assetToFundingClamp`, which are not in the doc example [observed].
- `meta`, `metaAndAssetCtxs`, `allMids`, `clearinghouseState` and others take `"dex": "<name>"`. The empty string means the first perp dex [doc: PERP, INFO]. `allPerpMetas` returns every dex at once [doc: PERP].
- HIP-3 coin names are always `{dex}:{coin}`, for example `xyz:TSLA` [doc: ASSET, INFO]. `candleSnapshot` needs the prefix: "For HIP-3, you need to prefix with the dex name, e.g. "xyz:XYZ100"" [doc: INFO]. The WS `allMids` with `dex:"xyz"` returned only `xyz:`-prefixed keys plus a `dex` field [observed].
- Spot coins are `PURR/USDC` or `@{index}` (for example, HYPE is `@107`). "Some assets may be remapped on user interfaces", for example BTC/USDC in the app is UBTC/USDC on HyperCore [doc: INFO].
- Outcome (HIP-4) markets show up as `#<encoding>` keys [doc: ASSET]. I observed 762 `#` keys and 401 `@` keys among the 1399 keys of the default `allMids` [observed]. **Outcomes are out of scope** and are filtered at ingest. They also carry separate legal restrictions [doc: TOU §1.7].
- Snapshot on 2026-10-29 [observed]: the core `meta` had 235 entries, 56 flagged `isDelisted`. The `xyz` `meta` had 141 entries, 21 flagged `isDelisted`. `spotMeta` had 347 pairs and 511 tokens. `allPerpMetas` listed per-dex universe sizes of core 235, `xyz` 141, `para` 39, `hyna` 25, `mkts` 24, `km` 23, `cash` 17, `flx` 16, `io` 16, `vntl` 15 and `abcd` 1, for 552 perp entries in total.
- Snapshot on 2026-11-02 around 17:50 UTC [observed]: the same 10 HIP-3 dexes listed 321 markets, of which 166 were live: `xyz` 120 of 142, `para` 33 of 40, `io` 12 of 18 and `mkts` 1 of 24. `flx`, `vntl`, `hyna`, `km`, `abcd` and `cash` had nothing live. The core dex had 179 live perps of 235.
- **In our system** the registry comes from the node's local info server, which supports `perpDexs`, `meta` and `spotMeta` [doc: NODE]. The public-API observations above are from research and are only for scale.
### 5.8 Backpressure
- Each client has a **bounded outbound queue** (start at 256 frames or 1 MB [target]).
- **State topics** are conflated per client. On each flush tick (or immediately, for the active chart), the server writes the latest value of every dirty topic, so a slow client gets fewer updates but never stale ones.
- **Event topics:** if a client's cursor falls behind the ring, the server drops that subscription's backlog and sends `Resync` plus a `Snapshot`. For trades, the snapshot is the last N trades.
- If the queue stays full for more than 10 s [target], the server closes the connection with a "slow consumer" code, and the client reconnects with `resume`.
- **Ingest is never blocked by a client.** The node-file tailers only write into the hot store. Fan-out runs in per-connection tasks.
### 5.9 Scale without Hyperliquid rate limits
- Ingest work is proportional to market activity (all coins, every block), not to clients. The gateway's work is proportional to client subscriptions. Neither touches a Hyperliquid rate limit.
- The public API limits (1200 weight/min, 1000 WS subscriptions, 10 connections per IP [doc: RL]) apply only to the one-time backfill (§5.6). That is the sanctioned way to get unlimited data: the ToU forbids bots that "bypass rate limits" [doc: TOU §3.1.8], and a node avoids the question entirely.
- The internal ingest-to-gateway stream carries per-block batches of book changes, trades, bars and prices for every coin. Its bandwidth is unmeasured [unverified; phase 2 measures it]. Gateways can subscribe only to the topics their clients use.
---
## 6. Node operations
### 6.1 Hosting choice and region
- **Region: Tokyo.** The README says "for lowest latency, run the node in Tokyo, Japan" [doc: NODE], and the Foundation's own node is in AWS `apne1-az1` [doc: FND].
- **We are likely ineligible for the Foundation node** (it requires 10k staked HYPE and Tier 1 maker volume, and excludes "certain activities involving the U.S. or Ontario") [doc: FND]. So we run our own.
- **Provider:** single-tenant bare metal in Tokyo from two different providers, so a single provider outage doesn't take out both (§6.6). The comparison, prices and recommendation are in §6.3: Vultr for node A and Cherry Servers for node B.
- **Ruled out:**
- Freestyle (freestyle.sh): VMs too small (64 GiB / 256 GB max), and no raw inbound TCP for gossip [doc: FREESTYLE].
- OVHcloud: no Japan region [doc: OVH-REGIONS].
- Equinix Metal: discontinued [doc: EQX-EOL].
- **Peers:** mainnet requires at least one root peer in `~/override_gossip_config.json`. Community root peers are mostly in Japan, and a `gossipRootIps` info request lists recent ones [doc: NODE]. The Foundation also lists paid infra providers that offer reliable peering [doc: INFRA-PEERS]. Running "against a reliable peer" is the first latency recommendation [doc: LAT]. Peer addresses live in the host's config, never in this repo or its docs.
### 6.2 Machine spec and OS
- **Minimum (non-validator):** 16 vCPU, 128 GB RAM, 500 GB SSD [doc: NODE]. "Currently only Ubuntu 24.04 is supported" [doc: NODE].
- **For low latency:** at least 32 logical cores, 128 GB RAM and 500 MB/s disk [doc: LAT].
- **Our target per node:** bare metal with 16+ physical cores (32+ threads), 128 GB+, **≥ 2 TB local NVMe**. Disk is sized for ~1–2 days of raw output (§6.4) plus abci states and the candle store. Ubuntu 24.04 LTS. Ingest, and in v1 the gateway, share the box (§6.3, "Colocating our Rust server") [unverified until measured].
- **Network:** gossip ports 4001 and 4002 open publicly [doc: NODE]. Everything else, including the info server and ingest's internal stream, is firewalled, with the internal stream over WireGuard or mTLS. Node bandwidth is undocumented [unverified; measure in week 1].
- **Install:** `hl-visor` binaries are signed. Verify them with the published GPG key, and run `hl-visor run-non-validator` with `~/visor.json` set to `{"chain": "Mainnet"}` [doc: NODE]. Run both visor and ingest under systemd. `hl-visor` auto-updates `hl-node` [doc: NODE], so we alert on version changes and re-verify ingest parsers after each one.
### 6.3 Where to host it: providers checked, with published prices
Requirements (§6.2):
- 16+ physical cores (32+ logical) and 128 GB+ RAM;
- 500 GB+ local NVMe, ideally 2 TB;
- Ubuntu 24.04;
- **raw public TCP on 4001–4002**;
- high bandwidth;
- in or near Tokyo.
Prices are list prices, checked 2026-10-29, excluding tax. "Stock" is what the provider's inventory showed that day. None of these are quotes.
| Provider and plan (Tokyo unless noted) | Hardware | Monthly price | Bandwidth | Stock | Source |
|---|---|---|---|---|---|
| **Vultr bare metal `vbm-24c-256gb-amd`** | EPYC 7443P, 24 cores / 48 threads, 256 GB. The product page also lists 2 × 1.92 TB NVMe; the public plans API lists only the 2 × 480 GB SSDs [confirm at order]. 25 Gbps. | **$725** (hourly $0.993) | 10 TB included, then $0.01/GB | Offered in Tokyo (`nrt`) and Osaka (`itm`) | [doc: VULTR-API, VULTR-BM, VULTR-PRICE] |
| Vultr bare metal `vbm-24c-384gb-amd5` | EPYC 9255, 24c/48t, 384 GB, NVMe | $995 | 10 TB | Tokyo and Osaka | [doc: VULTR-API] |
| Vultr bare metal `vbm-8c-132gb` | E-2288G, **8 cores**, 128 GB, 2 × 1.92 TB NVMe | $350 | 10 TB | Tokyo | **Fails** the 16-core minimum [doc: VULTR-API] |
| **Cherry Servers AMD EPYC 9275F** | 24c/48t at 4.1 GHz, 384 GB, 2 × 1 TB + 2 × 2 TB NVMe, 10 Gbps, Equinix TY15 | **$874.17** | 100 TB free egress | Listed as deployable in Tokyo | [doc: CHERRY-TYO] |
| Cherry Servers AMD Ryzen 9950X | 16c/32t, 128 GB (non-ECC), 2 × 1 TB NVMe, 10 Gbps | €439 (€373 on a 12-month term) | 100 TB | **Sold out in Tokyo** | [doc: CHERRY-9950X] (third-party tracker) |
| Latitude.sh `m4.metal.medium` | EPYC 9124, 16c/32t, 128 GB, 2 × 480 GB + 2 × 1.9 TB NVMe, 2 × 10 Gbps | $522 Tokyo ($365 on a 12-month term); $456 US list | 20 TB | **Sold out** in TYO3 and TYO4 | [doc: LATITUDE, LAT-M4] (Tokyo price from a third-party tracker) |
| Latitude.sh `f4.metal.medium` | EPYC 4564P, 16c/32t at 4.5 GHz, 128 GB, same disks | $715 Tokyo ($501 on term); $555 US list | 20 TB | **Sold out** in Tokyo | [doc: LATITUDE, LAT-F4] |
| AWS `i4i.4xlarge` (on demand) | 16 vCPU (8 cores), 128 GiB, 1 × 3,750 GB local NVMe | $1.61/h ≈ **$1,175** | Egress billed separately | `ap-northeast-1` | [doc: AWS-PRICE] |
| AWS `r6i.4xlarge` + 2 TB gp3 | 16 vCPU, 128 GiB, EBS only | $1.216/h ≈ $888, plus ≈ $215 EBS ≈ **$1,100** | Egress billed separately | `ap-northeast-1` | [doc: AWS-PRICE]; EBS from earlier third-party summaries |
| OVHcloud | — | — | — | **No Japan region.** Its APAC dedicated servers are in Singapore, Sydney and Mumbai only, with metered traffic that is throttled at the quota. | [doc: OVH-REGIONS, OVH-APAC] |
| Equinix Metal | — | — | — | **Discontinued.** Sunset on 2026-06-30. | [doc: EQX-EOL] |
| Freestyle (freestyle.sh) VMs | Pro tops out at 32 vCPU / **64 GiB** / **256 GB** per VM | — | — | **Ruled out:** below the 128 GB RAM and 500 GB disk minimums, and inbound traffic only reaches a VM through the TLS/SNI edge on 443, so there's no raw TCP on 4001–4002 for gossip | [doc: FREESTYLE] |
All three bare-metal providers above are single-tenant with full root, so we open 4001–4002 and firewall the rest ourselves with nftables. Ubuntu 24.04 is in Vultr's OS list [doc: VULTR-API] and Cherry's Tokyo FAQ [doc: CHERRY-TYO]. Node bandwidth needs are undocumented [unverified]. Vultr's 10 TB is the tightest allowance, and its overage ($0.01/GB) is cheap.
**Recommendation:**
- **Node A: Vultr `vbm-24c-256gb-amd` in Tokyo, $725/month.**
- 24 cores / 48 threads meets the 32-logical-core latency advice [doc: LAT].
- 256 GB of RAM leaves headroom for ingest and the gateway on the same box.
- It's orderable today.
- Confirm the NVMe drives at order time.
- **Node B: Cherry Servers EPYC 9275F in Tokyo (Equinix TY15), $874.17/month.**
- A different provider and building, so one provider outage doesn't take out both (§6.6).
- 100 TB egress, and faster cores.
- Alternatively, a second Vultr box in Osaka (`itm`): a different city, but the same provider.
- **Two-node total ≈ $1,600/month.** Budget path: one node at ≈ $725, or the 16-core Latitude and Cherry boxes ($365–522) once Tokyo stock returns.
- **AWS works but costs more for less:** 16 vCPU is 8 physical cores, and egress is extra. It mainly helps the S3 backfill (§5.6), which can run from a short-lived AWS instance instead.
**Colocating our Rust server:**
- **`tr-ingest` runs on the node box** (required: it tails local files and calls `localhost:3001`).
- **v1: `tr-gateway` on the same box as well.** The node gets most of the cores via systemd `CPUAffinity`/cgroup limits, and ingest plus the gateway get a reserved slice (for example 8 of 48 threads) [target; tune after measuring]. That makes the internal stream loopback, with no extra machine.
- **When client load grows,** move `tr-gateway` to a small box on the **same provider's private LAN** in the same city (Vultr VPC or Cherry private VLAN [doc: CHERRY-TYO]), so the ingest-to-gateway hop stays sub-millisecond and off the public internet [unverified latency; measure]. Never put the gateway across the Pacific from ingest. The data starts in Tokyo either way (§11).
**Cost summary (per month, list):**
- **One node:** ≈ **$725** (Vultr bare metal). A 16-core Latitude or Cherry box would be ≈ $365–522 when Tokyo stock returns; AWS is ≈ $1,100–1,175.
- **Two nodes (recommended, §6.6):** ≈ **$1,600** (Vultr + Cherry). The range is ≈ $1,450–2,050, depending on which pair we use.
- These are list prices, not quotes. Third-party tracker figures are labelled as such in the table.
### 6.4 Disk and log rotation (~100 GB/day)
- The node writes "around 100 GB of logs per day", which should be archived or deleted [doc: NODE]. Order statuses and raw book diffs add "a substantial amount" on top [doc: NODE]. We size for **100–200 GB/day** [unverified; measure in week 1].
- **Pruner** (a systemd timer, hourly):
- Delete hourly output directories older than **24 h** [target], *only* when ingest's committed checkpoint is past them. The pruner never deletes a file that ingest hasn't committed.
- Keep the last 3 `periodic_abci_states` files.
- Keep `replica_cmds` only if we enable it, with `--replica-cmds-style recent-actions` where possible [doc: NODE].
- Optionally compress `node_fills_by_block` to object storage (zstd), as our own archive for repairs.
- **Alerts:** disk use above 70% (warn) and 85% (page). If the disk fills, the node stops writing and ingest goes stale.
### 6.5 Monitoring and restart catch-up
- **Liveness:**
- `applied block X` log lines show the node is live [doc: NODE]. We export the block height and blocks per second.
- Ping the local `exchangeStatus` to compare the L1 timestamp with the local timestamp [doc: NODE]. We alert when it's behind by more than 2 s [target].
- Compare the heights of the two nodes.
- **Crashes:** `visor_child_stderr` and `node_logs/status` [doc: NODE] are shipped to logs, with an alert on new stderr content and on restarts.
- **Restart catch-up:** after a restart, the node "may take a while as the node navigates the network to find an appropriate peer" [doc: NODE]. How long catch-up takes, and whether output files are written for the blocks it replays, isn't documented [unverified; we test it with a planned restart in phase 1]. Ingest treats the node as unusable until `exchangeStatus` is fresh, then resumes from its checkpoint (§5.5). Any block range without output is filled from the other node.
### 6.6 Failover with a second node
- **Two nodes, A and B,** in Tokyo on different providers or availability zones. Each runs its own ingest and builds its own books and bars.
- **Primary and standby:** the gateway consumes both streams, tagged with `block_number`. One is primary. The gateway switches when the primary is behind by more than 20 blocks or 2 s [target], or stops sending. On a switch, it sends `Resync` for the book topics (books are per node), and uses `block_number` to drop duplicate or out-of-order trades and bar updates.
- **Bars stay identical** on both nodes because bar closes are block-time based (§5.3). The standby's candle store doubles as a hot replica for repairing gaps on the primary.
- **Upgrades:** `hl-visor` auto-upgrades [doc: NODE]. Both nodes upgrading at once is a correlated failure we can't fully prevent [unverified whether upgrades can be staggered]. We alert on it.
- **Single node (budget fallback):** one node plus the replay and repair path (S3, §5.6). Outages then mean stale data until the node catches up, shown as `Stale` in the UI.
## 7. Symbol mapping to Bloomberg-style keys
Decided with Micky on 2026-11-02, which settles open question 9 (§15). The interim gateway and the clients implement it.
### 7.1 Principles
1. **A HIP-3 perp is its own instrument, never the listed one.** `AAPL US Equity` stays the Nasdaq stock, with its Yahoo quote and chart. The perp keeps Hyperliquid's coin name as its market key, `xyz:AAPL` (typed in any case), and its own asset class, `AssetClass::Perp`. Nothing maps `AAPL US Equity` to the perp, on the server or in a client.
2. **Every surface labels a perp with its venue:** `PERP · HL xyz`, in a colour no listed instrument uses. That covers chart legends and headers, tab titles (`xyz:AAPL PERP`), the order book and its trades, watchlist and sidebar rows, the palette, security search and command-line suggestions.
3. **Links, not substitutions.** A small reviewed table (`crates/core/src/perps.rs`, §7.3) links an app instrument to the perp on the same underlying. A linked instrument's chart shows a `⇄ xyz:AAPL PERP` chip, the perp's chart shows `⇄ AAPL US Equity`, and `AAPL PERP` opens the perp. `PERP` or ⇧P switches the focused pane back and forth. A link shows only while the server lists the perp as live. Yahoo has no book for a stock, so the stock's simulated book offers the perp's book, which the pane then titles as the perp's.
4. **Venue keys always work.** Any perp the server lists opens by its key and turns up in search, linked or not (`xyz:SKHX`, `para:10Y`).
5. **Perps come only from the server.** Nothing else has them. Yahoo, the browser's own sources, sample mode and the `Fallback` wrapper refuse a perp instead of standing in for it. While the server is unreachable, a perp's chart, book and trades say so, and its watchlist price goes blank rather than show the last one. The same goes for a server that has lost its own Hyperliquid feed. A chart painted from the disk cache says its bars are cached and why they couldn't refresh. A perp chart whose load failed asks again every 10 s while it's on screen, so it comes back with the server.
### 7.2 Where it lives
- `tr-core` (`perps.rs`): key normalisation (`XYZ:aapl` is `xyz:AAPL`), the badge, the link table and perp search. `Security::parse` gives a perp its class, and its price decimals follow Hyperliquid's five significant figures.
- `tr-gateway`: lists, resolves and streams live markets only (§7.5). It needs no table. Book, trades and candle subscriptions and `candleSnapshot` take the prefixed coin as is (`l2Book xyz:AAPL`), so HIP-3 markets use the same code and budgets as core ones.
- `tr-data` (`RemoteMarkets`): sends perps to the server and nowhere else. `MarketData::perp_markets` reads the server's live list through `Rpc::Markets`, and `MarketData::trades` reads a market's tape.
- No protocol change. The design's `Resolution` struct (basis, venue, kind on every quote) isn't needed while a perp's key says what it is.
- The table ships with the client, not as server config. Moving it to the server pays off once rows change faster than releases. Until then, the server owns liveness: it can hide a link by not listing the perp, but adding a row needs a release.
### 7.3 The table
A row needs all of these: trade.xyz's spec index says the perp references the same underlying the app key means [doc: XYZ-SPEC]; the perp is live in `meta`; the `xyz` dex, unless xyz has no market on that underlying and another dex's match is unambiguous; and its price tracks the app instrument's. All 27 rows were checked on 2026-11-02 around 17:50 UTC, during US market hours. Every one was within 0.6% of its Yahoo instrument, and the oil rows differ by their futures contract month.
| App key | Perp | What the perp references |
|---|---|---|
| `AAPL`, `MSFT`, `NVDA`, `AMZN`, `GOOGL`, `META`, `TSLA`, `AVGO`, `AMD`, `NFLX`, `COIN`, `MSTR` `US Equity` | `xyz:` same ticker | 1 share of the company's common stock (Nasdaq), "quoted in USD" |
| `LLY US Equity` | `xyz:LLY` | 1 share of Eli Lilly common stock (NYSE) |
| `TLT`, `SMH`, `XLE US Equity` | `xyz:TLT`, `xyz:SMH`, `xyz:XLE` | 1 share of the ETF (Nasdaq, Nasdaq, NYSE Arca) |
| `SPX Index` | `xyz:SP500` | "the S&P 500 Index" |
| `CL1 Comdty` | `xyz:CL` | 1 barrel of WTI crude, from a futures contract it rolls monthly (the docs call it "WTIOIL (CL)") [doc: XYZ-COMM] |
| `CO1 Comdty` | `xyz:BRENTOIL` | 1 barrel of Brent crude, rolled monthly |
| `NG1 Comdty` | `xyz:NATGAS` | 1 MMBtu of Henry Hub natural gas, rolled monthly |
| `HG1 Comdty` | `xyz:COPPER` | 1 pound of high-grade copper, rolled monthly |
| `GC1`, `SI1`, `PL1 Comdty` | `xyz:GOLD`, `xyz:SILVER`, `xyz:PLATINUM` | the spot price of 1 troy ounce (XAU, XAG, XPT/USD), not the future. The tooltip says so. |
| `EURUSD Curncy`, `GBPUSD Curncy` | `xyz:EUR`, `xyz:GBP` | the spot rate, US dollars per euro or pound |
| `USDJPY Curncy` | `xyz:JPY` | the spot rate, "Japanese yen per 1 U.S. dollar". No inversion. |
Left unlinked on purpose:
| Perp | Why |
|---|---|
| `xyz:XYZ100` | It tracked `^NDX` within 0.04%, but the docs only say "a modified market-capitalization-weighted index of 100 of the largest, most actively-traded, non-financial companies that are listed on a U.S. stock exchange". They never name the Nasdaq-100, so `NDX Index` doesn't link to it. |
| `xyz:JP225` | It tracked `^N225` within 0.06%, but the docs describe "a JPY-denominated, price-weighted index of 225 leading Japanese companies" and never name the Nikkei 225. |
| `xyz:HO` | It tracked `HO=F` within 0.2%. The docs list New York Harbor ULSD as `DIESEL` and never tie it to the `HO` coin. |
| `xyz:KR200` | The KOSPI 200. The app has the KOSPI composite (`KOSPI Index`), a different index. |
| `para:10Y`, `para:2Y`, `para:30Y` | They look like Treasury yields in percent. Nobody has checked their semantics. |
| Other dexes | The only catalog instruments with a live perp elsewhere were AVGO and SMH on `para` and NVDA on `io`. xyz lists all three, so no row needs another dex. |
`perpConciseAnnotations` returns categories such as `stocks` and `commodities` for HIP-3 coins [doc: PERP; observed]. Use it to suggest new rows, never to add them.
### 7.4 Off-hours labelling
trade.xyz US stock perps take external prices 24/5, Sunday 8:05 PM ET to Friday 8:00 PM ET. That spans pre-market, regular, post-market and an overnight session from Blue Ocean ATS [doc: XYZ-US]. Outside external coverage, the oracle advances through an internal EMA of order-book impact prices, with τ = 30 min [doc: XYZ-ORACLE; changelog XYZ-TAU]. Mark price is bounded to ±(1/max leverage) around a reference price, which is the last external price over weekends [doc: XYZ-BOUNDS]. The SP500 and XYZ100 perps and the commodities take external prices Sunday 6 PM to Friday 5 PM ET with a daily 5 to 6 PM gap, and the FX perps Sunday 5 PM to Friday 5 PM ET [doc: XYZ-SPEC, XYZ-FX]. A snapshot of the gap, from `xyz:AAPL` during US hours: `markPx 349.72` vs `oraclePx 349.8` [observed].
What ships: a perp counts as trading around the clock (`AssetClass::trades_24_7`), and a perp's price never appears on the listed instrument's own chart or quote, so a weekend oracle can't pass for a Nasdaq price. Still open: a session label next to the perp (`Regular`, `Extended`, `Overnight`, `Closed: perp-only price discovery`).
### 7.5 Delistings and churn
The registry refreshes `perpDexs`, `meta` (per dex) and `spotMeta` from the node's local info server every minute [target]. These calls are local, so there's no rate limit [doc: NODE]. If an instrument turns `isDelisted`, or disappears, the server marks the topic `Delisted`, stops live streams, keeps its history, and notifies users who have it in watchlists or layouts. HIP-3 deployers can also halt and settle a market with `haltTrading`, which can later resume and "recycle" the asset [doc: HIP3]. That means the *same name may come back as a different contract*. History is therefore keyed by `(dex, coin, listing_epoch)`, and a relisting starts a new epoch [target].
The interim gateway already filters delistings. Hyperliquid keeps publishing a mid for every delisted perp: on 2026-11-02 all 56 delisted core perps and all 155 delisted HIP-3 perps had one [observed]. So mids alone can't say what trades. The registry keeps delisted markets with their flag, and the gateway lists, resolves and streams none of them; their mids never enter the table. A request for one gets `Unavailable { UnknownMarket, "xyz:VIX is delisted" }`. A dex with nothing live (`flx`, `vntl`, `hyna`, `km`, `abcd` and `cash` that day) gets no mids subscription. The registry refreshes every 5 minutes. If one dex's `meta` call fails, that dex keeps what the last refresh listed, so its delisted markets stay delisted. A market delisted while clients watch it loses its book, trades and bars streams: each subscriber gets the same `Unavailable` status, and the upstream subscriptions go.
---
## 8. User sync: accounts, documents and conflicts
### 8.1 Accounts and auth
- **Sign-in:** OAuth (Apple, Google, GitHub) plus email magic link. Desktop uses the system browser with a loopback redirect (RFC 8252 style). The web uses a same-site HTTP-only session cookie. Mobile uses the platform's auth session.
- **Tokens:** short-lived access tokens (JWT, 15 min [target]) plus refresh tokens, rotated and revocable. The WS `Hello` carries the access token. When it expires, the client re-authenticates in place without dropping subscriptions.
- **Anonymous mode:** public market data with no sync, which matches today's experience.
- **Build or buy:** a hosted identity provider (Clerk, WorkOS, Auth0 or Supabase Auth) vs a small self-hosted OAuth layer in Rust. See the open question in §15.
- **Secrets:** SnapTrade per-user secrets are encrypted at rest (envelope encryption with a KMS or age key). They are never logged and never sent back to clients.
### 8.2 Documents
Each file in `storage.rs` becomes a server document `(user_id, kind, name)` with a server-assigned, monotonically increasing `rev`:
| Kind | Today | Server model | Conflict rule |
|---|---|---|---|
| Layouts | `LayoutDoc` JSON | Whole-document, optimistic concurrency (`base_rev`) | If `base_rev` doesn't match, the server keeps both and saves the losing write as "Name (conflict, Device, time)". This mirrors how people expect files to behave and never loses work. |
| Watchlists | Watchlist panes or prefs | Ordered set of items with stable ids | Per-item ops (`Add`, `Remove`, `Move{after}`), last-writer-wins per item, with tombstones. Concurrent adds from two devices both survive. |
| Drawings | `Vec<Drawing>` inside chart panes | Stored separately per `(user, market_key)`, each drawing with a **new stable `id`** (the `Drawing` struct needs one) | Last-writer-wins per drawing (`rev` and `updated_at`). Delete wins over a concurrent edit. Drawings follow the symbol across layouts. |
| Prefs, themes, hotkeys, palette history | TOML/JSON files | Key-level merge | Last-writer-wins per key |
| Demo portfolio | `DemoBook` JSON | **Server-authoritative.** Clients send commands, and the server fills against its own hot quotes and broadcasts the book. | No merge needed: one writer. Paper only; it never routes to a venue. |
Offline behaviour: clients keep today's local files as a cache and an outbox. Each queued `DocOp` carries an `op_id` (a UUID generated once, when the edit is made, and persisted with the outbox entry). The server records `(user_id, op_id) → result (rev or conflict outcome)` **in the same transaction as the write**, and keeps it for at least 30 days [target]. On reconnect, clients replay queued `DocOp`s with their original `op_id` and `base_rev`. If the server has already applied that `op_id`, it returns the original `DocAck` or `DocConflict` instead of re-evaluating `base_rev`. So a lost acknowledgement never creates a spurious conflict copy. Genuine conflicts resolve as above. The first sign-in uploads the local documents. If the server already has documents, the user chooses "merge" or "keep server".
The demo book fills at the server's quotes, which come from Hyperliquid perp prices (§7). So the demo account must show the same `PERP` labelling, and its fills can happen off-hours, a behaviour change from today [unverified UX decision].
---
## 9. Moving the existing feeds server-side
| Feed | Today | Server |
|---|---|---|
| Congress (Senate eFD, House Clerk) | Each desktop ingests every 30 min. The web gets nothing. | One `CongressProvider` runs `tr_congress::refresh` on schedule into the server DB. Topics: `Congress(Latest)`, `Congress(Ticker)`, `Congress(Politician)`. Tracking alerts are per-user pushes. The web gets the full feature. **Legal note:** the reports carry statutory limits on commercial use (Ethics in Government Act) [unverified for our use; needs legal review]. |
| SnapTrade | Partner keys (`clientId`, `consumerKey`) and the user secret sit on each machine. The desktop uses them for reads *and* for order preview, place and cancel (`crates/broker/src/trading.rs`). | **Two modes.** (a) *Server mode* (web, mobile, and opt-in desktop): partner keys live only on the server. Each Terminal user maps to a SnapTrade `userId` and an encrypted `userSecret`, and the server proxies only the read-only endpoints (accounts, positions, orders, activities) plus the Connection Portal link. No trading in this mode. (b) *Local mode* (desktop, today's behaviour, unchanged): the local credentials stay in the Keychain or the 0600 file, and the desktop keeps calling SnapTrade directly for reads and for its existing order paths. This design does not migrate or remove local credentials, so existing desktop trading keeps working. Moving trading server-side is out of scope (§1) and would need its own design. |
| Yahoo, Treasury, FRED, Alpaca, RSS | Client-side; the web gets sample data | Server-side adapters with `DisplayPolicy` (§3.1). Yahoo and RSS are off for public display until reviewed [unverified]. |
| Binance and Coinbase crypto | Client-side websockets | Server-side, `public_display: false` (§3.1). Hyperliquid core perps and spot become the default crypto source. |
---
## 10. Observability
- **Tracing:** `tracing` with JSON logs and OpenTelemetry export. Logs carry no secrets, tokens or raw IPs (hash client IPs), and no user document contents.
- **Prometheus metrics:**
- **Node (per host):** block height, blocks per second, the `exchangeStatus` lag between L1 time and local time, the `hl-node` version, restarts, and new `visor_child_stderr` lines (§6.5). Also disk use and free space, output bytes per hour, network in/out, and the gap in heights between node A and node B.
- **Ingest:** file-tail lag (block written → applied), last applied `block_number`, block gaps, snapshot self-check mismatches per coin, book rebuilds, trade dedupe counts, bars closed and repaired, and the age of the latest oracle and mark per coin.
- **Hot store:** topics, refcounts, lingering topics, evictions, and staleness per instrument (p50/p99 and the worst 10).
- **Gateway:** connections, subscriptions per connection, outbound queue depth, conflation drops, resyncs (by reason), slow-consumer disconnects, the server-added latency histogram (ingest event to socket write), and the active node (A or B) with failover count.
- **Sync:** doc ops per second, conflicts by kind, and outbox replays.
- **Jobs:** backfill progress (one-time), pruner runs and bytes freed, gap repairs, and congress refresh duration and result.
- **Health:** `/healthz` (process up) and `/readyz` (a fresh node stream on at least one node, registry loaded, DB writable).
- **Alerts:**
- Node: `exchangeStatus` lag above 2 s, no new block for 5 s, disk above 70% (warn) or 85% (page), crash loops, or a version change.
- Ingest: a snapshot mismatch, a block gap, or a failover.
- General: staleness p99 above 10 s during the underlying's open hours, and the error rate.
- **Dashboards:** Grafana, or a hosted equivalent.
---
## 11. Deployment and hosting cost
**v1 topology (colocated, §6.3):**
- **Node host A** (Vultr bare metal, Tokyo) runs `hl-visor`, `hl-node`, `tr-ingest` **and the primary `tr-gateway` plus its database**. Ingest-to-gateway traffic stays on loopback.
- **Node host B** (Cherry Servers, Tokyo, Equinix TY15) runs `hl-visor`, `hl-node`, `tr-ingest` **and a warm-standby `tr-gateway`**:
- B's ingest streams to A's gateway over WireGuard/mTLS across providers (a short in-city hop [unverified; measure]).
- **Market data fails over automatically.** B builds its own books and bars (§6.6). If A's health check fails, DNS points clients at B, and they resync the market topics.
- **User writes have exactly one writer, with synchronous replication and an epoch fence:**
- **One writer.** Document ops, their `(user_id, op_id)` results (§8) and demo-book commands are accepted by the **writer** gateway only. The writer is A in v1, holding writer epoch `e`. B's gateway rejects writes while it's a standby; clients keep them in their outbox (§8).
- **Synchronous ack.** The writer sends `DocAck`/`DocConflict` only after the change **and its `op_id` result** are committed locally **and** confirmed durable on the other host, tagged with epoch `e`. So an acknowledged edit always exists on both hosts, and a client never drops from its outbox an edit the survivor lacks.
- **If the other host can't confirm, the writer stops acking.** Clients queue edits offline (§8) until replication returns. Market data is unaffected. We trade write availability for never losing acknowledged edits [target; revisit after measuring].
- **Promotion is operator-initiated in v1** (a runbook step, not a health check):
1. The operator promotes B to epoch `e+1`.
2. From then on, B refuses replication with any epoch `≤ e`.
3. A can no longer get confirmations, so it **cannot ack any further write** even if some clients can still reach it. When A reconnects, it sees the higher epoch and demotes itself to standby. That prevents split-brain divergence of documents and the demo book.
- Clients replay their outbox against the new writer, and `op_id` dedupe returns the original result for edits that had been acked (§8).
- **Bars and other derived data** are rebuilt per host from node output, so they need no write fence (§3.4, §6.6).
- **The gateway runs on the node boxes, not a separate cloud VM.** Node A has 24 cores / 48 threads and 256 GB, enough headroom for both [unverified until measured]. The node gets most of the cores via cgroup/`CPUAffinity` limits, so client load can't starve block processing.
- If measurement shows contention, the gateway moves to a small box on the **same provider's private LAN** (Vultr VPC, Cherry private VLAN) in Tokyo. It never goes to another region.
- **Market data starts in Tokyo,** so the hop to North American users is the same whether the gateway is in Tokyo or North America. The cost is a longer RTT for document sync from North America, ~150 ms [unverified; to measure], which is fine for that traffic. North American edge gateways can come later, if measurement shows it matters.
| Item | Monthly cost (list, §6.3) |
|---|---|
| Node A: Vultr `vbm-24c-256gb-amd`, Tokyo (node + ingest + gateway) | **$725** |
| Node B: Cherry Servers EPYC 9275F, Tokyo (node + ingest + standby gateway) | **$874.17** |
| Extra bandwidth beyond Vultr's 10 TB, at $0.01/GB | ≈ $0–50 [unverified; depends on §5.9 measurements] |
| Object storage for our own fills archive (optional, §6.4) | ≈ $10–30 [unverified] |
| One-time S3 backfill, requester-pays transfer (§5.6) | One-off; unknown until the bucket size is measured [unverified] |
| **Total (two nodes, gateway colocated)** | **≈ $1,610–1,680** |
| Total (single-node budget fallback, §6.6) | ≈ $735–805 (Vultr only); ≈ $375–600 on a 16-core Latitude or Cherry box once Tokyo stock returns |
Egress estimate (per always-on client): a 50-symbol watchlist at 4 Hz × ~30 bytes per postcard update ≈ 6 KB/s ≈ **15 GB/month** worst case [unverified, rough]. Most clients are idle or backgrounded most of the time. We measure real numbers in phase 6.
**Recommendation:**
- Two bare-metal nodes on two providers in Tokyo (Vultr + Cherry Servers), with `tr-ingest` and `tr-gateway` colocated on the node boxes. Primary on A, warm standby on B.
- Containerise ingest and the gateway (one Dockerfile each). The node itself runs on the host under systemd, as the README expects.
- CI deploys from `master` tags, alongside the existing `deploy-site.yml`.
---
## 12. Phased plan (node first; small PRs, each proving one thing)
| # | PR / step | Proves |
|---|---|---|
| 0 | **This doc** | Agreement on scope, protocol, node plan and risks |
| 1 | **Node A live in Tokyo** (ops, not app code): an Ubuntu 24.04 host per §6.2, GPG-verified `hl-visor`, the flags in §5.1, the pruner, and node metrics and alerts. A runbook goes in `docs/ops/node.md` (no addresses or secrets). | The node syncs and `exchangeStatus` stays fresh. We measure GB/day, bandwidth and catch-up after a planned restart. **Acceptance tests for the §5.2 verdict:** `xyz:` and other HIP-3 coins present in raw book diffs and `l4Snapshots`, spot present, one book compared once against the public `l2Book`, local `activeAssetData.markPx` working, and the oracle actions located in `replica_cmds`. |
| 2 | `crates/hl-node`: parsers for node files (fills, order statuses, raw book diffs, HIP-3 oracle updates, misc events, batched blocks), from **recorded node fixtures**, plus the checkpointed file tailer | Our parsers match the real shapes. Resume-from-checkpoint is exact. |
| 3 | `crates/book` (`tr-book`): L4 from snapshot plus diffs, L2 and BBO aggregation, and a self-check against `fileSnapshot` | Books match fresh snapshots for 24 h with zero unrepaired mismatches, across core, HIP-3 and spot. |
| 4 | `tr-ingest`: tape dedupe, the candle builder (all intervals, no cap), the candle store, and prices (mid, last, oracle, mark, funding, §5.4) | Bars are deterministic and identical when built twice from the same files. Prices reconcile against the abci state. |
| 5 | `crates/proto` (`tr-proto`): message types, postcard and JSON codecs, round-trip and size tests, a `wasm32` build in CI | The schema compiles everywhere. Frame sizes are known. |
| 6 | `server/` (`tr-gateway`) skeleton: the ingest → gateway stream, axum WS, `Hello`/`Welcome`, ping, and the `Quotes` topic. A CLI client and a 1k-client load test. | **One ingest fans out to N clients.** Server-added latency, internal-stream bandwidth and egress are measured. |
| 7 | Symbol registry (from the local info server) and mapping (§7), `Resolution` in quotes, and `RemoteMarkets` in `tr-data` behind `TR_SERVER_URL`. The web build uses it. | The web build shows live `xyz:` and core prices with `PERP` labelling. The `TODO(web proxy)` stubs are gone for quotes. |
| 8 | The `history` RPC and live `Bars` with `BarClosed`, plus `Book` (L2 and L4), `Trades` and `Ctx` topics, linger and eviction, and slow-client backpressure tests | Charts and books work through the server. Slow clients don't hurt others. |
| 9 | **One-time backfill** (§5.6): measure the S3 bucket, rebuild from `node_fills_by_block`, run the `candleSnapshot` sweep for what's left, and stitch with provenance | History before node start exists. Overlap comparisons pass. |
| 10 | **Node B and failover** (§6.6) | Killing node A mid-session causes one `Resync` and no lost closed bars. |
| 11 | Auth (sign-in) and accounts. Document sync for layouts, prefs and watchlists, with the conflict rules. | Two devices stay in sync, and conflicts never lose work. |
| 12 | Drawings (stable ids) and a server-authoritative demo book | Real-time cross-device drawings. One paper book. |
| 13 | `CongressProvider` server-side. The web gets the Congress panes. | Congress data reaches the web and the stub is deleted. |
| 14 | `SnapTradeProvider` server-side (read-only server mode). Desktop local mode is left untouched. | Web and mobile get portfolios with no partner secrets in those clients. Desktop trading still works. |
| 15 | Staging and prod deploys for the gateway, plus dashboards and the full alert set | It runs unattended and we can see when it doesn't. |
| 16 | (Later) a licensed equities and options `Provider` (Intrinio, Twelve Data or Massive) | The provider interface holds up against a second real source. |
---
## 13. Risks
1. **Legal: Hyperliquid's Terms of Use.** The Terms (last updated June 15, 2026) define "Restricted Persons" to include persons and entities that "reside in, are located in, are incorporated in, or have a registered office in the United States of America or Ontario, Canada", and forbid them from using the Interface. They also prohibit circumventing restrictions, for example with VPNs [doc: TOU §1.6, §3.1.5]. The Terms cover the website *Interface*. They contain **no explicit license or prohibition for redistributing or displaying API data** [doc: TOU; I found no data-licensing clause]. Goshen Labs is in Toronto, and many of Terminal's users will be in the US. **This needs legal review before launch**, and it is the main reason the server is data-only. The Foundation node also excludes "certain activities involving the U.S. or Ontario" [doc: FND]. Running our own node doesn't change this analysis. It's a question about displaying Hyperliquid data, not about how we fetch it.
2. **Perp ≠ stock.** HIP-3 equity perps follow the underlying through an oracle during external sessions. Off-hours (weekends, holidays, and anything outside 24/5 coverage) the oracle follows trade.xyz's own order flow through an EMA, and mark is bounded around the last external price [doc: XYZ-ORACLE, XYZ-BOUNDS]. Prices *will* diverge from the listed stock: at the open, around earnings, and on corporate actions (splits and dividends handling [unverified]). Funding also moves the perp relative to spot. Mitigations: mandatory labelling (§7.1), session badges (§7.4), and never computing "stock" analytics (for example the Congress performance estimates) from perp prices without labelling them.
3. **Builder delistings and settlements.** HIP-3 deployers can halt and settle markets at any time, and can recycle names [doc: HIP3]. Validators can delist validator-operated perps [doc: DELIST]. Snapshot counts: 21 of 141 `xyz` markets and 56 of 235 core markets flagged delisted [observed]. Mitigations: epochs (§7.5), notifications, and keeping history.
4. **Dependence on one venue and one software stack.** Everything in v1 comes from one chain, and from `hl-node` binaries we can't audit. Output formats can change without notice. Mitigations: the `Provider` trait, staleness UI, recorded fixtures with parser tests in CI, and alerts on version changes.
5. **The node is a single point of failure** if we run only one. Mitigation: two nodes on two providers (§6.6). With one node, outages show as stale data until catch-up (§6.5).
6. **HIP-3 (and spot) book coverage is undocumented** (§5.2). If node book outputs leave out HIP-3 dexes, our core use case, HIP-3 equity perps, has no book source except the public API. Mitigation: the phase 1 acceptance test, run before any app code is built on top of it.
7. **The reference book code is unmaintained** [doc: LAT, OBS], and queue-priority edge cases exist [doc: LAT]. Mitigations: our own `tr-book` with snapshot self-checks, per-coin rebuilds and recorded-day replay tests.
8. **Core mark can't be fully derived from node data** (it uses external CEX perp mids [doc: MARK]). We rely on the local `activeAssetData` [unverified], with a labelled estimate as the fallback (§5.4).
9. **Disk and I/O.** Writing ~100–200 GB/day [doc: NODE; unverified total] can fill a disk within days if the pruner fails. Mitigations: checkpoint-gated pruning, 70%/85% alerts, and NVMe sized for headroom.
10. **Peers and auto-upgrades.** Startup depends on finding a good peer [doc: NODE]. `hl-visor` auto-upgrades `hl-node`, which can change output schemas or restart both nodes at once. Mitigations: reliable peers (§6.1), version-change alerts, and parser tests against fresh fixtures after every upgrade.
11. **Unknown backfill size and cost.** The S3 archive range and size are undocumented, and transfer is requester-pays [doc: HIST]. Mitigation: measure first. The `candleSnapshot` sweep is the cheap fallback.
12. **Display rights for other feeds.** Coinbase, OKX and Kraken (per the parent's research), Yahoo, and Congress data commercial-use limits. Mitigation: `DisplayPolicy` defaults to off.
13. **Thin HIP-3 liquidity.** Some markets are small (`abcd` had 1 asset [observed]). Mid prices can be noisy or missing when a book is empty [unverified for specific markets]. We fall back to mark price, with the basis label.
14. **A single stateful gateway.** Restarting it drops the hot store. Clients reconnect and resync, but it is visible. Mitigation: fast startup from the registry and SQLite, plus a warm-standby gateway on node host B (§11). Market data fails over automatically. User writes need synchronous replication with an epoch fence, and promotion is operator-initiated, so acknowledged edits are never lost and the two hosts never diverge. The cost is that edits queue in client outboxes while replication or promotion is pending. Later, a two-instance setup with sticky sessions. Colocation means a node-host outage also takes out that host's gateway; that's why the standby sits on the other provider.
15. **Protocol evolution with postcard.** A breaking change needs a version bump. Mitigations: the rules in §4.4 and support for N/N−1.
---
## 14. Hyperliquid facts referenced (summary)
| Fact | Value | Source |
|---|---|---|
| Node spec | Non-validator: 16 vCPU, 128 GB RAM, 500 GB SSD. Ubuntu 24.04 only. Ports 4001–4002 public. Tokyo for lowest latency. | [doc: NODE] |
| Latency spec | ≥ 32 logical cores, 128 GB RAM, 500 MB/s disk, a reliable peer, `--disable-output-file-buffering` | [doc: LAT] |
| Output volume | "Around 100 GB of logs per day" (more with order statuses and book diffs) | [doc: NODE] |
| Book outputs | `--write-raw-book-diffs`, `--write-order-statuses`, `--write-fills`, `--batch-by-block` | [doc: NODE, L1] |
| L4 checkpoint | `fileSnapshot` → `l4Snapshots` on the local info server (latest state), or `compute-l4-snapshots` from `periodic_abci_states` (every 10,000 blocks) | [doc: NODE] |
| L4 rebuild | Snapshot + apply order statuses = realtime L4 book | [doc: L1] |
| Reference builder | `order_book_server`: l2Book (≤ 100 levels), trades, l4Book. Unmaintained, no spot (per README), no trigger orders. | [doc: OBS, LAT] |
| Local info server | Subset only (`meta`, `spotMeta`, `perpDexs`, `exchangeStatus`, `activeAssetData`, …). No time series, no WS, no `l2Book`. | [doc: NODE] |
| Oracle | Validators publish about every 3 s. Weighted CEX spot median, then a stake-weighted median across validators. | [doc: ORACLE] |
| Mark | Median of oracle+EMA, the book/last-trade median, and CEX perp mids | [doc: MARK] |
| Funding | Hourly. Premium sampled every 5 s. Interest 0.01%/8h. Cap 4%/h. | [doc: FUNDING] |
| HIP-3 oracle | `setOracle` about every 3 s with `oraclePxs`, `markPxs` and `externalPerpPxs`. Node flag `--write-hip3-oracle-updates`. | [doc: HIP3-ACT, NODE] |
| S3 archives | Requester-pays. `hl-mainnet-node-data` (fills, trades, misc events, blocks). `hyperliquid-archive` (l2Book snapshots, asset_ctxs). No candles. | [doc: HIST] |
| Public candle depth (backfill only) | Most recent 5000 candles per interval | [doc: INFO] |
| Public REST weight (backfill only) | 1200/min per IP. `candleSnapshot` costs 20 plus 1 per 60 items. | [doc: RL] |
| HIP-3 naming | `{dex}:{coin}`. Asset id `100000 + dex_index*10000 + index` | [doc: ASSET, PERP] |
---
## 15. Open questions for Micky
1. **Legal:** who reviews the Hyperliquid ToS (US/Ontario "Restricted Persons") for a Toronto company displaying Hyperliquid data to US and Canadian users? Is it OK to bring the node and server up before that review, given that it's data-only?
2. **Node budget:** two nodes (≈ $1,600/month, list) or one node to start (≈ $725)? Is there a monthly ceiling?
3. **Hosting providers:** Vultr bare metal + Cherry Servers in Tokyo as the two-provider pair (§6.3), or wait for cheaper 16-core Latitude or Cherry stock in Tokyo? Freestyle, OVHcloud and Equinix Metal are ruled out.
4. **AWS account for the backfill:** OK to open or use one for requester-pays S3 transfer, and what's the cap on the one-time backfill spend?
5. **Gateway placement:** colocated on the node boxes in Tokyo (recommended for v1, §11), or on a separate same-LAN box from the start?
6. **Mark price:** OK to show the node-read mark (`activeAssetData`) and fall back to a labelled estimate if that doesn't work?
7. **If HIP-3 books are missing from node output** (the phase 1 test): pause, or allow the public `l2Book` for HIP-3 only, as a stopgap?
8. **Default crypto source:** core perp mid (`BTC`) or HL spot (`UBTC/USDC`), and how prominently do we label "perp" for crypto?
9. **Proxy mapping:** *resolved 2026-11-02 (§7).* `AAPL US Equity` never resolves to `xyz:AAPL`. The perp is its own instrument with its own key and a `PERP · HL xyz` label, linked from the stock by a reviewed table. Other dexes get a row only when xyz lacks the underlying and the match is unambiguous; no row needs one yet.
10. **Off-hours:** *partly resolved 2026-11-02.* The equity quote never shows the perp's price, so it stays at the listed market's last price, and the perp shows separately (§7.1). A session label for the perp is still open (§7.4).
11. **Auth:** a hosted identity provider (Clerk, WorkOS, Supabase) or self-hosted OAuth in Rust? Which sign-in methods first?
12. **Anonymous use:** can signed-out users get live data (today's behaviour), or is sign-in required for the web build?
13. **Demo account:** OK to make it server-authoritative and let it fill off-hours at perp prices?
14. **Encoding:** OK with postcard only (JSON for debugging), given that every client is Rust?
15. **Secondary feeds:** keep Binance, Coinbase and Yahoo server-side but staff-only, or drop them from the server entirely?
16. **Congress data:** get legal sign-off on the commercial-use limits before serving it publicly from our server?
17. **Licensed vendor:** is there a preferred vendor (Intrinio, Twelve Data, Massive) and a timeline, so the `DisplayPolicy` and entitlement model can be shaped around its contract?
---
## References
- **RL**: Rate limits and user limits. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits
- **INFO**: Info endpoint. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint
- **PERP**: Info endpoint, Perpetuals. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals
- **WS**: Websocket. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket
- **WS-SUB**: Websocket subscriptions. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions
- **HB**: Timeouts and heartbeats. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/timeouts-and-heartbeats
- **ASSET**: Asset IDs. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids
- **HIP3**: HIP-3, builder-deployed perpetuals. https://hyperliquid.gitbook.io/hyperliquid-docs/hyperliquid-improvement-proposals-hips/hip-3-builder-deployed-perpetuals
- **DELIST**: Delisting. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/delisting
- **HIST**: Historical data. https://hyperliquid.gitbook.io/hyperliquid-docs/historical-data
- **LAT**: Optimizing latency. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/optimizing-latency
- **L1**: L1 data schemas. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/nodes/l1-data-schemas
- **FND**: Foundation non-validating node. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/nodes/foundation-non-validating-node
- **NODE**: hyperliquid-dex/node README. https://github.com/hyperliquid-dex/node
- **OBS**: hyperliquid-dex/order_book_server README. https://github.com/hyperliquid-dex/order_book_server
- **TOU**: Hyperliquid Terms of Use (last updated June 15, 2026). https://app.hyperliquid.xyz/terms
- **XYZ-US**: trade[XYZ] US stocks. https://docs.trade.xyz/perpetuals/markets/stocks/us
- **XYZ-ORACLE**: trade[XYZ] oracle price. https://docs.trade.xyz/perpetuals/mechanics/oracle-price
- **XYZ-BOUNDS**: trade[XYZ] discovery bounds. https://docs.trade.xyz/perpetuals/mechanics/discovery-bounds
- **XYZ-TAU**: trade[XYZ] oracle time constant updates. https://docs.trade.xyz/perpetuals/changelog/oracle-time-constant-updates
- **XYZ-SPEC**: trade[XYZ] specification index (what each market references, read 2026-11-02). https://docs.trade.xyz/perpetuals/specifications-and-schedules/specification-index
- **XYZ-COMM**: trade[XYZ] commodities and their contract roll. https://docs.trade.xyz/perpetuals/markets/commodities
- **XYZ-FX**: trade[XYZ] FX. https://docs.trade.xyz/perpetuals/markets/fx
- **ORACLE**: Oracle. https://hyperliquid.gitbook.io/hyperliquid-docs/hypercore/oracle
- **MARK**: Robust price indices. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/robust-price-indices
- **FUNDING**: Funding. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/funding
- **HIP3-ACT**: HIP-3 deployer actions. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-3-deployer-actions
- **INFRA-PEERS**: Foundation non-validating node for infra providers. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/nodes/foundation-non-validating-node-for-infra-providers
- **LATITUDE**: Latitude.sh pricing. https://www.latitude.sh/pricing
- **LAT-M4**: Latitude.sh m4.metal.medium (official page; $456 US list) https://www.latitude.sh/pricing/m4-metal-medium. Tokyo price and stock come from a third-party tracker: https://servermarket.dev/providers/latitude/m4-metal-medium
- **LAT-F4**: Latitude.sh f4.metal.medium, Tokyo price and stock (third-party tracker). https://servermarket.dev/providers/latitude/f4-metal-medium
- **VULTR-API**: Vultr public plans API (plans, monthly price and locations; `nrt` is Tokyo, `itm` is Osaka) and OS list, read 2026-10-29. https://api.vultr.com/v2/plans-metal, https://api.vultr.com/v2/plans, https://api.vultr.com/v2/os
- **VULTR-BM**: Vultr bare metal product page. https://www.vultr.com/products/bare-metal/
- **VULTR-PRICE**: Vultr pricing (bandwidth overage). https://www.vultr.com/pricing/
- **CHERRY-TYO**: Cherry Servers Tokyo dedicated servers (prices, Equinix TY15, 100 TB egress, Ubuntu 24.04, private VLANs). https://www.cherryservers.com/bare-metal-dedicated-servers/tokyo. Launch post: https://www.cherryservers.com/blog/cherry-servers-tokyo-location-live
- **CHERRY-9950X**: Cherry Servers Ryzen 9950X price and stock by location (third-party tracker). https://servermarket.dev/providers/cherry/amd-ryzen-9950x. Official page: https://www.cherryservers.com/pricing/dedicated-servers/amd-ryzen-9950x
- **AWS-PRICE**: AWS EC2 on-demand Linux prices for Asia Pacific (Tokyo), from the price data behind the official pricing page, read 2026-10-29. https://aws.amazon.com/ec2/pricing/on-demand/
- **OVH-REGIONS**: OVHcloud dedicated-server regions (APAC: Singapore, Mumbai, Sydney). https://www.ovhcloud.com/en/bare-metal/regions-availability/
- **OVH-APAC**: OVHcloud APAC traffic quotas and throttling. https://support.us.ovhcloud.com/hc/en-us/articles/4404330636819-All-About-APAC-Region-Servers
- **EQX-EOL**: Equinix Metal sunset (June 30, 2026). https://docs.equinix.com/goodbye-metal/
- **FREESTYLE**: Freestyle VM pricing and limits, and inbound TLS. https://www.freestyle.sh/docs/vms/pricing-and-limits, https://www.freestyle.sh/docs/vms/network/tls-inbound
- **AWS prices** (third-party trackers, accessed 2026-10-29, not quotes): r6i.4xlarge Tokyo https://vmprices.com and https://www.usage.ai, r7a.4xlarge https://cloudcostdb.com, EBS gp3 Tokyo summaries. Official structure: https://aws.amazon.com/ec2/pricing/on-demand/ and https://aws.amazon.com/ebs/pricing/
### Observations log (read-only calls, 2026-10-29 ~17:35 UTC)
- `POST /info {"type":"perpDexs"}` returned 11 entries (null plus 10 named dexes).
- `{"type":"meta"}` returned 235 universe entries (56 `isDelisted`). `{"type":"meta","dex":"xyz"}` returned 141 entries (21 `isDelisted`), all names `xyz:`-prefixed.
- `{"type":"spotMeta"}` returned 347 pairs and 511 tokens.
- `{"type":"allMids"}` returned 1399 keys (401 `@…` spot, 762 `#…` outcomes, no `dex:` keys). `{"type":"allMids","dex":"xyz"}` returned `xyz:` keys only.
- `candleSnapshot` (startTime 0) returned:
- BTC 1m: 5104 bars (~3.5 days)
- BTC 1h: 5002 bars (from 2026-04-04)
- BTC 1d: 2263 bars (from 2020-08-19)
- `xyz:AAPL` 1d: 343 bars (from 2025-11-21)
- Numbers came back as strings.
- WS `wss://api.hyperliquid.xyz/ws`, 12 s window, subscriptions to `allMids{dex:xyz}`, `l2Book BTC`, `trades BTC`, `candle xyz:AAPL 1m`, `bbo xyz:AAPL`, `activeAssetCtx xyz:AAPL` and `activeAssetCtx @107`:
- Each got a `subscriptionResponse`.
- Message counts: l2Book 3, trades 14, activeAssetCtx 13, activeSpotAssetCtx 13, bbo 9, allMids 2, candle 2.
- `ping` → `{"channel":"pong"}`.
- Candle OHLCV came as strings.
- WS `allDexsAssetCtxs` returned ~122 KB messages (2 in 8 s). WS `fastAssetCtxs` returned 10 messages in 8 s (1.9–28 KB), with a first snapshot of 2240 coins after base64 and raw-DEFLATE decoding.
- No `exchange` endpoint calls, no orders, no user-specific subscriptions.
- `POST /info {"type":"allPerpMetas"}` returned per-dex universe sizes as listed in §5.7.
- Hosting research (2026-10-29): read-only GETs of the Vultr public plans and OS APIs (no account), plus AWS's public Tokyo on-demand price data. Nothing was ordered or provisioned.
+575
View File
@@ -0,0 +1,575 @@
# Trading on Hyperliquid: design
Status: **v1 built for testnet** (decisions in §15, as built in §16). Built-in Turnkey wallets replace the external-wallet and pasted-key flows (decided 2026-11-04, §4.5). Mainnet is not reachable from this build; it waits on §12.
Author: Grok Bot, for Micky; v1 by Claude. Research date: 2026-11-02 (read-only calls about 23:30 UTC). Times are UTC unless marked.
Micky asked: *"i wanna enable crypto trading and stocks (via hyperliquid)"*. This doc designs order entry on Hyperliquid from Terminal's desktop app and the WASM web build. That covers crypto (core perps such as `BTC`, and spot such as `HYPE/USDC`) and the HIP-3 equity perps the terminal already shows (`xyz:AAPL`, labelled `PERP · HL xyz`). It fits the order entry that exists today: the demo account, the SnapTrade opt-in, the `PORT`/`POS`/`ORD`/`ACTV` panes and the order ticket.
**Read §12 (legal) first.** Hyperliquid's Terms of Use name Ontario, Canada as a restricted region. This needs a decision from Micky before any phase touches mainnet, and it affects how testnet can be funded too. Micky's answer for now (§15): build and demo on testnet; legal review before mainnet.
Evidence tags used below:
- **[doc: X]** is from Hyperliquid's docs, Terms of Use or SDK source (references at the end).
- **[observed]** is from a read-only call or a local build I ran on 2026-11-02. These are snapshots, not guarantees.
- **[repo]** is from reading `michaelshimeles/terminal` at `0690de8`.
- **[inferred]** and **[unverified]** are my reasoning, to be confirmed on testnet in phase 1.
---
## 1. Goals and non-goals
### Goals
1. Place, modify and cancel orders on Hyperliquid from the order ticket: core perps, spot, and HIP-3 perps on the `xyz`, `para`, `io` and `mkts` dexes.
2. Show the account live: positions, open orders, fills, funding, margin and PnL, in the existing `PORT`/`POS`/`ORD`/`ACTV` panes, from Hyperliquid's user websocket streams.
3. Keep custody with the user. Terminal never sees the user's main wallet key, and Terminal's servers never sign anything.
4. Ship the same flow on desktop (macOS first) and on the web build at `terminal.rasmic.xyz/app`.
5. Be safe by default: off until the user opts in, testnet first, a confirmation on every order, a max-notional guard, a kill switch, and no keys in logs.
6. Never present a HIP-3 equity perp as a share.
### Non-goals (for this design)
- **No server-side signing or order routing.** `tr-gateway` stays data-only, as `docs/architecture/server.md` §1 already decided ("It never signs, routes or places orders") [repo]. §3 explains why this doc keeps that.
- No deposits, withdrawals or transfers in v1. Users fund the account on Hyperliquid themselves. (`usdClassTransfer` between spot and perp may come later, §11.)
- No vaults, sub-accounts, multi-sig, staking, TWAP or outcome markets in v1.
- No automated or algorithmic trading. Every order starts from a human click or command.
- No change to the demo account or to SnapTrade.
---
## 2. How Terminal trades today (what this fits into)
| Piece | Today | Where |
|---|---|---|
| Account modes | `AccountMode::{Demo, Live}`. Demo is the default: one-click orders filled locally against the terminal's quotes. Live means SnapTrade. The toolbar shows a `DEMO`/`LIVE` badge. | `app/src/prefs.rs`, `app/src/app/chrome.rs` [repo] |
| Opt-in | `TradingPrefs { account_mode, enabled, max_order_value, default_account }`. Live orders stay off until Settings › Trading is enabled, behind an acknowledgement dialog ("I understand that orders are real…"). | `app/src/prefs.rs`, `app/src/app/trading.rs` `enable_dialog` [repo] |
| Order ticket | `Ticket` (symbol, side, `OrderType::{Market, Limit, Stop, StopLimit}`, `TimeInForce::{Day, Gtc, Fok, Ioc}`, units or dollars, `SELL ALL`). It opens from `BUY`/`SELL`/`TRADE`, ⇧B/⇧S, chart, watchlist and position rows, and the ⌘K palette. | `app/src/app/trading.rs` [repo] |
| Live safety rails | (1) off until enabled; (2) an order can only be placed from a broker preview of exactly the current fields; (3) one confirmation showing account, symbol, side, quantity and estimated cost; (4) an optional maximum order value. | `app/src/app/trading.rs` module docs [repo] |
| Worker | The UI sends `Request::Trade(TradeRequest::{Preview, Place, Cancel, Portal, Capabilities})` and drains `TradeResponse`, so it never blocks. In the browser the same requests run as tasks on the page's event loop. | `app/src/worker.rs` [repo] |
| Broker crate | `tr-broker`: the SnapTrade client, the `Portfolio`/`Account`/`Position`/`Order` model, `DemoBook`, and `SecretStore` (macOS Keychain service `terminal`, otherwise an owner-only JSON file). `Credentials` has a redacting `Debug` and `mask()`. | `crates/broker/src/*.rs` [repo] |
| Market data | `tr-gateway` (`wss://gateway.rasmic.xyz/ws`) relays Hyperliquid's public websocket. `tr-proto` `MarketInfo { key, venue, kind: Perp \| Spot }`. HIP-3 perps are instruments of their own, labelled `PERP · HL xyz`, never resolved from `AAPL US Equity`. | `crates/proto/src/lib.rs`, `server/src/hl/`, `server.md` §7 [repo] |
The design reuses all of this. Hyperliquid becomes a **third account source** next to Demo and SnapTrade, with its own opt-in, so enabling SnapTrade live orders never enables Hyperliquid orders, or the reverse.
---
## 3. Custody: who holds the key that signs orders
### 3.1 What Hyperliquid requires
- Every order, cancel and modify is an **L1 action** posted to `POST https://api.hyperliquid.xyz/exchange` (testnet: `https://api.hyperliquid-testnet.xyz`) with `action`, `nonce`, `signature`, and optionally `vaultAddress` and `expiresAfter` [doc: EXCH, API]. It can also go over the websocket as a `post` message [doc: WSPOST].
- The signer can be the account's own key **or an API wallet ("agent wallet")** that the account approved. API wallets "are only used to sign"; account data is always queried by the master address [doc: NONCE].
- **Approving an agent is a user-signed action** (`approveAgent`, EIP-712 domain `HyperliquidSignTransaction`), so the main wallet has to sign it once [doc: EXCH, SDK-SIGN]. Approving a builder fee must be signed by the main wallet, "not an agent/API wallet" [doc: BUILDER]. Transfers and withdrawals (`usdSend`, `spotSend`, `withdraw3`) are user-signed actions of the same kind [doc: EXCH]. I infer that an agent key cannot move funds out [inferred]. The one agent-signable transfer, `agentSendAsset`, requires "Destination must match the source address" [doc: EXCH].
So a stolen agent key can trade, and lose money by trading, but per the docs it cannot withdraw. That is the key fact behind the recommendation below.
### 3.2 Options
**Option A: client-side signing with a local API wallet (recommended).**
- Terminal generates a fresh secp256k1 key on the device. Its address is approved as a **named** agent by the user's main wallet, with an expiry. The private key never leaves the device. Every order is signed locally and posted straight to Hyperliquid.
- **Desktop:** the agent key lives in the OS secret store. That is the macOS Keychain, under its own service (`terminal.hyperliquid`, account `testnet:<master address>`). On Linux it is the Secret Service (GNOME Keyring, KWallet) through the `keyring` crate, with **no file fallback**: without a keyring, Hyperliquid trading stays off and Settings says why (Q6). Other platforms have no store, so trading is off there.
- **Web:** orders are signed by the agent key in WASM. The agent key is either **session-only** (memory; lost on reload, re-approve next time) or an **encrypted local key** (wrapped with a non-extractable WebCrypto AES-GCM key and stored in IndexedDB). Default: session-only, with "Remember on this browser" as an explicit choice.
- **The main wallet** is the user's built-in Turnkey wallet on both (§4.5). It signs only the rare user-signed actions: approving the agent and replacing it. *(Before 2026-11-04 the web used a browser wallet over EIP-1193 and the desktop a pasted testnet key; both are gone from the app.)*
- Why not have the browser wallet sign each order? L1 actions are signed over an EIP-712 domain with `chainId: 1337` ("phantom agent") [doc: SDK-SIGN]. Wallets generally won't sign typed data for a chain they aren't on, and a popup per order is unusable anyway [inferred]. Hyperliquid's own SDK and docs steer trading processes to API wallets [doc: NONCE].
**Option B: signing on the server.**
- `tr-gateway`, or a new service, holds each user's agent key (in a KMS or HSM) and signs and posts orders for authenticated users.
- For it: one implementation, keys out of browsers, and it is easy to add server-side risk checks.
- Against it:
1. It reverses a recorded decision. `server.md` §1 says the server "never signs, routes or places orders", partly because of the legal flag [repo].
2. Goshen Labs would run trading infrastructure that holds keys able to trade customer accounts. That is a much larger security target, and a regulatory question for an Ontario company (§12).
3. Rate limits are per IP: REST weight is 1200/min, and user-specific websocket subscriptions allow at most 10 unique users per IP [doc: RL]. All users behind one server IP would share them.
4. It needs sign-in (server.md §8 is still open), per-user secrets at rest, and an audit trail before the first order.
5. Compromising the server would compromise every user at once.
**Option C (fallback inside A): paste an API wallet key.** For users who already made an API wallet elsewhere (for example on Hyperliquid's own API page), Terminal accepts its private key and stores it like an A key. It has the same trust model, but the key was generated outside Terminal. This is offered as a fallback, not the default.
### 3.3 Recommendation
**Option A.** Every order is signed on the user's own device by a Terminal-generated API wallet that the user's main wallet approves once (named, with an expiry). The key sits in the macOS Keychain on desktop, and in memory or a WebCrypto-encrypted IndexedDB entry on the web. Neither Terminal's server nor Terminal itself ever holds the main key. A leaked agent key can trade but, per the docs, cannot withdraw. Revocation is one action, and `tr-gateway` stays data-only, so the per-IP limits and the server-side custody and legal exposure of Option B never arise.
### 3.4 Signing is small enough to own, and it works in WASM
Hyperliquid warns that hand-rolled signatures fail silently: a wrong signature recovers a different address, with errors like "User or API Wallet … does not exist" [doc: SIGN]. The algorithm is short [doc: SDK-SIGN]:
1. `msgpack(action)`, with **field order exactly as in the SDK** (order matters), then append `nonce` as 8 big-endian bytes, then `0x00` (no vault) or `0x01 ‖ vault address`, and for `expiresAfter` append `0x00 ‖ expiresAfter` as 8 big-endian bytes. `keccak256` of all of it gives `connectionId`.
2. The phantom agent is `{source: "a" (mainnet) | "b" (testnet), connectionId}`.
3. EIP-712 sign `Agent(string source, bytes32 connectionId)` under the domain `{name: "Exchange", version: "1", chainId: 1337, verifyingContract: 0x0}`.
4. User-signed actions (approveAgent, approveBuilderFee) use the domain `HyperliquidSignTransaction`, version 1, with `chainId = signatureChainId` and `hyperliquidChain: "Mainnet" | "Testnet"` in the message. The Python SDK sets `signatureChainId = 0x66eee`, and the docs' examples show `0xa4b1` (Arbitrum) [doc: SDK-SIGN, EXCH].
5. Common pitfalls the docs list: two signing schemes, msgpack field order, trailing zeros in numbers, and upper-case addresses (lowercase everything before signing) [doc: SIGN, TICK].
**Spike [observed, on the box, not in this PR]:** about 40 lines using `alloy-sol-types` (the `sol!` `Agent` struct and `eip712_domain!`), `alloy-signer-local` 1.8 (k256) and `rmp-serde::to_vec_named` reproduce the Python SDK's published test vector `test_l1_action_signing_matches` exactly: r/s/v for both mainnet (v=27) and testnet (v=28). The same crate **builds for `wasm32-unknown-unknown`** with `getrandom` 0.3 `wasm_js` and 0.2 `js` enabled (rustc 1.99.1). I have not yet run it in a browser.
### 3.5 Rust SDK options
| Option | State [observed 2026-11-02] | Fit |
|---|---|---|
| `hyperliquid-dex/hyperliquid-rust-sdk` (official) | 474★, MIT. Last commit 2025-10-21; crates.io `hyperliquid_rust_sdk` 0.6.0 published 2025-05-16. Depends on `tokio` "full" and `tokio-tungstenite` with `native-tls`. Its info client has no `dex` parameter (grep), so no HIP-3 queries. The current docs page links only the Python SDK and the community Rust and TS SDKs [doc: API]. | Not WASM-friendly (deps) [inferred], no HIP-3, and stale. Use as a reference only. |
| `infinitefield/hypersdk` (community; linked from the API docs as the Rust SDK) | 235★, MPL-2.0, pushed 2026-10-26, crates.io 0.3.0 (2026-10-12). alloy 2, rust_decimal, HIP-3 support, multisig. | **Does not build for wasm32** [observed]: even with `default-features = false`, 0.3.0 failed with 5 errors (yawc `TcpWebSocket`, non-`Send` futures, `reqwest::ClientBuilder::timeout`). A good reference and a source of test vectors; possible on desktop only. MPL-2.0 is file-level copyleft (fine to depend on; copying files in carries the licence). |
| `alethea` | The only crate by that name on crates.io is a templating library (0.1.0), unrelated to Hyperliquid [observed]. If Micky meant another crate, it needs a pointer (Q9). | Not applicable. |
| Own minimal module on `alloy-sol-types` + `alloy-signer-local` + `rmp-serde` | Proven by the spike above. | **Recommended.** One code path for desktop and WASM, our own action structs with explicit field order, and CI pinned to Python SDK test vectors. |
---
## 4. User flow
### 4.1 Turning it on
1. Settings › Trading › **Hyperliquid** (new section; off by default). This build has **testnet only**: no network selector, no mainnet URL, no switch. Mainnet comes in phase 4, after §12, in a build made for it (§8).
2. A first-run acknowledgement like the SnapTrade `enable_dialog` [repo], with Hyperliquid-specific lines: orders are irreversible on-chain transactions; perps are leveraged and can be liquidated; HIP-3 stock markets are perpetual futures, not shares; Hyperliquid's Terms restrict some regions, including Ontario (§12), and you are responsible for whether you may use it.
3. **Create wallet / Sign in** (§4.5): the browser opens Terminal's sign-in page, and the user's built-in wallet appears in Settings. Its address is the master address, used for reads and display. "Watch an address" still shows any testnet account read-only, with no key involved, so `PORT`/`POS`/`ORD` light up with live data and no trading.
### 4.2 Approving the agent (one main-wallet signature)
1. Terminal generates the agent key locally and stores it (Keychain or browser).
2. It builds `approveAgent { hyperliquidChain, signatureChainId, agentAddress, agentName: "terminal-<device> valid_until <ms>", nonce }`.
- **Named, never unnamed.** "An account can have 1 unnamed approved wallet and up to 3 named ones", plus 2 more named per sub-account. Approving a new unnamed agent deregisters the existing unnamed one [doc: EXCH, NONCE], which may be the user's Hyperliquid web session or another tool.
- **Expiry:** `valid_until`, at most 180 days ahead [doc: EXCH]. Default: 180 days since 2026-11-04, because each approval is a paid Turnkey signature (§4.5); it was 30 (Q5).
3. Signing: the built-in Turnkey wallet signs the EIP-712 digest of the approval, at `signatureChainId` 0x66eee with `hyperliquidChain: "Testnet"` (§4.5). Terminal checks that the signature recovers the wallet's address before posting it to `/exchange`. *(Replaced on 2026-11-04: the web's `eth_signTypedData_v4` through a browser wallet and the desktop's pasted testnet key.)*
4. Terminal shows: agent address (masked), name, network, expiry, and "Revoke".
### 4.3 Revoking and rotating
- The docs list no explicit "revoke agent" action. An agent is deregistered when a new `approveAgent` reuses its **name** (or a new unnamed one is approved), when it expires, or when the account runs out of funds [doc: NONCE].
- **Revoke** in Terminal: delete the local key first (Keychain or IndexedDB), then have the built-in wallet sign `approveAgent` with the **same name** and a freshly generated throwaway address whose key is dropped immediately. That costs one Turnkey signature and needs a fresh sign-in; "Delete key here" alone is free.
- **Never reuse an agent address.** The docs "strongly" advise it, because once pruned, the nonce set can let "previously signed actions … be replayed" [doc: NONCE]. Rotation always generates a new key.
- Terminal warns 7 days before expiry and offers one-click renew (new key, same name).
### 4.4 Funding
Terminal moves no funds. The **Deposit** window shows the built-in wallet's address in full, as a QR code and with a Copy button, next to the account's testnet balances (perp account value and withdrawable from `clearinghouseState`, spot USDC from `spotClearinghouseState`), and says it is testnet only. The user sends test USDC there on Hyperliquid testnet (for example a `usdSend` from another testnet account). Account abstraction matters for HIP-3:
- In **unified account** mode, "USDC balance is the single source for validator-operated perps, XYZ perps, and spot".
- In **standard** mode, each dex has a separate balance [doc: AAM].
Terminal reads the mode and shows per-dex balances in standard mode. If the HIP-3 dex balance is zero, the ticket explains it instead of failing at submit.
### 4.5 Built-in wallets (Turnkey), testnet only
Decided 2026-11-04 (§15). Users never connect MetaMask or any other external wallet, and never paste a private key. Each user gets a wallet that Turnkey holds, and that wallet is their Hyperliquid account. Turnkey references are tagged [tk: X] (list at the end).
**Who holds what**
| Key | Where | Signs |
|---|---|---|
| Wallet (main) key, secp256k1 | Inside Turnkey's enclaves, in the user's own sub-organization. Nobody at Goshen, and no Terminal server, can use it: a parent organization has read access to its sub-organizations and no write access [tk: ORGS]. | Only `approveAgent` (approve, renew, revoke). Never an order. |
| Session key, P-256 | Made on the device. Desktop: the OS secret store, like the agent key. Web: memory. | Turnkey API requests (the `X-Stamp` header) for 15 minutes. The session token alone authorizes nothing [tk: SESSIONS]. |
| Agent key, secp256k1 | Made on the device, stored as in §3.2 (Keychain, Secret Service, browser memory or the opt-in encrypted IndexedDB copy). | Every L1 action: orders, cancels, modify, leverage, `scheduleCancel`, `noop`. |
**Sign-in.** The desktop opens the system browser at Terminal's sign-in page, `https://terminal.rasmic.xyz/wallet`. The web build opens the same page in a popup.
```mermaid
sequenceDiagram
participant T as Terminal (desktop or WASM)
participant B as Browser: terminal.rasmic.xyz/wallet
participant P as Turnkey Auth Proxy
participant K as Turnkey API
participant H as Hyperliquid testnet
T->>T: make session key D, state (256 bits)
T->>B: open ?flow=desktop&pk=D.pub&state&port (or a popup on the web)
B->>P: email OTP or passkey (@turnkey/core); sign-up creates the sub-org and its wallet
P-->>B: browser session for a key B kept by the page
B->>K: stamp_login(publicKey = D.pub, 900 s), stamped with B
K-->>B: session token bound to D
B->>B: log out (drops B)
B->>T: 302 to http://127.0.0.1:port/callback?state&session (web: postMessage to the opener)
T->>T: check state, token's public_key == D, not expired
T->>K: whoami, list_wallets, list_wallet_accounts (stamped with D)
T->>T: make agent key A, approveAgent(A, "terminal-<device>", 180 days)
T->>K: sign_raw_payload(digest of the testnet approval), stamped with D
K-->>T: r, s, v
T->>T: the signature must recover the wallet's address
T->>H: POST /exchange approveAgent
T->>H: orders, signed locally by A
```
1. Terminal makes a fresh P-256 session key D and a 256-bit `state`. On the desktop it listens on `127.0.0.1` at a port the OS picks, for at most 10 minutes.
2. The page signs the user in with Turnkey's JS SDK (`@turnkey/core`) through Turnkey's Auth Proxy: an email code, or a passkey. A new user gets a sub-organization with one Ethereum wallet at `m/44'/60'/0'/0/0`; a returning user logs in to theirs [tk: AUTHPROXY].
3. The page now has its own short browser session (key B, kept by the SDK). It calls `stamp_login` with `publicKey` set to D's public key. That is the call the SDK's own `refreshSession` makes [tk: SDK-CORE], and it returns a session token bound to D. The page then logs out, so the browser keeps no session.
4. The page hands the token back. On the desktop that is a redirect to `http://127.0.0.1:<port>/callback?state=…&session=…`; the page only ever builds that loopback URL from a port number, so it is not an open redirect. On the web it is a `postMessage` to the opener, which checks the message's origin.
5. Terminal accepts the token only if `state` matches and the token's `public_key` is D's. It then reads the wallet's address from Turnkey itself (`list_wallets`, `list_wallet_accounts`) with requests stamped by D, never from the URL, and shows the Turnkey user name from `whoami`.
Why a loopback redirect and not `terminal://`: RFC 8252 recommends the loopback for desktop apps (§7.3). It needs no URL scheme registered with the OS (an `Info.plist` entry on macOS, a `.desktop` file on Linux), and while Terminal is listening no other program can take the port. A custom scheme can be claimed by any app that registers it first. The token in the URL is useless without D's private key, so a redirect that leaks into history or another tab exposes nothing that can sign. A program on the same machine that reads the `open` command's arguments could see `state` and race the browser with a session from its own Turnkey account; Terminal shows the Turnkey user name after sign-in so a swapped account is visible. RFC 8252 treats local malware as out of scope for this reason.
**What Turnkey signs, and how often.** Turnkey charges per signature: on Pay as You Go, 25 free per month for the whole parent organization, then $0.10 each [tk: PRICING]. Terminal asks it to sign only `approveAgent`:
- `sign_raw_payload` V2 with `PAYLOAD_ENCODING_HEXADECIMAL` and `HASH_FUNCTION_NO_OP`, over the EIP-712 digest `tr-hl` computes (as `alloy-signer-turnkey` does). The digest is pinned to the Python SDK's vector in tests.
- `tr_hl::signing::sign_approve_agent_with` is the only way a remote wallet's signature enters `tr-hl`. It computes the digest of a testnet approval (`hyperliquidChain: "Testnet"`, `signatureChainId: 0x66eee`), refuses anything else before Turnkey is called, and keeps the answer only if it recovers the wallet.
- L1 signing takes the sealed `SigningKey` trait, which only the local `AgentKey` (and the test-only `MainKey`) implement. The Turnkey client is not a `SigningKey`, so it cannot sign an order.
- Agents approved through the built-in wallet are valid for **180 days** (Hyperliquid's maximum), not 30, because each approval is a paid signature. The 7-day expiry warning stays.
Budget per user per 180 days: one signature per device (desktop, or a browser with "Remember on this browser" on), plus one per early renewal and one per on-chain revoke. A browser with "remember" off (the default) needs a new approval, and so a new signature, on every visit; Settings says so next to the checkbox. Example: 150 desktop users cost about 25 signatures a month, inside the free tier; 1,000 users about 167 a month, roughly $14. Hyperliquid allows 3 named agents per account, so one wallet can trade from 3 devices at a time [doc: EXCH]. Turnkey doesn't list which other activities (sub-organization creation, OTP, sessions, export) it bills as signatures; its definition, "a secure authorization that executes an onchain action", suggests they aren't, but that is unconfirmed.
**Sessions.** Sessions last 15 minutes (`stamp_login` with `expirationSeconds: 900`, Turnkey's default). That is enough to approve a trading key, and nothing else needs one. On the desktop the session key and token are kept in the OS secret store under `turnkey:testnet:<address>` so a restart in those 15 minutes doesn't force another sign-in; Terminal deletes the item when the session expires or the user signs out. On the web they live in memory only. Neither is ever logged: `SessionKey` and `Session` print a masked public key only.
**No Terminal server holds a Turnkey credential.** Creating a sub-organization is an activity the parent organization signs [tk: SUBORG], so some parent-organization key has to sign it. With the **Auth Proxy**, that key is a proxy-scoped API key Turnkey creates in Micky's parent organization when the proxy is turned on, and Turnkey stores and uses it; the page only sends the public config ID (`X-Auth-Proxy-Config-Id`) [tk: AUTHPROXY]. `tr-gateway` doesn't change, and no Vercel function is added. The parent organization, and so the proxy key, can start sign-ins and create sub-organizations, but cannot sign with or change a sub-organization's wallet [tk: ORGS, WAAS].
Proxy settings Micky needs (Turnkey dashboard, Embedded Wallets › Configuration): allowed origins `https://terminal.rasmic.xyz` only; email OTP and passkeys on, everything else off; session expiration 900 s; the sign-up wallet one Ethereum account at `m/44'/60'/0'/0/0`; captcha (Turnstile) on if sign-up abuse shows up.
Fallback, only if Micky doesn't want the Auth Proxy (**not built, not deployed**): one small Vercel function on the site, `POST /api/wallet/signup`, holding a parent-organization API key that belongs to a **non-root** user (root users bypass every policy [tk: POLICIES]). Its only policy:
```json
{
"policyName": "terminal-signup: sub-organizations and OTP only",
"effect": "EFFECT_ALLOW",
"consensus": "approvers.any(user, user.id == '<SIGNUP_USER_ID>')",
"condition": "activity.type == 'ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V8' || activity.type == 'ACTIVITY_TYPE_INIT_OTP_V3' || activity.type == 'ACTIVITY_TYPE_VERIFY_OTP_V2' || activity.type == 'ACTIVITY_TYPE_OTP_LOGIN_V2'"
}
```
Everything else is denied by default. The function would rate-limit by IP and by email (the site already uses Upstash Redis), accept only an email or a passkey attestation plus the client's public key, and expose no signing route. Even a stolen copy of that key could not sign for a user's wallet, because the parent has no write access to sub-organizations.
**Backup and export.** "Back Up Wallet…" warns first (anyone with the phrase controls the wallet; Terminal and Goshen can't restore it; never type it into a website), then opens the sign-in page in export mode. The user signs in again there. The page embeds Turnkey's export iframe (`export.turnkey.com`, `@turnkey/iframe-stamper`), which makes its own target key; the page asks Turnkey to export the wallet to that key, and passes the encrypted bundle to the iframe, which decrypts and shows the recovery phrase [tk: EXPORT]. The phrase exists in plaintext only inside Turnkey's iframe: not in the page's JavaScript, not in the app, not on any Terminal server. The page logs out when the user is done.
**Mainnet stays impossible.** Nothing in §8's testnet rail changes. The endpoints are still constants, `source` is `"b"`, and `hyperliquidChain` is `"Testnet"`. `approve_agent_hash` now also requires `signatureChainId` 0x66eee, so Turnkey can only be asked for a testnet approval. `crates/hl/tests/mainnet_impossible.rs` checks that a remote wallet is never called for a mainnet or 0xa4b1 approval, that Turnkey's API (its local stand-in) receives nothing for one, and that the Turnkey crate, the app's wallet modules and the sign-in page name no mainnet host.
**Removed from the app.** The web's EIP-1193 "Connect Wallet" and the desktop's "paste a testnet main key" field. `MainKey` stays in `tr-hl` for tests and the developer-only `testnet_check` example (run from a terminal with a 0600 key file; nothing in the app reaches it). "Watch an address" stays: it is read-only, holds no key and signs nothing, and it lets the panes work before Turnkey is set up.
---
## 5. Order routing: perps, spot and HIP-3 dexes
### 5.1 Asset ids [doc: ASSET, EXCH]
| Market | Asset id | Example |
|---|---|---|
| Core perp | index in `meta.universe` | `BTC` = 0 on mainnet |
| Spot | `10000 + spotMeta.universe[i].index` | `PURR/USDC` = 10000. HYPE spot is 107 on mainnet and 1035 on testnet: **ids differ by network** |
| HIP-3 perp | `100000 + perp_dex_index * 10000 + index_in_meta`, names always `{dex}:{coin}` | see below |
`perp_dex_index` is the position in the `perpDexs` info response (index 0 is the core dex) [doc: ASSET, PERP]. Observed on 2026-11-02:
| Network | dex (index) | `xyz:AAPL` | Others |
|---|---|---|---|
| Mainnet | `xyz` (1), `para` (8), `mkts` (9), `io` (10) | index 9 in `meta{dex:"xyz"}` → **110009** | `para:TOTAL2` → 180000, `mkts:US500` → 190000, `io:OAI` → 200000 |
| Testnet | `xyz` (**65**); `para`, `io`, `mkts` absent | index 9 → **750009** | — |
**Rule: never hard-code asset ids.** `tr-hl` builds an `AssetRegistry` per network from `perpDexs` + `meta{dex}` per dex + `spotMeta`. It caches with a TTL and refreshes on an unknown coin or a reject. It is keyed by the venue-native names `tr-proto` already uses (`BTC`, `xyz:AAPL`, `@107`, `PURR/USDC`) [repo]. `szDecimals`, `maxLeverage`, `onlyIsolated` and `marginMode` come from the same `meta` (for `xyz:AAPL`: `szDecimals` 3, `maxLeverage` 20 [observed]).
### 5.2 Price and size rules [doc: TICK]
- Prices: at most 5 significant figures, and at most `6 − szDecimals` decimals for perps or `8 − szDecimals` for spot. Integer prices are always valid.
- Sizes are rounded to `szDecimals`. Trailing zeros are stripped before signing.
- Minimum order value: $10 [doc: EXCH, error example].
The ticket rounds as you type and shows the value that will be sent.
### 5.3 Margin and leverage
`updateLeverage {asset, isCross, leverage}` [doc: EXCH]. The ticket shows the current leverage and margin mode from `activeAssetData` [doc: WSSUB]. Changing it is its own confirmed action, never a side effect of placing an order. HIP-3 assets may be isolated-only (`onlyIsolated`) [doc: PERP, HIP3]. Terminal respects the meta flag and caps leverage at `min(meta.maxLeverage, user cap)` (§8).
---
## 6. Order types and the ticket
### 6.1 Mapping
| Ticket | Hyperliquid action | Notes |
|---|---|---|
| Limit + GTC / IOC / Post-only | `order` with `t: {limit: {tif: "Gtc" \| "Ioc" \| "Alo"}}` | ALO "will be canceled instead of immediately matching" [doc: EXCH] |
| Market | `order` with an IOC limit at `mid × (1 ± slippage)` | There is no native market order. The Python SDK's `market_open` uses an IOC limit and a default 5% slippage [doc: SDK-EXCH]. Terminal default: 1%, editable, shown in the confirm. |
| Stop / Stop-limit, Take-profit | `order` with `t: {trigger: {isMarket, triggerPx, tpsl: "sl" \| "tp"}}` | |
| Bracket (entry + TP + SL) | `order` batch with `grouping: "normalTpsl"`; position TP/SL with `"positionTpsl"` | phase 3 |
| Reduce-only | `r: true` | Default on for close and `SELL ALL` from a position row |
| Client order id | `c`: 128-bit hex `cloid` | Always set, so an uncertain placement can be reconciled (like `PlaceError::uncertain` today [repo]) |
| Modify | `modify` / `batchModify` | Only from `ORD` |
| Cancel | `cancel` (by `oid`) or `cancelByCloid` | `f: true` (fast) is rejected for trigger orders [doc: EXCH]. Terminal leaves `f` out and pins the exact bytes with SDK test vectors, since field presence changes the hash [inferred] |
| Expiry | `expiresAfter = now + 30 s` on every order | A stale signed order can't land late. Expired actions "consume 5x the usual address-based rate limit" [doc: EXCH], so the window is short but not tiny. |
| Not in v1 | `twapOrder`, `trailingStop`, outcomes, vault/sub-account `vaultAddress` | |
`TimeInForce::Day` and `Fok` have no Hyperliquid equivalent. The ticket hides them for HL accounts.
### 6.2 The ticket in the Bloomberg-style panes
The existing `Ticket` window gains an HL variant, chosen by the account, not a second ticket:
```
┌ BUY xyz:AAPL ─ Apple Inc perp ─ PERP · HL xyz ─────────── TESTNET ┐
│ Account HL 0x12…ab (unified) Equity $1,024.10 Avail $812 │
│ Side [BUY] SELL Type Market Limit Stop TP │
│ Size 0.500 AAPL ≈ $113.20 [units | $] │
│ Limit 226.40 TIF GTC IOC Post-only │
│ Leverage 5× cross (max 20×) Reduce-only [ ] Slippage 1.0% │
│ Est. fee $0.05 Liq. px ≈ 184.9 Margin $22.64 │
│ ⚠ Perpetual future on Hyperliquid (xyz). Not a share of AAPL. │
│ [ Review order ⏎ ] │
└────────────────────────────────────────────────────────────────────┘
```
- **Network badge** in the title: `TESTNET` in amber, `MAINNET` in the live red that `LIVE` uses today [repo].
- The market line reuses the label code that already renders `PERP · HL xyz` [repo]. HIP-3 rows add a one-line perp warning that cannot be dismissed (§7).
- Reference price, fee and liquidation estimates come from the same network the order goes to. On testnet that is testnet `allMids`/`l2Book`, **not** the gateway's mainnet quotes [inferred: testnet prices differ from mainnet].
- Commands: `BUY xyz:AAPL 0.5 @226.4`, `SELL BTC 0.01 MKT`, `TRADE HYPE/USDC`, with the same parser as today. A new `KILL` command and the Kill button are covered in §8.
### 6.3 Confirmation
Every HL order and cancel-all goes through **one confirmation modal**. There is no one-click (that stays demo-only). It shows:
- network;
- master address (masked) and agent name;
- market with its kind (perp/spot/HIP-3 dex);
- side, size, price or slippage cap;
- notional;
- leverage and margin mode;
- estimated liquidation price;
- fee, including any builder fee;
- reduce-only;
- `expiresAfter`.
Editing any field invalidates the confirmation, mirroring today's "preview of exactly the ticket's current fields" rail [repo]. Hyperliquid has no preview endpoint, so Terminal builds the preview locally from `activeAssetData` and `clearinghouseState`. It is labelled as an estimate.
---
## 7. HIP-3 stock perps: labelled as perps, not shares
The rules from `server.md` §7 carry over to trading [repo]:
1. A HIP-3 market is its own instrument (`xyz:AAPL`). `BUY AAPL` on an HL account **does not** route to `xyz:AAPL`. It errors with a suggestion: "AAPL is a stock. Hyperliquid has the perp xyz:AAPL (PERP · HL xyz). Trade that instead?"
2. Everywhere the market appears (ticket, confirm, `POS`, `ORD`, fills, toasts), the `PERP · HL <dex>` label is shown. Positions use `InstrumentKind::Future` (no new variant needed) and show "perp" in the description, never "shares".
3. The confirm repeats that it is a leveraged perpetual future with funding, priced by the deployer's oracle, which can diverge from the listed stock (off-hours, opens, earnings, corporate actions). The deployer can halt and settle the market at mark [doc: HIP3]. Cross margin across dexes carries extra risk [doc: HIP3].
4. `PORT` totals keep HL perps apart from brokerage share holdings. There is no "AAPL exposure" netting across a share and a perp in v1.
---
## 8. Safety
| Rail | Design |
|---|---|
| Off by default | `prefs.trading.hl.enabled = false`, separate from SnapTrade's `enabled`. The Hyperliquid account is read-only until enabled with its acknowledgement (§4.1). |
| Testnet only (v1) | `Network` has one variant, `Testnet`. The endpoints are the testnet hosts, the phantom agent's `source` is always `"b"` and `hyperliquidChain` always `"Testnet"`; a stored `"network": "mainnet"` reads as testnet. Tests grep `tr-hl` for mainnet hosts. Mainnet needs a later build after §12, with its own acknowledgement. The amber TESTNET badge is on the toolbar, the account, the ticket and every confirmation. |
| Main wallet (built-in) | The Turnkey wallet signs only `approveAgent`, through `tr_hl::signing::sign_approve_agent_with`: Turnkey is handed the digest of a testnet approval at 0x66eee and nothing else, and the signature must recover the wallet before it is sent. Turnkey sessions last 15 minutes, are bound to a key made on the device, and never reach a log (§4.5). |
| Confirmation | One per order, cancel-all and leverage change (§6.3). Enter confirms; Escape or a click outside cancels, as today [repo]. |
| Hard caps | Constants in `tr-hl`, not settings: **$100** per order (size × the higher of the order price and the mid), **3×** leverage, **1%** market slippage. `tr-hl` checks them when the confirm opens, and the worker reads the testnet mid and leverage again and re-checks right before signing. An order is refused while the account's leverage on that market is over 3× (Hyperliquid's default is 20×), so a confirmed "Set 3×" comes first; reduce-only orders are not blocked by leverage. A breach refuses before anything is signed. |
| Kill switch | `KILL` or the red KILL button in `ORD` (and in Settings): one confirmation, then Hyperliquid orders turn off in prefs and every open order is cancelled, one `cancel` action per dex (cancels have a higher rate-limit allowance [doc: RL]). KILL never waits for another request. It cancels at once and refuses any order confirmed before it that hasn't been signed, even one still reading the market. If an order was already being sent, KILL cancels again once that order can no longer land. That is when its answer comes, or when its `expiresAfter` has passed if the send got no clear answer. Single cancels and the dead man's switch never wait for orders either. "And flatten" is not in v1. |
| Dead man's switch | **Opt-in, off by default** (Q7). While on and trading is enabled, Terminal keeps a `scheduleCancel` about 60 s ahead and refreshes it every 30 s [doc: EXCH]; turning it off clears the schedule. These changes go out one at a time, in the order Terminal made them, and never wait for an order. A newer change replaces one still waiting, and each is signed with a fresh deadline when it goes out. After a change whose send got no clear answer, the next waits until that one can no longer land, so an older timer or clear never lands after a newer change. Constraints: at least 5 s ahead, and at most 10 triggers per day, reset 00:00 UTC [doc: EXCH]. |
| Nonces | One agent per device and network (nonces are tracked per signer; "use a separate private key" per session [doc: NONCE]). The nonce is an atomic `max(last + 1, now_ms)`, within (T − 2 days, T + 1 day) [doc: NONCE]. A noop (`{"type": "noop"}`) can invalidate an in-flight nonce [doc: EXCH]. |
| Uncertain results | A network error after send means "uncertain", never "failed". Reconcile by `cloid` via `orderStatus` and `orderUpdates` before allowing a resend (same idea as `PlaceError::uncertain` [repo]). Every `/info` and `/exchange` request has its own 10 s deadline, in the browser too (`fetch` has no default timeout); an `/exchange` request that runs out of time is uncertain. |
| Never log keys | `AgentKey` is a newtype with redacted `Debug`/`Display` (like `Credentials` and `mask()` [repo]) and is zeroized on drop. It is never serialized into prefs, layouts or the demo JSON. Signed request bodies are not logged; logs carry `cloid`, `oid`, asset and status only. Unit test: a `tracing` capture of a full place/cancel cycle contains no 64-hex-digit secrets. |
| Rate limits | Address-based limit: 1 request per 1 USDC traded since inception, plus an initial buffer of 10,000; when limited, 1 request every 10 s [doc: RL]. A human-driven terminal stays far below this. Terminal reads `userRateLimit` [doc: INFO] and surfaces it in Settings, and backs off quietly on 429s, as the SnapTrade capabilities code does [repo]. |
---
## 9. Positions, orders, fills and PnL from the user streams
The client connects **directly** to `wss://api.hyperliquid.xyz/ws` (testnet: `wss://api.hyperliquid-testnet.xyz/ws`) [doc: WS] for the user's own data, not through `tr-gateway`. That keeps user addresses off our server and fits the 10-users-per-IP limit [doc: RL]. The REST endpoints answer CORS preflight with `access-control-allow-origin: *` on mainnet and testnet [observed], so the web build can post `/exchange` and `/info` directly.
| Subscription [doc: WSSUB] | Feeds |
|---|---|
| `clearinghouseState {user, dex}` per used dex, or `allDexsClearinghouseState {user}` | `POS` rows (szi, entryPx, positionValue, unrealizedPnl, returnOnEquity, liquidationPx, marginUsed, cumFunding, leverage), `ACCT` (marginSummary, crossMarginSummary, withdrawable) [doc: PERP] |
| `spotState {user}` | spot balances (in unified/portfolio-margin modes, "all balances and holds" live in the spot clearinghouse state [doc: AAM]) |
| `openOrders {user, dex}` and `orderUpdates {user}` | `ORD` (status, limitPx, sz, origSz, oid, cloid) |
| `userFills {user}` | `ACTV` fills, realized PnL (`closedPnl`), fees (`fee`, which includes `builderFee`) [doc: INFO] |
| `userEvents {user}` (channel `user`) | fills, funding, liquidations, `nonUserCancel`: toasts |
| `userFundings`, `userNonFundingLedgerUpdates` | funding and deposit/transfer history in `ACTV` |
| `activeAssetData {user, coin}` | the ticket: leverage, max trade sizes, available to trade (perps only) |
| `webData3 {user}` | optional aggregate. The current docs list `webData3`, not `webData2`, and warn that undocumented fields "will be removed" [doc: WSSUB]. Terminal uses the typed streams above. |
| `notification {user}` | toasts |
The streaming endpoints send a snapshot first with `isSnapshot: true` [doc: WSSUB]. After a reconnect, Terminal replaces state from the snapshot and dedupes fills by `(time, coin, tid)` (the docs give that as the globally unique trade id [doc: WSSUB]).
**Model mapping.** A new `tr-hl` adapter produces the existing `tr_broker::Portfolio`:
- `Account { institution: "Hyperliquid", kind: "Perps" | "Spot", is_paper: network == Testnet, number: masked address }`.
- `Position { kind: Future | Crypto, units: szi (signed), cost_basis: entryPx, price: mark }`.
So `PORT`/`POS`/`ORD`/`ACTV` render HL with modest UI change, **but the market link needs real work**:
- Today `tr_broker::market_key()` returns `None` for `InstrumentKind::Future` [repo: `crates/broker/src/model.rs`].
- For `Crypto` it builds a spot-style pair (`BTC` → `BTCUSD`), not a Hyperliquid perp key [repo].
- `PortfolioPanes::live_price` looks quotes up only by that key [repo: `app/src/app/broker.rs`].
- The position-row Buy/Sell/Close controls are limited to `Stock | Etf` [repo: `app/src/app/portfolio_view.rs`].
As modelled above, an HL perp row would have no live repricing, no chart link and no trade buttons. The integration therefore adds:
1. **A venue-native market key on `Position`.** A new optional, serde-defaulted field (`market: Option<String>`) that the HL adapter fills with the exact key the gateway publishes in `MarketInfo.key` (`xyz:AAPL` for HIP-3, the core-perp and spot keys as `tr-proto` names them) [repo]. `Position::market_key()` returns it before falling back to today's kind-based mapping, so SnapTrade and demo rows are unchanged. HIP-3 keys resolve through `Security::perp` (`AssetClass::Perp`) [repo: `crates/core/src/security.rs`], never through the stock path.
2. **Repricing from that key, kept separate per network.** Mainnet HL rows reprice through today's path: `live_price` reads the shared quote map, which `broker_periodic` fills from the gateway [repo]. Testnet rows **must not** use that map. Testnet and mainnet use the same market keys (`xyz:AAPL`), so either network's price could overwrite the other's. The plan:
- a separate `testnet_quotes` map owned by the HL adapter, filled only from testnet `allMids` (per dex);
- `live_price` takes the account's network (from `Account.is_paper` / the HL account's network) and reads only the matching map;
- `broker_periodic` requests gateway quotes only for keys from mainnet accounts (and SnapTrade/demo), never for testnet rows;
- the ticket's reference price, fee and liquidation estimates use the same network-selected source (§6.2);
- tests feed interleaved mainnet and testnet updates for the same key and assert each row and ticket reads only its own network.
3. **Row actions.** The `tradable` check in `portfolio_view.rs` also allows rows from an enabled HL account that have a `market`. Close sends a reduce-only order (§6.1), through the same confirmation (§6.3). Chart and key menus use `market`.
4. **Tests.** Unit tests for `market_key()` precedence (an HL row with `market` set, a SnapTrade `Future` row still `None`), and `live_price` tests with perp quotes on both networks.
Unrealized PnL is then repriced from the gateway's live marks on mainnet, or testnet mids on testnet, as the panes already do for SnapTrade rows that have a key [repo]. Realized PnL = Σ `closedPnl` − Σ `fee` over the selected range, with funding shown separately.
---
## 10. Code layout (as built in v1; see §16)
- `crates/hl` (`tr-hl`): `signing` (§3.4), `actions` (order, cancel, cancelByCloid, modify, batchModify, updateLeverage, scheduleCancel, noop, approveAgent, approveBuilderFee, structs with explicit field order), `assets` (the registry, §5.1), `rounding` (§5.2), `client` (`/info` and `/exchange` over `reqwest`, which already compiles for wasm in this workspace [repo]), `stream` (user websocket), `nonce`. No `tokio` "full"; it follows `tr-broker`'s wasm split [repo].
- `tr-broker`: `secrets` gains a second store for agent keys (Keychain service `terminal.hyperliquid`). The existing SnapTrade item is untouched.
- `tr-broker` `model`: `Position.market: Option<String>` (serde default) and the `market_key()` precedence change from §9. `app/src/app/portfolio_view.rs`: the HL row actions. `app/src/app/broker.rs`: a network-aware `live_price` with a separate testnet quote map, and `broker_periodic` requesting gateway quotes only for mainnet keys (§9).
- `app`: `TradingPrefs.hl: HlPrefs { enabled, network, max_order_value, max_leverage, daily_notional_cap, slippage_bps, dead_mans_switch, agent: Option<AgentMeta> }`. `AgentMeta` holds the public address, name, expiry and network, never the key. New `TradeRequest::Hl(HlRequest)` and `TradeResponse::Hl(..)` variants in the worker. The ticket gets an HL variant, and Settings › Trading gets a Hyperliquid section.
- `tr-proto` / `tr-gateway`: **no change.** Market data keeps flowing as today. User data and orders never touch the server.
- Built-in wallets (§4.5): `crates/turnkey` (`tr-turnkey`: the session key and stamps, the Turnkey client, the desktop loopback callback, and a `mock` feature with a local stand-in for tests and screenshots); `app/src/app/wallet.rs` (sign-in, approval through Turnkey, sessions) and `wallet_view.rs` (the wallet section, Deposit, Back Up); `site/src/routes/wallet/` (the sign-in and export page, `@turnkey/core` and `@turnkey/iframe-stamper`).
---
## 11. Testing plan (testnet only, evidence-driven)
**No orders are placed in this design step**, on mainnet or testnet. From phase 1 on, every trading test runs on **testnet only**, until Micky signs off on §12 and phase 4.
1. **Unit (CI, offline):**
- Signing matches the Python SDK vectors (`test_phantom_agent_creation_matches_production`, `test_l1_action_signing_matches`, `…_order_matches`, `…_order_with_cloid_matches`, `…_matches_with_vault`, `…_tpsl_order_matches`, `test_schedule_cancel_action`, and the user-signed `test_sign_usd_transfer_action`; the SDK has no approve-agent vector, so we generate one with the Python SDK and pin it) [doc: SDK-TEST], natively and under `wasm-bindgen-test`.
- msgpack field order, rounding (§5.2) and asset ids from recorded `perpDexs`/`meta` fixtures.
- The no-secrets-in-logs test.
2. **Recorded fixtures:** JSON from testnet `/info` and websocket snapshots, so parser tests don't need the network. This follows server.md's fixture approach [repo].
3. **Testnet integration (manual and scripted, gated by an env flag, never in CI):**
- approve agent → place a $10–20 GTC limit far from the market on `BTC` and on testnet `xyz:AAPL` (750009) → confirm it shows in `ORD` → modify → cancel → kill switch with two resting orders → revoke agent and prove the old key is rejected;
- then one small IOC fill to see `userFills`, `POS` and PnL.
- Testnet lacks `para`, `io` and `mkts` [observed]; those dexes are covered by asset-id fixture tests only, until mainnet.
4. **Evidence for each PR:**
- **before-and-after screenshots** of the ticket, confirm, `POS`, `ORD`, `ACTV` and Settings › Trading, on desktop and web;
- the testnet `oid`s and the explorer links for each action;
- the list of commands run. No keys, addresses in full, IPs or `.env` contents appear in PRs or logs.
5. **Browser:** the web build signs on testnet from MetaMask and from Rabby (approve agent), and from the WASM agent key (orders).
6. **Failure drills:** kill the network mid-order (an uncertain result reconciled by `cloid`), let `expiresAfter` lapse, hit the max-notional guard, use an expired agent, and run the dead man's switch firing.
**Testnet funding caveat:** the faucet gives 1,000 mock USDC only to addresses that "have deposited on mainnet with the same address" [doc: FAUCET]. For an Ontario resident, that mainnet deposit is itself a ToS question (§12, Q2).
---
## 12. Legal and Terms of Use (needs Micky)
- **Terms of Use, last updated June 15, 2026, §1.6:** "Restricted Persons include: (a) persons or entities who reside in, are located in, are incorporated in, or have a registered office in the United States of America or **Ontario, Canada**; (b) … jurisdictions subject to applicable economic and trade sanctions …; and (c) citizens of Restricted Territories" [doc: TOU].
- **§3.1.5** prohibits circumvention: "using technologies such as VPNs, proxies, or other methods to conceal your location" or "making false statements … about your residency" [doc: TOU].
- **§1.9:** the user is responsible for compliance, "including … laws governing leveraged or derivative trading" [doc: TOU].
- **Scope:** the Terms govern Hyperliquid Corp.'s website Interface at `app.hyperliquid.xyz`. They say the Interface "is not the exclusive means of accessing Hyperliquid" (§1.1) [doc: TOU]. They don't directly address third-party clients or the API. **That is not a green light.** Building a client that lets Ontario or US residents trade where the official interface may not is exactly what needs legal review. The design must not geo-dodge, hide location or tell users how to get around restrictions.
- **Micky and Goshen Labs are in Toronto.** Micky personally trading on mainnet, and Goshen shipping order entry to Ontario or US users, are separate questions, and both need advice. Collecting builder fees (§13) could make Terminal look like it is in the business of trading, which matters under Ontario securities law [inferred; not legal advice].
- **Proposed product guardrails, whatever the outcome:**
- Hyperliquid trading off by default (§8);
- a jurisdiction attestation in the enable dialog ("I am not a Restricted Person under Hyperliquid's Terms"), with a link to the Terms;
- no circumvention guidance;
- an option to compile HL trading out of a build entirely (`hl-trading` cargo feature) if counsel says so.
---
## 13. Builder codes (monetisation option) [doc: BUILDER, EXCH]
- An order may carry `builder: {b: address, f: tenths of a bp}`. For example, `f: 10` = 1 bp. Up to 5 sub-builders can share the fee.
- Caps: 0.1% on perps and 1% on spot. Builder fees only apply to fees collected in the quote or collateral asset, so not to the buying side of spot.
- The user must first sign `approveBuilderFee {maxFeeRate, builder}` with the **main wallet**, not an agent, and can revoke. A user can have at most 10 active approvals.
- The builder address needs at least 100 USDC in perps account value and must be in standard abstraction mode to accrue fees [doc: AAM].
- Fees are claimed through referral rewards. Per-day builder fills are published at `stats-data.hyperliquid.xyz/Mainnet/builder_fills/{address}/{YYYYMMDD}.csv.lz4`, with the address lowercase.
- **Design:** the builder code is off in v1 (`f` absent). If Micky wants it, it is a separate opt-in screen showing the rate (e.g. 1–5 bp) before the main wallet signs, and the confirm always shows the builder fee. It is subject to §12 (Q1).
---
## 14. Phased milestones (small PRs, each proving one thing)
| Phase | Scope | Exit evidence |
|---|---|---|
| 0 | This doc, reviewed. | Micky's answers to §15 |
| 1 | `tr-hl` signing, actions, assets and rounding, plus SDK vector tests (native + wasm). Read-only: connect an address, then `POS`/`ORD`/`ACTV`/`ACCT` from the user streams (testnet and mainnet reads), including the `Position.market` key so perp rows reprice live and link to charts (§9). | CI green, screenshots of the read-only panes |
| 2 | Agent lifecycle on **testnet**: generate, Keychain/IndexedDB storage, approve (web wallet + desktop handoff), expiry warning, revoke. | testnet `extraAgents` before and after; a revoked key is rejected |
| 3 | Testnet order entry: limit/market(IOC)/stop/TP, reduce-only, modify, cancel, kill switch, dead man's switch, max-notional and leverage guards, confirm modal, HIP-3 labelling. | §11 drills, before-and-after screenshots, testnet oids |
| 4 | Mainnet behind `hl-mainnet` + acknowledgement, **only after §12 is resolved**. Tiny-size canary trades by Micky personally. | Legal sign-off recorded in this doc |
| 5 | Optional: builder code, brackets (`normalTpsl`), spot↔perp `usdClassTransfer`, Linux Secret Service. | per feature |
---
## 15. Open questions for Micky
**Decided (2026-11-04).** Micky: built-in wallets only. Users never connect MetaMask or any external wallet, and never paste a private key. As designed in §4.5:
1. Each user gets a Turnkey sub-organization with one Ethereum wallet, created at first sign-in. Its address is their Hyperliquid account.
2. Sign-in happens in the system browser on `terminal.rasmic.xyz/wallet` (email code or passkey through Turnkey's Auth Proxy). The page returns a session bound to a key made on the device: a loopback redirect to `127.0.0.1` on the desktop, `postMessage` on the web.
3. The built-in wallet signs only `approveAgent` (approve, renew, revoke), named `terminal-<device>`, valid 180 days, never unnamed and never a reused address. `withdraw3` and `usdSend` are not built.
4. The agent key is made on the device and kept as decided on 2026-11-02 (items 3 and 4 below). It signs every L1 action.
5. No Terminal server holds a Turnkey credential: the Auth Proxy signs sub-organization creation with a key Turnkey keeps. `tr-gateway` stays data-only. A fallback signup function with a narrowly scoped key is designed in §4.5 but not built.
6. Turnkey sessions last 15 minutes and are stored like the agent key (Keychain, Secret Service, browser memory).
7. Deposit shows the address, a QR code and the testnet balances. Backup goes through Turnkey's export iframe on the sign-in page.
8. The web's EIP-1193 wallet and the desktop's pasted key are removed. "Watch an address" (read-only) stays.
9. Everything decided on 2026-11-02 still holds (caps, confirmations, KILL, the opt-in dead man's switch, testnet only), except that agents are now valid 180 days by default instead of 30 (item 3), because each approval is a paid Turnkey signature.
**Decided (2026-11-02).** Micky: "Build it on testnet now, we'll sort the legal review before mainnet." Defaults for v1:
1. Signing on the device only (Option A). `tr-gateway` stays data-only; no server change.
2. A named API wallet, `terminal-<device> valid_until <ms>`, valid 30 days by default (180 at most; 180 by default since 2026-11-04). Never the unnamed slot, never a reused agent address.
3. Desktop keys in the macOS Keychain; on Linux the Secret Service with no file fallback (no keyring means no trading, and Settings says why). Windows: no store in this build, so trading stays off there.
4. Web keys for the session only (memory), with an opt-in "remember on this browser" that encrypts with a non-extractable WebCrypto AES-GCM key kept in IndexedDB.
5. Perps only: the core dex and the HIP-3 `xyz` dex. No spot; `para`, `io` and `mkts` are refused at the ticket with a reason.
6. Hard caps enforced in `tr-hl` before signing, not raisable in this build: $100 per order, 3× leverage, 1% market slippage.
7. The dead man's switch is opt-in, off by default.
8. One confirmation for every order, modify, cancel-all and leverage change, plus the kill switch (`KILL` and a button in `ORD`).
9. No builder fee: the `builder` field is never sent.
10. Hyperliquid sits inside LIVE next to the SnapTrade accounts, with an amber TESTNET badge on the account, the ticket, the confirmation and the toolbar.
11. Asset ids come from the API per network (`perpDexs` and `meta` per dex), never hard-coded.
12. A separate testnet quote map: testnet rows and tickets never read the gateway's mainnet quotes, and the reverse.
The questions as first asked:
1. **Legal / ToS (blocking for mainnet):** Hyperliquid's Terms restrict persons in Ontario (§12). Who reviews (a) you personally trading, and (b) Goshen Labs shipping HL order entry to users in Ontario and the US? Until then, is a testnet-only build OK to develop and demo?
2. **Testnet funds:** the faucet requires a prior mainnet deposit from the same address. Do you already have a mainnet-funded address you're comfortable using for testnet, or should we find another source of testnet USDC (e.g. a teammate outside restricted regions sending testnet USDC) [unverified that this works]?
3. **Custody:** OK with Option A (local API wallet, main wallet signs once, server never signs)? Or do you want Option B for a future hosted or mobile product?
4. **Web key storage:** default to session-only agent keys on the web (re-approve each visit), with an opt-in encrypted IndexedDB key? Or remember by default?
5. **Agent expiry:** 30 days by default (max 180)?
6. **Linux:** require Secret Service (libsecret) for the agent key, or allow today's owner-only file fallback with a warning?
7. **Dead man's switch:** on by default while trading is enabled (cancels *all* the account's open orders if Terminal disappears, including ones placed elsewhere), or opt-in?
8. **Limits:** defaults of $100 max order notional on testnet, 3× max leverage, 1% market slippage. What should the mainnet defaults be, and should the cap be hard (not editable above a ceiling)?
9. **"alethea":** the only `alethea` crate on crates.io is a templating library. Did you mean a different signing crate? (The recommendation is our own small module on alloy, §3.5.)
10. **Builder fees:** monetise with a builder code (e.g. 1–5 bp on perps, opt-in, always shown in the confirm), or keep Terminal fee-free? This depends on Q1, since fees may change the regulatory picture.
11. **Scope of "stocks":** HIP-3 perps on `xyz` only for v1, or also `para`/`io`/`mkts` (which testnet doesn't have, so they'd launch untested end to end)?
12. **Spot:** include HL spot (e.g. `HYPE/USDC`) in v1, or perps only?
13. **One account mode or two?** Should the toolbar badge become `DEMO` / `LIVE` / `HL` (three modes), or should HL accounts sit inside `LIVE` next to SnapTrade accounts, with the network badge on each?
---
## References
- **API**: API overview (SDK links, testnet URL). https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api
- **EXCH**: Exchange endpoint (order, cancel, cancelByCloid, scheduleCancel, modify, batchModify, updateLeverage, approveAgent, approveBuilderFee, noop, expiresAfter). https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint
- **SIGN**: Signing. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/signing
- **NONCE**: Nonces and API wallets. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets
- **ASSET**: Asset IDs. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids
- **TICK**: Tick and lot size. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size
- **RL**: Rate limits and user limits. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits
- **INFO**: Info endpoint (userRole, userRateLimit, maxBuilderFee, fills). https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint
- **PERP**: Info endpoint, perpetuals (perpDexs, meta, clearinghouseState). https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals
- **WS**: Websocket. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket
- **WSSUB**: Websocket subscriptions. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions
- **WSPOST**: Websocket post requests. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/post-requests
- **BUILDER**: Builder codes. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/builder-codes
- **AAM**: Account abstraction modes. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/account-abstraction-modes
- **HIP3**: HIP-3 builder-deployed perpetuals. https://hyperliquid.gitbook.io/hyperliquid-docs/hyperliquid-improvement-proposals-hips/hip-3-builder-deployed-perpetuals
- **FAUCET**: Testnet faucet. https://hyperliquid.gitbook.io/hyperliquid-docs/onboarding/testnet-faucet
- **TOU**: Hyperliquid Terms of Use (last updated June 15, 2026). https://app.hyperliquid.xyz/terms
- **SDK-SIGN**: Python SDK `hyperliquid/utils/signing.py`. https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/hyperliquid/utils/signing.py
- **SDK-EXCH**: Python SDK `hyperliquid/exchange.py` (`market_open`, `DEFAULT_SLIPPAGE`, `approve_agent`). https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/hyperliquid/exchange.py
- **SDK-INFO**: Python SDK `hyperliquid/info.py` (`extra_agents`). https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/hyperliquid/info.py
- **SDK-TEST**: Python SDK `tests/signing_test.py`. https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/tests/signing_test.py
- Rust SDKs: https://github.com/hyperliquid-dex/hyperliquid-rust-sdk, https://github.com/infinitefield/hypersdk
Turnkey (read 2026-11-04):
- **tk: AUTHPROXY**: Auth Proxy. https://docs.turnkey.com/features/authentication/auth-proxy and https://docs.turnkey.com/api-reference/auth-proxy
- **tk: ORGS**: Organizations (parent read access, no write access). https://docs.turnkey.com/features/organizations
- **tk: WAAS**: Embedded wallets as a service. https://docs.turnkey.com/solutions/embedded-wallets/embedded-waas
- **tk: SUBORG**: Create sub-organization. https://docs.turnkey.com/api-reference/activities/create-sub-organization
- **tk: SESSIONS**: Sessions ("The session JWT is only metadata"). https://docs.turnkey.com/features/authentication/sessions/overview
- **tk: POLICIES**: Policies and the root quorum. https://docs.turnkey.com/features/policies/overview
- **tk: SIGN**: Sign raw payload. https://docs.turnkey.com/api-reference/activities/sign-raw-payload
- **tk: EXPORT**: Export wallets. https://docs.turnkey.com/features/wallets/export-wallets
- **tk: PRICING**: Pricing. https://www.turnkey.com/pricing
- **tk: SDK-CORE**: `@turnkey/core` 2.14.0 (`refreshSession`, `stampLogin`). https://www.npmjs.com/package/@turnkey/core
- Rust SDK 0.17.1 (`turnkey_api_key_stamper`, `turnkey_client`). https://github.com/tkhq/rust-sdk; `alloy-signer-turnkey` 2.5.0. https://github.com/alloy-rs/alloy
### Observations log (read-only, 2026-11-02 ~23:30 UTC)
- `POST /info {"type":"perpDexs"}` mainnet: index 1 `xyz`, 8 `para`, 9 `mkts`, 10 `io` (11 entries). Testnet: 277 entries, `xyz` at 65, no `para`/`io`/`mkts`.
- `meta {dex:"xyz"}`: mainnet 142 assets, `xyz:AAPL` index 9 (`szDecimals` 3, `maxLeverage` 20), collateral token 0. Testnet 70 assets, `xyz:AAPL` index 9.
- `OPTIONS /exchange` with `Origin: https://terminal.rasmic.xyz`: mainnet and testnet both return `access-control-allow-origin: *`.
- Signing spike: alloy-signer-local 1.8.3 + alloy-sol-types + rmp-serde. Matches SDK vector r/s/v on mainnet and testnet; `cargo build --target wasm32-unknown-unknown` OK.
- `hypersdk` 0.3.0 with `default-features = false` on wasm32: 5 compile errors in `hypersdk` itself.
---
## 16. v1 as built (testnet)
- **`crates/hl` (`tr-hl`):** `network` (testnet only), `key` (`AgentKey`, `MainKey`: redacted, zeroized on drop), `signing` (k256 directly, with `alloy-primitives` and `alloy-sol-types`; no alloy signer crates, which pull in the RPC stack), `actions`, `assets`, `rounding`, `caps`, `nonce`, `trader` (the only way to an L1 signature), `client` (reqwest, native and wasm), `model`, `stream` (subscriptions and a reducer; the socket is the app's). It builds for wasm32.
- **`tr-broker`:** `AgentKeyStore` (Keychain, Secret Service or memory; never a file), and `market: Option<String>` on `Position`, `Order` and `Activity`, which `market_key()` prefers.
- **App:** `app/src/hl_worker.rs` (the user stream, reads, signing after the re-check, the key store off the UI thread), `app/src/app/hl.rs` (state, flows, rows, testnet quotes), `hl_view.rs` (ticket body, confirmations, Settings), `hl_web.rs` (EIP-1193 wallet, IndexedDB key). The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit (GTC, IOC, post-only), stop loss and take profit (reduce-only market triggers), reduce-only and "Set 3×". A dollar amount (`BUY xyz:AAPL $50`) becomes a size at the testnet mid, rounded down to the market's size step. Until the mid is in, or when the amount is under one step, the ticket can't send it.
- **Checked on testnet** with throwaway keys and no funds: every signature recovers its signer, and a corrupted one recovers someone else. `approveAgent` needs a deposit first ("Must deposit before performing actions"). The PR that added this build lists the responses.
- **Built-in wallets (2026-11-04, §4.5):** `tr-turnkey` and the sign-in page. Checked against a local Turnkey stand-in only (`tr_turnkey::mock`), because there is no Turnkey organization yet: the desktop sign-in through the loopback, stamps, the wallet's address, and `approveAgent` signed through the client, which reproduces the Python SDK's vector when the stand-in's wallet holds the SDK's test key. Live Turnkey tests are `#[ignore]`d and run with `TURNKEY_ORGANIZATION_ID`, `TURNKEY_API_PRIVATE_KEY` and `TURNKEY_SIGN_WITH`.
- **Not done in v1:** modify is in `tr-hl` and the worker but has no `ORD` control yet; brackets (`normalTpsl`), "and flatten" on KILL, the desktop browser handoff, spot, other HIP-3 dexes, the daily notional cap, and the expiry warning's one-click renew. Resting orders, fills, positions and P&L can only be checked once the testnet account has test USDC.
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more