Compare commits

...
16 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
Michael ShimelesandClaude Opus 5.5 7d999a8e19 Rename the project from terminal-rs to terminal (#27)
* chore: rename the project from terminal-rs to terminal

The crate and binary are now `terminal`, and user-facing text, docs, CI,
scripts and repo URLs drop the -rs.

Saved data follows the new name:
- The desktop data folder moves from terminal-rs to terminal on first
  launch, and a link at the old path keeps older builds working.
- The browser build copies localStorage keys from terminal-rs/ to
  terminal/ once and never overwrites newer keys.
- SnapTrade credentials move from Keychain service terminal-rs to
  terminal the first time the app reads them.

The bundle ID (dev.terminal-rs.app), the Sparkle signing key's keychain
account and the notary profile keep the old name, because released
copies and the release Mac depend on them.

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

* docs: add screenshots for the rename PR (removed in the next commit)

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

* docs: drop the PR screenshots so they stay out of master

The PR body links them at the previous commit.

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

* fix(storage): keep fallback saves and the old-folder link working

A browser tab that couldn't copy its keys keeps the old prefix. If
another tab later finishes the copy, that tab's saves went to keys the
next load skips. Its paths under terminal-rs/ now map to terminal/ as
soon as the copy's marker exists.

The link left at the old folder repeated `new` as given, so a relative
HOME or XDG_DATA_HOME made it dangle. It now names its sibling.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 10:47:58 -04:00
Michael ShimelesandClaude Opus 5.5 fa1b3c8886 fix(web): cache the app's wasm for good and serve /app/ without a redirect chain (#26)
* fix(web): cache the app's wasm for good and serve /app/ without a redirect chain

scripts/build-web.sh now writes each build's JS and wasm to
static/app/immutable/<hash>/, named after a hash of those files, and
fills the paths into index.html. vercel.json caches /app/immutable/ for
a year and answers a missing file there with a 404 marked no-store.
index.html and the fonts keep Vercel's default of revalidating on every
visit, so a deploy can't leave a page with one build's JS and another
build's wasm.

/app/ used to take two 308s through the SvelteKit function to reach the
page (/app/ to ../app to /app/index.html). vercel.json now sends /app
to /app/ with one 308 and serves /app/ from index.html, so the /app
endpoint is gone. Relative URLs in the page still resolve under /app/.

Both workflows move off the Node 20 majors: actions/checkout@v7,
upload-artifact@v7, download-artifact@v8 and setup-node@v7 run on
Node 24.

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

* fix(web): fall back to shasum where sha256sum is missing

Older macOS releases ship shasum but not sha256sum, so build-web.sh
stopped with exit 127 before writing index.html. Both commands give the
same digest, so the folder names don't change.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 09:07:17 -04:00
Michael ShimelesandClaude Opus 5.5 bf1dd94fd5 ci(web): deploy the site with the browser build from GitHub Actions (#24)
Vercel's build doesn't run Rust, so a deploy from Vercel's Git integration
has no /app. The new deploy-site workflow runs scripts/build-web.sh, then
vercel build and vercel deploy --prebuilt. Pushes to master deploy to
production, and pull requests from this repository get a preview.
site/vercel.json turns off Vercel's Git deployments so each commit deploys
once.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 07:47:03 -04:00
Michael ShimelesandClaude Opus 5.5 728179ae66 feat(web): run the terminal in the browser with WebAssembly and WebGPU (#23)
* feat(web): build the terminal for the browser with WebAssembly

Compile the app for wasm32-unknown-unknown and run it with eframe's web
runner on WebGPU, with a separate WebGL2 build for browsers without it.
Desktop-only pieces sit behind cfg(target_arch = "wasm32") seams: a
tokio/browser task shim, a websocket wrapper, localStorage in place of
files, and stand-ins for SQLite, Congress ingest and SnapTrade.

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

* perf(web): WebGPU-only main build, emoji fonts after first frame

The default browser build drops wgpu's WebGL2 path (naga, wgpu-core,
wgpu-hal), and the page loads a second build with it only when there is
no WebGPU adapter. egui's emoji fonts now load after the first frame
instead of shipping inside the .wasm. Together that takes the WebGPU
bundle from 2.64 MB to 1.69 MB brotli.

Also: label the browser's mixed data source as "Crypto live", tell the
Congress panes the data needs the desktop app, autosave every 2 s in the
browser, and redirect /app to the build on the site.

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

* docs(readme): state browser timings as ranges across runs

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

* docs(readme): use the Retina window run for browser timings

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

* docs: browser build screenshots

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

* docs(readme): final-build throttled timing

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

* docs(readme): rewrap browser timings

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

* fix(web): real downloads for exports, one wasm download, keep /app flags

- Layout and theme exports download a file in the browser instead of
  writing a hidden localStorage key. Imports and "open folder" say they
  need the desktop app for now.
- The page starts the WebGPU wasm with an abortable fetch and cancels it
  when there is no WebGPU adapter, so only the WebGL2 build downloads.
- /app keeps its query string when it redirects to /app/index.html.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 07:47:02 -04:00
Michael ShimelesandCursor 2ae060d2d0 fix(macos): ship as Terminal.dmg with the logo as its volume icon (#22)
* fix(macos): ship as Terminal.dmg with the logo as its volume icon

Bumps to 0.1.1 so the next release replaces the v0.1.0 DMG, which predates the Terminal name and icon, and renames the remaining user-facing terminal-rs strings.

Co-authored-by: Cursor <[email protected]>

* fix(macos): detach the DMG's writable image if a release build fails while it's mounted

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-10-20 22:16:52 -04:00
208 changed files with 29568 additions and 512 deletions

No files matched your search

+15 -8
View File
@@ -22,7 +22,7 @@ jobs:
arch: x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
@@ -40,9 +40,9 @@ jobs:
TR_PROVIDER: sample
TR_CONGRESS_REFRESH_MIN: "0"
TR_SMOKE_SECS: "8"
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs
TR_DATA_DIR: ${{ runner.temp }}/terminal
run: |
./target/release/terminal-rs > smoke.log 2>&1 &
./target/release/terminal > smoke.log 2>&1 &
pid=$!
sleep 6
screencapture -x screenshot-${{ matrix.arch }}.png || true
@@ -56,9 +56,9 @@ jobs:
env:
TR_SMOKE_SECS: "12"
TR_CONGRESS_REFRESH_MIN: "0"
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs-live
TR_DATA_DIR: ${{ runner.temp }}/terminal-live
run: |
./target/release/terminal-rs > live.log 2>&1 &
./target/release/terminal > live.log 2>&1 &
pid=$!
sleep 10
screencapture -x screenshot-live-${{ matrix.arch }}.png || true
@@ -67,7 +67,7 @@ jobs:
grep -q "smoke:" live.log
- name: Bundle .app
run: ./scripts/bundle-macos.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: always()
with:
name: macos-${{ matrix.arch }}
@@ -80,19 +80,26 @@ jobs:
linux:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- 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
TR_CONGRESS_REFRESH_MIN: "0"
TR_SMOKE_SECS: "6"
run: xvfb-run -a ./target/release/terminal-rs 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
run: xvfb-run -a ./target/release/terminal 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
+128
View File
@@ -0,0 +1,128 @@
name: deploy site
# Builds the browser app with scripts/build-web.sh and deploys the site with it to Vercel:
# production on every push to master, a preview for each pull request. Vercel's own Git
# deployments are off (site/vercel.json) because Vercel's build doesn't run Rust.
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
# Production runs queue instead of cancelling each other, so the newest commit always deploys
# last. A new push to a pull request cancels the preview run for the old one.
concurrency:
group: deploy-site-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
wasm:
name: Build the browser app
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
# Pull requests build the branch head, like Vercel's Git previews did, not the merge commit.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Read the wasm-bindgen version from Cargo.lock
id: lock
run: echo "wasm-bindgen=$(awk '/^name = "wasm-bindgen"$/ { getline; gsub(/version = |"/, ""); print; exit }' Cargo.lock)" >> "$GITHUB_OUTPUT"
- uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen@${{ steps.lock.outputs.wasm-bindgen }}
- name: Install wasm-opt (binaryen)
env:
BINARYEN: version_133
SHA256: 2dc9c7813f5375db93d96ead4b78222fcc3e2677bbb832297af4797782a37489
run: |
curl -sSfLo "$RUNNER_TEMP/binaryen.tar.gz" "https://github.com/WebAssembly/binaryen/releases/download/$BINARYEN/binaryen-$BINARYEN-x86_64-linux.tar.gz"
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"
# 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
path: site/static/app
if-no-files-found: error
retention-days: 7
deploy:
name: Deploy to Vercel
needs: wasm
# Pull requests from forks get no secrets, so they stop after the build.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
environment:
name: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
url: ${{ steps.deploy.outputs.url }}
env:
TARGET: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Check the Vercel secrets
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
missing=0
for name in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_PROJECT_ID; do
if [ -z "${!name}" ]; then
echo "::error::The $name repository secret is not set (see site/README.md)"
missing=1
fi
done
exit $missing
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/download-artifact@v8
with:
name: web-app
path: site/static/app
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: site/package-lock.json
- run: npm install --global [email protected]
# Run from the repository root, because the Vercel project's Root Directory is site/.
- name: Pull the project settings and environment variables
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: vercel pull --yes --environment="$TARGET" --token="$VERCEL_TOKEN"
# The build gets no token, since it runs the site's npm install and build scripts.
- name: Build
run: |
if [ "$TARGET" = production ]; then vercel build --prod; else vercel build; fi
test -f .vercel/output/static/app/index.html
- name: Deploy
id: deploy
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
if [ "$TARGET" = production ]; then
url=$(vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN")
else
url=$(vercel deploy --prebuilt --token="$VERCEL_TOKEN")
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "Deployed $TARGET: $url ($url/app)" >> "$GITHUB_STEP_SUMMARY"
Generated
+2410 -36
View File
File diff suppressed because it is too large. Load diff
+15 -2
View File
@@ -1,20 +1,23 @@
[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]
version = "0.1.0"
version = "0.1.1"
edition = "2024"
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"] }
@@ -36,3 +39,13 @@ debug = 1
[profile.dev.package."*"]
opt-level = 2
# The browser build (scripts/build-web.sh): one fully optimised unit, no debug info, so the
# .wasm is as small as it gets before wasm-opt.
[profile.web]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
debug = false
strip = true
+74 -10
View File
@@ -1,4 +1,4 @@
# terminal-rs
# Terminal
A fast, keyboard-driven market terminal in pure Rust: TradingView-class GPU charting with a
Bloomberg Terminal look (dense tiled panes, monospace numbers, a command line like `AAPL <GO>`)
@@ -9,7 +9,7 @@ systems** and **your own brokerage accounts through [SnapTrade](https://snaptrad
to Solarized, and a fully dockable
workspace with saved layouts.
![terminal-rs: AAPL chart with congress trade markers and RSI, the Congress trades feed, world markets and a multi-asset watchlist](docs/img/overview.png)
![Terminal: AAPL chart with congress trade markers and RSI, the Congress trades feed, world markets and a multi-asset watchlist](docs/img/overview.png)
> **Built from reverse-engineering research.** Chart UX was studied behaviourally on
> tradingview.com with [REA](https://github.com/morluto/rea) in website mode (passive captures of
@@ -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
@@ -122,7 +127,7 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
row in a linked watchlist or world-markets pane loads it into the group.
- **Saved layouts**: save, name, rename, duplicate, delete and switch (`LAYOUT <name>`, the
sidebar list, ⌥1…⌥9); the active layout autosaves and is restored on launch. Each layout is a
JSON file in `~/Library/Application Support/terminal-rs/layouts` (`$XDG_DATA_HOME` on Linux)
JSON file in `~/Library/Application Support/terminal/layouts` (`$XDG_DATA_HOME` on Linux)
and can be imported or exported (`LAYOUT EXPORT`/`IMPORT`, or Settings › Layouts). Per-pane
settings (chart type, indicators, drawings, filters, depth, link, tab title) and, optionally,
the theme (`LAYOUT THEME ON`) save with the layout.
@@ -181,16 +186,19 @@ TR_PROVIDER=sample cargo run --release # offline
APCA_API_KEY_ID=… APCA_API_SECRET_KEY=… cargo run --release # Alpaca
```
Data lives in `~/Library/Application Support/terminal-rs` (macOS) or
`$XDG_DATA_HOME/terminal-rs` (Linux), or `TR_DATA_DIR`. The first congress ingest covers the
current and previous year (a few minutes; it runs in the background while you work).
Data lives in `~/Library/Application Support/terminal` (macOS) or
`$XDG_DATA_HOME/terminal` (Linux), or `TR_DATA_DIR`. Versions from before the project was renamed
used a `terminal-rs` folder in the same place; the first launch moves it to `terminal` and leaves
a link at the old path, so builds that still use the old name find the same files. The first
congress ingest covers the current and previous year (a few minutes; it runs in the background
while you work).
`cargo run -p tr-congress --example ingest -- congress.sqlite3` runs one ingest from the CLI.
Appearance: `TR_THEME=light|dark|<theme name>` overrides the saved theme, `TR_MSAA=1|2|4|8` sets
chart anti-aliasing (default 4 on macOS), `TR_VIBRANCY=0` disables the translucent sidebar, and
⌃⌘S (Ctrl+Shift+S elsewhere) toggles the sidebar. `scripts/bundle-macos.sh` builds a retina-capable
`Terminal.app` (icon from `scripts/build-icon.sh`); `scripts/release-macos.sh` builds the universal (Apple Silicon + Intel) app,
signs it with a Developer ID certificate, notarizes it and packages `terminal-rs-universal.dmg`,
signs it with a Developer ID certificate, notarizes it and packages `Terminal.dmg`,
plus the zip and signed `appcast.xml` that installed copies update from (one-time credential
setup is at the top of the script). The app checks that feed once a day through
[Sparkle](https://sparkle-project.org) and offers to install a newer release; Terminal › Check
@@ -198,6 +206,57 @@ for Updates… or `UPDATE` checks at once. `scripts/check-macos-cross.sh` compil
and lints the whole workspace for aarch64-apple-darwin from Linux (zig as the C cross-compiler;
linking needs the macOS SDK).
## In the browser
The same code also builds for WebAssembly and runs in a browser tab on WebGPU:
```sh
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.129 --locked # must match Cargo.lock
brew install binaryen # wasm-opt
scripts/build-web.sh # writes site/static/app
cd site && npm run dev # then open http://localhost:5173/app/index.html
```
`scripts/build-web.sh <dir>` writes somewhere else instead; any static file server works. Add
`?sample` to the URL to run offline on sample data, or `?fps` for the frame-time readout.
Every push to `master` builds it and deploys it with the site to
[terminal.rasmic.xyz/app](https://terminal.rasmic.xyz/app/). Pull requests get a preview;
[site/README.md](site/README.md#deploys) covers the workflow and the secrets it needs.
![The browser build in Chrome: a live BTCUSD chart from Binance beside a demo-account position](docs/img/web-live-crypto.png)
What the browser build can and can't do yet:
- Crypto is live, because Binance and Coinbase allow cross-origin requests. History, quotes, order
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/`.
The first load copies anything saved under the old `terminal-rs/` prefix, without overwriting
newer entries.
- Charts need WebGPU, since they read bars from storage buffers in the vertex shader. Browsers
without a WebGPU adapter get a second, larger build on WebGL2 where everything except the chart
series works.
Measured with Playwright driving Chrome 155 on an Apple M4 Pro:
| | WebGPU build | WebGL2 build |
|---|---|---|
| `.wasm` | 5.60 MB (2.26 MB gzip, 1.69 MB brotli) | 7.48 MB (2.99 MB gzip, 2.22 MB brotli) |
| JS glue | 115 KB | 169 KB |
- A cold load from localhost draws its first frame 91 to 97 ms after navigation, the medians of
three runs of 9 loads, two headless and one in a Retina window. On a 50 Mbit/s link with 40 ms
of latency it takes 446 ms, and 1.64 s on 10 Mbit/s with 80 ms.
- Panning and zooming a chart costs 1.3 to 1.5 ms of CPU in the UI code and 2.4 to 3.3 ms for the
whole frame (medians across runs), and holds the display's 60 Hz.
- egui's two emoji fonts, 740 KB together, load after the first frame instead of inside the `.wasm`.
## Brokerage setup (SnapTrade)
Everything happens in **Settings › Brokerage**, with no commands needed. Open it from the sidebar
@@ -221,8 +280,9 @@ the portal, and **Disconnect** removes a connection and its accounts from SnapTr
explicit confirmation.
**Where credentials live.** On macOS the client ID, consumer key, user ID and user secret are
stored together in the **Keychain** (generic password, service `terminal-rs`, account
`snaptrade`). Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
stored together in the **Keychain** (generic password, service `terminal`, account
`snaptrade`); credentials saved under the old service, `terminal-rs`, move there the first time
the app reads them. Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
(0600 in a 0700 directory). They are never written to the repository, layouts, prefs or logs;
request errors are scrubbed of the URL (which carries the user secret), and the settings screen
masks secrets. **Forget Credentials** removes them. `SNAPTRADE_CLIENT_ID`,
@@ -327,10 +387,14 @@ app/ eframe shell: command line, docked panes, chart widget, panels
themes.rs theme model, built-in themes, TOML/JSON theme files and the themes folder
app/ workspace (drag and drop, pop-out windows, link groups), panels, settings, chrome
worker.rs tokio runtime: all network, SQLite, pyramid and indicator work (UI never blocks)
storage.rs saved files: the data folder on desktop, localStorage in the browser
web.rs browser entry point (eframe web runner), console logging, frame timings
web/ the page that hosts the browser build
crates/core Bar/Series, timeframes, market keys + security catalog, indicators and their
registry (codes, parameters, panes, levels), sample data
crates/data MarketData trait; per-class router (Binance/Coinbase, Treasury.gov/FRED, Yahoo,
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache;
the browser router (crypto live, the rest sampled) and a tokio/browser task shim
crates/chart wgpu renderer, LOD pyramids, viewport math, axes, drawings, egui paint callback
crates/congress Senate eFD + House Clerk parsers, rosters, SQLite store, refresh, alerts, performance
crates/broker SnapTrade client (request signing, accounts, balances, positions, orders, activity),
+53 -6
View File
@@ -1,13 +1,20 @@
[package]
name = "terminal-rs"
name = "terminal"
description = "Bloomberg-style trading terminal with GPU charts and congressional trade tracking"
version.workspace = true
edition.workspace = true
default-run = "terminal-rs"
default-run = "terminal"
[features]
# Browser build only: add wgpu's WebGL2 backend for browsers without WebGPU (about 1 MB more,
# and charts stay off there because WebGL2 has no storage buffers).
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
@@ -16,13 +23,53 @@ chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
futures = "0.3"
egui = "0.36"
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts", "wayland", "x11"] }
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"] }
tokio.workspace = true
tracing-subscriber.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
raw-window-handle = "0.6"
# The browser build (see `src/web.rs` and scripts/build-web.sh): WebGPU only, unless the
# `webgl` feature adds the WebGL2 fallback.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wgpu = { version = "30", default-features = false, features = ["webgpu", "wgsl"] }
egui-wgpu = { version = "0.36", default-features = false, features = ["fragile-send-sync-non-atomic-wasm"] }
# egui's text fonts only; the page fetches its emoji fonts after start-up (see theme::base_fonts).
epaint_default_fonts = "0.36"
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "registry", "std"] }
chrono = { workspace = true, features = ["wasmbind"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"Blob",
"Document",
"Element",
"HtmlAnchorElement",
"HtmlCanvasElement",
"Location",
"Performance",
"Response",
"Storage",
"Url",
"UrlSearchParams",
"Window",
"console",
] }
[dev-dependencies]
async-trait = "0.1"
tr-turnkey = { workspace = true, features = ["mock"] }
+190 -18
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,12 +20,14 @@ mod portfolio_view;
mod settings_view;
mod theming;
mod trading;
mod wallet;
mod wallet_view;
mod workspace;
pub use broker::PosSort;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use std::time::Duration;
use egui::{Color32, Event, Key, Modifiers, Rect, Ui, UiBuilder, pos2, vec2};
use serde::{Deserialize, Serialize};
@@ -28,6 +35,7 @@ use tr_chart::drawings::Tool;
use tr_congress::Trade;
use tr_congress::db::{Alert, PoliticianSummary};
use tr_data::{NewsItem, OrderBook, Quote, SearchHit, YieldCurves};
use web_time::Instant;
use crate::anim;
use crate::chart_view::ChartState;
@@ -44,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 {
@@ -154,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(),
@@ -463,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>,
@@ -513,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();
@@ -568,13 +630,14 @@ impl TerminalApp {
};
}
let settings: Option<Settings> =
std::fs::read(settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
crate::storage::read(&settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
let broker = broker::BrokerState::load(provider.eq_ignore_ascii_case("sample"));
let (demo, demo_note) = demo::DemoState::load(demo::demo_path());
let mut app = Self {
broker,
trading: Default::default(),
demo,
hl: hl::HlState::platform(),
worker,
provider,
panes: Vec::new(),
@@ -611,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(),
@@ -672,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);
@@ -681,6 +751,17 @@ impl TerminalApp {
app
}
/// Replace the status bar message.
pub fn set_status(&mut self, status: String) {
self.status = status;
}
/// Show the frame-time readout (as F12 does); the browser's `?fps` flag.
#[cfg(target_arch = "wasm32")]
pub fn show_perf(&mut self) {
self.show_perf = true;
}
fn save_settings(&self) {
let s = Settings {
watchlist: self.watchlist.clone(),
@@ -690,7 +771,7 @@ impl TerminalApp {
panes: None,
};
if let Ok(json) = serde_json::to_vec_pretty(&s)
&& let Err(e) = crate::layouts::write_atomic(&settings_path(), &json)
&& let Err(e) = crate::storage::write_atomic(&settings_path(), &json)
{
tracing::warn!("saving settings: {e}");
}
@@ -766,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);
@@ -920,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);
}
}
}
@@ -945,6 +1032,7 @@ impl TerminalApp {
prepended,
from_cache,
exhausted,
full,
live,
} => {
let worker = &self.worker;
@@ -952,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 } => {
@@ -980,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,
@@ -1040,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;
@@ -1054,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)
@@ -1073,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());
@@ -1086,6 +1195,9 @@ impl TerminalApp {
/// Freshness line for the Congress feed: last check, newest filing, next check.
fn congress_freshness(&self) -> String {
if cfg!(target_arch = "wasm32") {
return "desktop app only for now".into();
}
let newest = self.latest.iter().map(|t| t.filed_date).max();
let minutes = std::env::var("TR_CONGRESS_REFRESH_MIN")
.ok()
@@ -1273,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()),
}
}
@@ -1292,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,
@@ -1343,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 {
@@ -1365,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)
@@ -1384,7 +1517,9 @@ impl TerminalApp {
self.last_feed = Some(Instant::now());
}
self.save_dirty_themes(false);
if self.last_save.elapsed() > Duration::from_secs(10) {
// A browser tab can close without an exit callback, so save sooner there.
let every = Duration::from_secs(if cfg!(target_arch = "wasm32") { 2 } else { 10 });
if self.last_save.elapsed() > every {
self.autosave();
self.last_save = Instant::now();
}
@@ -1560,6 +1695,13 @@ impl eframe::App for TerminalApp {
}
let cpu = frame_start.elapsed().as_secs_f64() * 1e3;
self.perf_cpu_ms = self.perf_cpu_ms * 0.9 + cpu * 0.1;
#[cfg(target_arch = "wasm32")]
crate::web::record_frame(&crate::web::FrameStats {
ui_ms: self.perf_cpu_ms,
interval_ms: self.perf_dt_ms,
last_cpu_ms: frame.info().cpu_usage.map_or(0.0, |s| f64::from(s) * 1e3),
frames: ctx.cumulative_frame_nr(),
});
if self.show_perf {
// Measure continuously while the readout is visible.
ctx.request_repaint();
@@ -1583,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,
@@ -1601,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();
+89 -16
View File
@@ -3,9 +3,10 @@
//! refresh scheduling and the `PORT`/`ACCT`/`POS`/`ORD`/`ACTV` commands. Order entry is in
//! `trading`.
use std::time::{Duration, Instant};
use std::time::Duration;
use tr_broker::{Credentials, Portfolio, Position, SecretStore};
use web_time::Instant;
use super::{PaneKind, TerminalApp};
use crate::command::PortView;
@@ -100,9 +101,15 @@ pub struct BrokerState {
impl BrokerState {
pub fn load(offline: bool) -> Self {
let store = SecretStore::platform(&data_dir());
Self::with_store(SecretStore::platform(&data_dir()), offline)
}
/// [`Self::load`] with credentials from `store` instead of the platform's.
pub fn with_store(store: SecretStore, offline: bool) -> Self {
let mut message = None;
let stored = match store.load() {
// The browser build can't reach SnapTrade, and keys don't belong in page storage.
let stored = if cfg!(target_arch = "wasm32") { Ok(None) } else { store.load() };
let stored = match stored {
Ok(c) => c.unwrap_or_default(),
Err(e) => {
tracing::warn!("SnapTrade credentials unavailable: {e:#}");
@@ -112,7 +119,9 @@ impl BrokerState {
};
let mut creds = stored.clone();
let from_env = creds.apply_env(|k| std::env::var(k).ok());
let simulate = offline || std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
let simulate = offline
|| cfg!(target_arch = "wasm32")
|| std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
Self {
store,
saved: stored,
@@ -145,18 +154,31 @@ 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.
#[cfg(not(target_arch = "wasm32"))]
pub fn open_external(target: &str) -> std::io::Result<()> {
let opener = if cfg!(target_os = "macos") {
"open"
@@ -168,11 +190,23 @@ pub fn open_external(target: &str) -> std::io::Result<()> {
std::process::Command::new(opener).arg(target).spawn().map(|_| ())
}
/// Open a URL in a new browser tab.
#[cfg(target_arch = "wasm32")]
pub fn open_external(target: &str) -> std::io::Result<()> {
let window = web_sys::window().ok_or_else(|| std::io::Error::other("no browser window"))?;
window
.open_with_url_and_target_and_features(target, "_blank", "noopener")
.map(|_| ())
.map_err(|_| std::io::Error::other("the browser blocked the new tab"))
}
/// A stable, non-identifying SnapTrade user id for this installation.
pub fn new_user_id() -> String {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
let mix = nanos ^ (u128::from(std::process::id()) << 64);
format!("terminal-rs-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
let nanos = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
// Browsers have no process ids.
let pid = if cfg!(target_arch = "wasm32") { 0 } else { std::process::id() };
let mix = nanos ^ (u128::from(pid) << 64);
format!("terminal-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
}
impl TerminalApp {
@@ -207,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();
@@ -784,10 +821,46 @@ mod tests {
let a = new_user_id();
std::thread::sleep(Duration::from_millis(1));
let b = new_user_id();
assert!(a.starts_with("terminal-rs-") && a.len() == "terminal-rs-".len() + 16);
assert!(a.starts_with("terminal-") && a.len() == "terminal-".len() + 16);
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");
+97 -14
View File
@@ -1,13 +1,14 @@
//! Window chrome in the style of a SwiftUI macOS app: a unified toolbar holding the
//! command line, a translucent (vibrancy) sidebar, rounded pane cards and a status bar.
use std::time::{Duration, Instant};
use std::time::Duration;
use egui::{
Align2, Color32, CornerRadius, CursorIcon, Id, Key, Modifiers, Rect, RichText, Sense, Shadow, Stroke, StrokeKind,
Ui, UiBuilder, ViewportCommand, pos2, vec2,
};
use tr_data::SearchHit;
use web_time::Instant;
use super::{PaneKind, TerminalApp};
use crate::anim;
@@ -101,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 live = !self.provider.eq_ignore_ascii_case("sample");
let chip_text = p.layout_no_wrap(if live { "Live" } else { "Offline" }.into(), ui_font(11.0), muted());
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.
@@ -133,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();
@@ -281,11 +293,6 @@ impl TerminalApp {
}
}
fn provider_label(&self) -> String {
let n = self.provider.to_lowercase();
if n.contains("sample") { "Offline · sample data".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();
@@ -448,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
}
@@ -541,7 +548,7 @@ impl TerminalApp {
let head = Rect::from_min_size(rect.min, vec2(rect.width(), self.toolbar_h()));
window_drag_region(ui, head, "sidebar");
if !cfg!(target_os = "macos") {
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL-RS", mono(12.5), amber());
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL", mono(12.5), amber());
}
let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max);
@@ -559,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() };
@@ -587,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())));
@@ -647,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());
@@ -968,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));
}
}
+15 -4
View File
@@ -7,11 +7,13 @@
//! demo change is saved at once to `demo-account.json` in the app data folder.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::time::Duration;
use anyhow::Context;
use tr_broker::demo::{DemoBook, Event, STARTING_CASH};
use tr_broker::trading::OrderRequest;
use tr_broker::{InstrumentKind, Order, Portfolio};
use web_time::Instant;
use super::TerminalApp;
use crate::prefs::AccountMode;
@@ -49,11 +51,16 @@ impl DemoState {
/// account starts; the returned message says so.
pub fn load(path: PathBuf) -> (Self, Option<String>) {
let now = chrono::Utc::now();
let (book, note) = match DemoBook::load(&path, now) {
let saved = match crate::storage::read(&path) {
Ok(bytes) => DemoBook::from_json(&bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DemoBook::new(now)),
Err(e) => Err(anyhow::Error::new(e).context("reading the demo account")),
};
let (book, note) = match saved {
Ok(b) => (b, None),
Err(e) => {
let aside = path.with_extension(format!("damaged-{}.json", now.format("%Y%m%d%H%M%S")));
let moved = std::fs::rename(&path, &aside).is_ok();
let moved = crate::storage::rename(&path, &aside).is_ok();
tracing::warn!("demo account: {e:#}");
let note = if moved {
format!(
@@ -91,7 +98,11 @@ impl DemoState {
/// Save now. Returns whether the book is on disk.
pub fn save(&mut self) -> bool {
self.last_save_try = Some(Instant::now());
match self.book.save(&self.path) {
let saved = self
.book
.to_json()
.and_then(|json| crate::storage::write_atomic(&self.path, &json).context("saving the demo account"));
match saved {
Ok(()) => {
self.save_error = None;
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()];
+108 -16
View File
@@ -1,6 +1,6 @@
//! Pane content for every panel kind.
use std::time::Instant;
use web_time::Instant;
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, Ui, UiBuilder, pos2, vec2};
use tr_data::OrderBook;
@@ -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::*;
+49 -13
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()
@@ -454,7 +458,7 @@ fn setup_screen(ui: &mut Ui, b: &BrokerState, out: &mut Vec<PortAction>) {
ui.add_space(dp(4.0));
note(
ui,
"terminal-rs reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
"Terminal reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
unless you turn on trading in Settings › Trading.",
);
ui.add_space(dp(8.0));
@@ -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 {
@@ -1174,20 +1208,22 @@ mod tests {
#[test]
fn renders_every_view_without_panicking() {
let ctx = egui::Context::default();
let mut b = BrokerState::load(true);
// A store that holds nothing, so the test never reads (or moves) the user's Keychain item.
let none = std::env::temp_dir().join(format!("tr-portfolio-test-{}/snaptrade.json", std::process::id()));
let mut b = BrokerState::with_store(tr_broker::SecretStore::file(none), true);
b.creds = tr_broker::Credentials::default();
b.portfolio = Some(Box::new(tr_broker::sample::portfolio()));
let quotes = HashMap::new();
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();
}
@@ -1196,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();
}
@@ -1204,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();
}
+4 -6
View File
@@ -471,7 +471,7 @@ impl TerminalApp {
match self.theme_store.save(&self.themes[i]) {
Ok(path) => {
if let Some(f) = old_file.filter(|f| *f != path) {
let _ = std::fs::remove_file(f);
let _ = crate::storage::remove(&f);
}
self.themes[i].source = Some(path);
}
@@ -519,7 +519,7 @@ impl TerminalApp {
expand(given).with_extension(op)
};
self.status = match themes::ThemeStore::export(&self.themes[idx], &path) {
Ok(()) => format!("Exported {name:?} to {}", path.display()),
Ok(to) => format!("Exported {name:?} to {to}"),
Err(e) => format!("Export failed: {e:#}"),
};
}
@@ -551,7 +551,7 @@ impl TerminalApp {
}
"import" => {
let path = expand(self.theme_io.trim());
match themes::load_file(&path) {
match themes::import_file(&path) {
Ok(mut t) => {
t.name = themes::unique_name(&self.themes, &t.name);
t.source = None;
@@ -566,9 +566,7 @@ impl TerminalApp {
}
}
"folder" => {
let _ = std::fs::create_dir_all(&self.theme_store.dir);
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
if let Err(e) = std::process::Command::new(opener).arg(&self.theme_store.dir).spawn() {
if let Err(e) = crate::storage::reveal(&self.theme_store.dir) {
self.status = format!("Could not open {}: {e}", self.theme_store.dir.display());
}
}
+2 -1
View File
@@ -2,11 +2,12 @@
//! configured dark/light pair), cycle, the THEME command, copy-on-write edits of built-ins,
//! and debounced saving of edited custom themes.
use std::time::{Duration, Instant};
use std::time::Duration;
use super::TerminalApp;
use crate::prefs::{Density, UiFont};
use crate::themes::{self, SYSTEM, Theme};
use web_time::Instant;
impl TerminalApp {
/// Index of the theme currently in effect.
+86 -17
View File
@@ -23,7 +23,7 @@
//! quietly when SnapTrade rate-limits.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use std::time::Duration;
use egui::{Color32, RichText, Ui};
use serde::{Deserialize, Serialize};
@@ -31,6 +31,7 @@ use tr_broker::trading::{
self, Amount, Capability, OrderPreview, OrderRequest, OrderType, PlaceError, Side, TimeInForce,
};
use tr_broker::{Account, InstrumentKind, Order, demo};
use web_time::Instant;
use super::TerminalApp;
use super::broker::{BrokerMode, open_external};
@@ -80,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> {
@@ -115,6 +118,7 @@ impl Ticket {
placing: false,
error: None,
done: None,
hl: Default::default(),
}
}
@@ -126,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);
@@ -151,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;
@@ -302,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
@@ -322,7 +344,8 @@ impl TerminalApp {
if std::mem::replace(&mut self.trading.caps_loaded, true) {
return;
}
let cached: Option<CapsCache> = std::fs::read(caps_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
let cached: Option<CapsCache> =
crate::storage::read(&caps_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
if self.trading.caps.is_none() {
self.trading.caps = cached;
}
@@ -370,7 +393,7 @@ impl TerminalApp {
Ok(caps) => {
let cache = CapsCache { fetched: Some(chrono::Utc::now()), caps };
if let Ok(json) = serde_json::to_vec_pretty(&cache)
&& let Err(e) = crate::layouts::write_atomic(&caps_path(), &json)
&& let Err(e) = crate::storage::write_atomic(&caps_path(), &json)
{
tracing::warn!("saving trading access: {e}");
}
@@ -462,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()));
@@ -539,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()),
@@ -590,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,
@@ -745,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;
@@ -924,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)
@@ -943,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;
@@ -1607,8 +1664,8 @@ impl TerminalApp {
ui.add_space(dp(4.0));
for line in [
"Orders you confirm are sent to your real brokerage account through SnapTrade and can execute immediately with real money.",
"Market orders fill at the prevailing price, which can differ from the preview. Quotes in terminal-rs may be delayed.",
"terminal-rs is provided as is, without warranty. You are responsible for every order you place.",
"Market orders fill at the prevailing price, which can differ from the preview. Quotes in Terminal may be delayed.",
"Terminal is provided as is, without warranty. You are responsible for every order you place.",
"Your brokerage connection must allow trading (reconnect with trading access if it was connected read-only).",
] {
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
@@ -1692,7 +1749,7 @@ impl TerminalApp {
requests read-only access; use Reconnect with Trading Access in Settings › Trading for brokers that trade."
.into()
} else {
"Read-only: terminal-rs reads accounts, balances, positions, orders and activity. Live trading is off \
"Read-only: Terminal reads accounts, balances, positions, orders and activity. Live trading is off \
(Settings › Trading), and the Connection Portal requests read-only access."
.into()
}
@@ -1834,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() {
@@ -1849,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)));
@@ -1972,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();
}
}
}
}
+52 -6
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;
@@ -954,8 +1002,8 @@ impl TerminalApp {
let dir = if downloads.is_dir() { downloads } else { self.store.dir.join("exports") };
dir.join(format!("{}.json", crate::layouts::Store::file_stem(&name)))
});
self.store.export(&name, &path)?;
Ok(format!("Exported {name:?} to {}", path.display()))
let to = self.store.export(&name, &path)?;
Ok(format!("Exported {name:?} to {to}"))
}
LayoutOp::Import(path) => {
let name = self.store.import(&path)?;
@@ -964,9 +1012,7 @@ impl TerminalApp {
Ok(format!("Imported {} as {name:?}", path.display()))
}
LayoutOp::OpenFolder => {
std::fs::create_dir_all(&self.store.dir)?;
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
std::process::Command::new(opener).arg(&self.store.dir).spawn()?;
crate::storage::reveal(&self.store.dir)?;
Ok(format!("Opened {}", self.store.dir.display()))
}
})();
@@ -1447,7 +1493,7 @@ impl TerminalApp {
let Some(geom) = self.panes[i].window else { continue };
let title = self.panes[i].display_title();
let builder = ViewportBuilder::default()
.with_title(format!("{title} — terminal-rs"))
.with_title(format!("{title} — Terminal"))
.with_inner_size([geom.w, geom.h])
.with_position([geom.x, geom.y])
.with_min_inner_size([320.0, 220.0]);
+123 -21
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,
@@ -106,8 +108,8 @@ pub struct ChartState {
hover_marker: Option<usize>,
/// Resolved security for `symbol`.
sec: Security,
last_live: Option<std::time::Instant>,
last_indicator_req: Option<std::time::Instant>,
last_live: Option<web_time::Instant>,
last_indicator_req: Option<web_time::Instant>,
/// App-level actions requested from this chart (context menu), drained by the app.
pub actions: Vec<Action>,
/// False while a pane is being dragged over the workspace: no crosshair or hover cards.
@@ -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,
) {
@@ -404,7 +413,7 @@ impl ChartState {
return;
}
if live {
self.last_live = Some(std::time::Instant::now());
self.last_live = Some(web_time::Instant::now());
}
let old_len = self.bars.len();
let new_len = bars.len();
@@ -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,24 +449,44 @@ 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);
self.last_indicator_req = Some(std::time::Instant::now());
self.last_indicator_req = Some(web_time::Instant::now());
}
if !live {
self.request_compare(w);
}
}
/// 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");
+10 -1
View File
@@ -9,6 +9,10 @@ use tr_congress::{Chamber, Party, Trade, TxType};
use crate::theme::{self, amber, mono, muted, text, ui_font};
use crate::worker::Profile;
/// Shown instead of Congress data in the browser build, which can't fetch disclosures yet.
pub const WEB_NOTE: &str =
"Congress data needs the desktop app for now: browsers can't reach Senate eFD or the House Clerk.";
/// What a click inside a panel asks the app to do.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
@@ -192,7 +196,12 @@ pub fn feed(ui: &mut Ui, trades: &[Trade], filter: &mut FeedFilter, status: &str
header(ui, "FILED TRADED LAG POLITICIAN PTY C TYPE TICKER AMOUNT OWNER ASSET");
let rows: Vec<&Trade> = trades.iter().filter(|t| filter.matches(t)).collect();
if rows.is_empty() {
plain(ui, "No disclosures yet. Ingest runs in the background (REFRESH to force).", muted());
let empty = if cfg!(target_arch = "wasm32") {
WEB_NOTE
} else {
"No disclosures yet. Ingest runs in the background (REFRESH to force)."
};
plain(ui, empty, muted());
}
let row_h = 16.0;
ScrollArea::both().id_salt("feed").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
+1028
View File
File diff suppressed because it is too large. Load diff
+17 -28
View File
@@ -1,6 +1,6 @@
//! Named, saved workspace layouts: the dock tree, each pane's kind and settings, link
//! groups and popped-out windows. Stored one JSON file per layout in
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal-rs/layouts` on macOS),
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal/layouts` on macOS),
//! so they can be shared, hand-edited, imported and exported.
use std::collections::HashSet;
@@ -16,6 +16,7 @@ use crate::command::IndicatorSpec;
use crate::congress_view::FeedFilter;
use crate::dock::{Node, PaneId};
use crate::layout::Layout;
use crate::storage;
pub const FORMAT_VERSION: u32 = 1;
@@ -276,16 +277,6 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
}
}
/// Write via a temp file and rename, so a crash never leaves a half-written file.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, path)
}
/// The layouts folder and its files.
pub struct Store {
pub dir: PathBuf,
@@ -314,19 +305,17 @@ impl Store {
/// Saved layout names, sorted case-insensitively. Names come from the documents, so a
/// file copied in under any file name keeps its display name.
pub fn list(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
let mut names: Vec<String> = storage::list(&self.dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let bytes = std::fs::read(e.path()).ok()?;
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.filter_map(|p| {
let bytes = storage::read(&p).ok()?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
Some(
doc["name"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| e.path().file_stem().unwrap_or_default().to_string_lossy().into_owned()),
.unwrap_or_else(|| p.file_stem().unwrap_or_default().to_string_lossy().into_owned()),
)
})
.collect();
@@ -343,12 +332,12 @@ impl Store {
/// that name (e.g. dropped into the folder by hand).
fn find_file(&self, name: &str) -> Option<PathBuf> {
let p = self.path(name);
if p.exists() {
if storage::exists(&p) {
return Some(p);
}
std::fs::read_dir(&self.dir).ok()?.flatten().map(|e| e.path()).find(|p| {
storage::list(&self.dir).into_iter().find(|p| {
p.extension().is_some_and(|x| x == "json")
&& std::fs::read(p)
&& storage::read(p)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.is_some_and(|v| v["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
@@ -357,7 +346,7 @@ impl Store {
pub fn load(&self, name: &str) -> Result<LayoutDoc> {
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
let mut doc = LayoutDoc::from_json(&std::fs::read(&path)?).with_context(|| format!("{}", path.display()))?;
let mut doc = LayoutDoc::from_json(&storage::read(&path)?).with_context(|| format!("{}", path.display()))?;
if doc.name.trim().is_empty() {
doc.name = name.to_string();
}
@@ -366,13 +355,13 @@ impl Store {
pub fn save(&self, doc: &LayoutDoc) -> Result<PathBuf> {
let path = self.path(&doc.name);
write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
storage::write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
pub fn delete(&self, name: &str) -> Result<()> {
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
std::fs::remove_file(path)?;
storage::remove(&path)?;
Ok(())
}
@@ -411,16 +400,16 @@ impl Store {
(2..).map(|i| format!("{base} {i}")).find(|n| !self.exists(n)).expect("unbounded")
}
/// Write a layout to an arbitrary path.
pub fn export(&self, name: &str, to: &Path) -> Result<()> {
/// Write a layout to an arbitrary path (a download in the browser). Returns where it went.
pub fn export(&self, name: &str, to: &Path) -> Result<String> {
let doc = self.load(name)?;
write_atomic(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
storage::export(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
}
/// Copy a layout file into the store (renaming on conflict). Returns its name.
pub fn import(&self, from: &Path) -> Result<String> {
let mut doc =
LayoutDoc::from_json(&std::fs::read(from).with_context(|| format!("reading {}", from.display()))?)?;
LayoutDoc::from_json(&storage::import(from).with_context(|| format!("reading {}", from.display()))?)?;
if doc.name.trim().is_empty() {
doc.name = from.file_stem().unwrap_or_default().to_string_lossy().into_owned();
}
+52 -17
View File
@@ -1,4 +1,4 @@
//! terminal-rs: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
//! Terminal: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
//! congressional (STOCK Act) trade tracking.
mod anim;
@@ -7,18 +7,65 @@ mod chart_view;
mod command;
mod congress_view;
mod dock;
mod hl_worker;
mod layout;
mod layouts;
mod markets_view;
mod palette;
mod prefs;
mod storage;
mod theme;
mod themes;
mod wallet_worker;
#[cfg(target_arch = "wasm32")]
mod web;
mod widgets;
mod worker;
use std::time::Instant;
use app::TerminalApp;
use web_time::Instant;
/// Build the terminal once eframe has a GPU: register the chart renderer, start the worker
/// and restore the saved workspace. Shared by the desktop and browser entry points.
fn create_app(
cc: &eframe::CreationContext<'_>,
provider: tr_data::Provider,
msaa: u32,
t0: Instant,
) -> Result<TerminalApp, Box<dyn std::error::Error + Send + Sync>> {
let rs = cc.wgpu_render_state.as_ref().ok_or("Terminal requires the wgpu renderer")?;
// Charts read their series from storage buffers in the vertex shader. WebGL2 has none,
// so there chart panes keep their axes and labels but draw no series.
let charts = rs.adapter.get_downlevel_capabilities().flags.contains(eframe::wgpu::DownlevelFlags::VERTEX_STORAGE);
if charts {
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
&rs.device,
rs.target_format,
msaa,
));
}
let name = provider.name().to_string();
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
let info = rs.adapter.get_info();
tracing::info!("window + GPU ready in {:?} (adapter: {}, backend: {:?})", t0.elapsed(), info.name, info.backend);
let mut app = TerminalApp::new(&cc.egui_ctx, worker, name);
if !charts {
tracing::warn!("this GPU backend has no vertex storage buffers; charts are off");
app.set_status(if cfg!(target_arch = "wasm32") {
"Charts need WebGPU. This browser fell back to WebGL2, so they are off.".into()
} else {
"This GPU backend has no vertex storage buffers, so charts are off.".into()
});
}
Ok(app)
}
#[cfg(target_arch = "wasm32")]
fn main() {
web::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
let t0 = Instant::now();
tracing_subscriber::fmt()
@@ -36,7 +83,7 @@ fn main() -> eframe::Result {
.unwrap_or(if cfg!(target_os = "macos") { 4 } else { 1 });
let mut viewport = egui::ViewportBuilder::default()
.with_title("Terminal")
.with_app_id("terminal-rs")
.with_app_id("terminal")
.with_icon(eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon.png")).expect("app icon"))
.with_inner_size([1600.0, 960.0])
.with_min_inner_size([900.0, 560.0]);
@@ -56,20 +103,8 @@ fn main() -> eframe::Result {
..Default::default()
};
eframe::run_native(
"terminal-rs",
"terminal",
options,
Box::new(move |cc| {
let rs = cc.wgpu_render_state.as_ref().ok_or("terminal-rs requires the wgpu renderer")?;
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
&rs.device,
rs.target_format,
u32::from(msaa),
));
let provider = tr_data::from_env();
let name = provider.name().to_string();
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
tracing::info!("window + GPU ready in {:?} (adapter: {})", t0.elapsed(), rs.adapter.get_info().name);
Ok(Box::new(app::TerminalApp::new(&cc.egui_ctx, worker, name)))
}),
Box::new(move |cc| Ok(Box::new(create_app(cc, tr_data::from_env(), u32::from(msaa), t0)?))),
)
}
+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));
}
}
+2 -2
View File
@@ -183,7 +183,7 @@ pub struct Palette {
impl Palette {
/// Load recent picks from `path` (missing or unreadable files start empty).
pub fn load(path: &Path) -> Self {
let recents = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let recents = crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
Self { recents, path: Some(path.to_path_buf()), ..Default::default() }
}
@@ -230,7 +230,7 @@ impl Palette {
self.recents.truncate(MAX_RECENTS);
if let Some(path) = &self.path
&& let Ok(json) = serde_json::to_vec_pretty(&self.recents)
&& let Err(e) = crate::layouts::write_atomic(path, &json)
&& let Err(e) = crate::storage::write_atomic(path, &json)
{
tracing::warn!("saving palette recents: {e}");
}
+83 -3
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" });
@@ -684,7 +741,8 @@ impl Default for Prefs {
impl Prefs {
pub fn load(path: &std::path::Path) -> Prefs {
let mut p: Prefs = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let mut p: Prefs =
crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
p.migrate();
p.hotkeys = p.hotkeys.with_defaults();
p.appearance.font_scale = p.appearance.font_scale.clamp(0.7, 1.6);
@@ -756,7 +814,7 @@ impl Prefs {
}
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
crate::layouts::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
crate::storage::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
}
}
@@ -840,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.
+469
View File
@@ -0,0 +1,469 @@
//! Where preferences, settings, layouts, themes, palette history and the demo account are
//! kept: files under the data directory on the desktop, `localStorage` entries keyed by path
//! in the browser. Every saved document is UTF-8 text (JSON or TOML).
//!
//! [`export`], [`import`] and [`reveal`] are for files the user names: on the desktop they are
//! ordinary files and folders; in the browser an export is a download, and there is no import
//! or folder yet.
use std::io;
use std::path::{Path, PathBuf};
pub use imp::*;
/// Saved documents keyed by path, as the browser keeps them.
#[cfg(any(target_arch = "wasm32", test))]
trait KeyStore {
fn keys(&self) -> Vec<String>;
fn get(&self, key: &str) -> Option<String>;
/// False when the store refuses the write (full or blocked).
fn set(&mut self, key: &str, value: &str) -> bool;
fn remove(&mut self, key: &str);
}
/// Copies every key under `old/` to the same path under `new/`, once. Returns whether to use
/// `new`. Keys already under `new/` are never overwritten, and the old keys stay as they are. A
/// marker key records that the copy finished, so a file deleted afterwards doesn't come back on
/// the next load. If the store refuses a write, the copies made so far are removed and this
/// returns false, so the page keeps its complete old set and the next load tries again.
#[cfg(any(target_arch = "wasm32", test))]
fn copy_keys(store: &mut impl KeyStore, old: &str, new: &str) -> bool {
let marker = marker(new);
if store.get(&marker).is_some() {
return true;
}
let keys: Vec<(String, String)> = store
.keys()
.into_iter()
.filter_map(|k| Some((format!("{new}/{}", k.strip_prefix(old)?.strip_prefix('/')?), k)))
.collect();
if keys.is_empty() {
return true;
}
let mut copied = Vec::new();
let mut ok = true;
for (to, from) in keys {
if store.get(&to).is_some() {
continue;
}
let Some(value) = store.get(&from) else { continue };
if !store.set(&to, &value) {
ok = false;
break;
}
copied.push(to);
}
if ok && store.set(&marker, old) {
tracing::info!("copied {} saved files from {old}/ to {new}/", copied.len());
return true;
}
for k in &copied {
store.remove(k);
}
tracing::warn!("browser storage refused copying saved files from {old}/ to {new}/; using {old}/ for now");
false
}
/// The key [`copy_keys`] sets once everything under `new/` is in place.
#[cfg(any(target_arch = "wasm32", test))]
fn marker(new: &str) -> String {
format!("{new}/.migrated")
}
/// Where a page that kept the old prefix (because [`copy_keys`] failed) should read and write
/// `key`. Once another tab has finished the copy, keys under `old` map to `new`, so this page's
/// saves land where later loads look instead of under keys they no longer read.
#[cfg(any(target_arch = "wasm32", test))]
fn redirect(store: &impl KeyStore, old: &str, new: &str, key: String) -> String {
match key.strip_prefix(old) {
Some(rest) if (rest.is_empty() || rest.starts_with('/')) && store.get(&marker(new)).is_some() => {
format!("{new}{rest}")
}
_ => key,
}
}
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::*;
/// Moves the data folder from `old` (its name before the app was renamed) to `new` the first
/// time, and leaves a link at the old path so builds that still use the old name find the
/// same files. Returns the folder to use: `new`, or `old` if it couldn't be moved. Never
/// replaces an existing `new`.
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
if new.exists() || !old.is_dir() {
return new.to_path_buf();
}
if let Err(e) = std::fs::rename(old, new) {
// Another copy of the app may have moved it first.
if new.is_dir() {
return new.to_path_buf();
}
tracing::warn!("could not move {} to {} ({e}); using it where it is", old.display(), new.display());
return old.to_path_buf();
}
tracing::info!("moved {} to {}", old.display(), new.display());
#[cfg(unix)]
{
// Name the sibling rather than repeating `new`, which may be relative (an unset HOME
// or a relative XDG_DATA_HOME) and would then resolve from the link's folder.
let target = match new.file_name() {
Some(name) if old.parent() == new.parent() => PathBuf::from(name),
_ => std::path::absolute(new).unwrap_or_else(|_| new.to_path_buf()),
};
if let Err(e) = std::os::unix::fs::symlink(&target, old) {
tracing::warn!("linking {} to {}: {e}", old.display(), target.display());
}
}
new.to_path_buf()
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
/// Write via a temp file and rename, so a crash never leaves a half-written file.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, path)
}
/// Entries directly inside `dir` (none if it doesn't exist).
pub fn list(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).collect()
}
pub fn exists(path: &Path) -> bool {
path.exists()
}
pub fn remove(path: &Path) -> io::Result<()> {
std::fs::remove_file(path)
}
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
std::fs::rename(from, to)
}
/// Write a file the user exports. Returns where it went, for the status line.
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
write_atomic(path, bytes)?;
Ok(path.display().to_string())
}
/// Read a file the user imports.
pub fn import(path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
/// Open a folder in the system file manager, creating it first.
pub fn reveal(dir: &Path) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
std::process::Command::new(opener).arg(dir).spawn().map(|_| ())
}
}
#[cfg(target_arch = "wasm32")]
mod imp {
use super::*;
fn store() -> io::Result<web_sys::Storage> {
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "browser storage is unavailable"))
}
thread_local! {
/// The old and new prefixes while this page keeps the old one (see [`migrate`]).
static FALLBACK: std::cell::RefCell<Option<(String, String)>> = const { std::cell::RefCell::new(None) };
}
fn key(path: &Path) -> String {
let key = path.to_string_lossy().into_owned();
let Some((old, new)) = FALLBACK.with_borrow(Clone::clone) else { return key };
match store() {
Ok(s) => redirect(&s, &old, &new, key),
Err(_) => key,
}
}
impl KeyStore for web_sys::Storage {
fn keys(&self) -> Vec<String> {
(0..self.length().unwrap_or(0)).filter_map(|i| self.key(i).ok().flatten()).collect()
}
fn get(&self, key: &str) -> Option<String> {
self.get_item(key).ok().flatten()
}
fn set(&mut self, key: &str, value: &str) -> bool {
self.set_item(key, value).is_ok()
}
fn remove(&mut self, key: &str) {
let _ = self.remove_item(key);
}
}
/// Copies what the page saved under `old/` (its prefix before the app was renamed) to
/// `new/`, once (see [`copy_keys`]). Returns the prefix to use. If the copy fails, the page
/// keeps `old`, and its keys follow [`redirect`] from then on, in case another tab finishes
/// the copy while this one is open.
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
let Ok(mut s) = store() else { return new.to_path_buf() };
let (old, new) = (key(old), key(new));
if copy_keys(&mut s, &old, &new) {
return PathBuf::from(new);
}
FALLBACK.with_borrow_mut(|f| *f = Some((old.clone(), new)));
PathBuf::from(old)
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
match store()?.get_item(&key(path)) {
Ok(Some(text)) => Ok(text.into_bytes()),
Ok(None) => Err(io::ErrorKind::NotFound.into()),
Err(_) => Err(io::Error::other("browser storage refused the read")),
}
}
/// A single `setItem`, which is already all-or-nothing.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let text = std::str::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
store()?.set_item(&key(path), text).map_err(|_| io::Error::other("browser storage is full or blocked"))
}
/// Keys directly under `dir/`.
pub fn list(dir: &Path) -> Vec<PathBuf> {
let Ok(s) = store() else { return Vec::new() };
let prefix = format!("{}/", key(dir).trim_end_matches('/'));
(0..s.length().unwrap_or(0))
.filter_map(|i| s.key(i).ok().flatten())
.filter(|k| k.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/')))
.map(PathBuf::from)
.collect()
}
pub fn exists(path: &Path) -> bool {
store().ok().and_then(|s| s.get_item(&key(path)).ok().flatten()).is_some()
}
pub fn remove(path: &Path) -> io::Result<()> {
if !exists(path) {
return Err(io::ErrorKind::NotFound.into());
}
store()?.remove_item(&key(path)).map_err(|_| io::Error::other("browser storage refused the delete"))
}
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
write_atomic(to, &read(from)?)?;
remove(from)
}
/// Download the exported file under its file name. Returns where it went, for the status line.
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
use wasm_bindgen::JsCast;
let fail = |what: &str| io::Error::other(format!("the browser refused the download ({what})"));
let name = path.file_name().map_or_else(|| "export".into(), |n| n.to_string_lossy().into_owned());
let doc = web_sys::window().and_then(|w| w.document()).ok_or_else(|| fail("no document"))?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts).map_err(|_| fail("blob"))?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(|_| fail("object URL"))?;
let link: web_sys::HtmlAnchorElement =
doc.create_element("a").map_err(|_| fail("link"))?.dyn_into().map_err(|_| fail("link"))?;
link.set_href(&url);
link.set_download(&name);
link.click();
let _ = web_sys::Url::revoke_object_url(&url);
Ok(format!("your downloads as {name}"))
}
// TODO(web): read imports through a file picker instead of a typed path.
pub fn import(_path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "importing files needs the desktop app for now"))
}
pub fn reveal(_dir: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "the browser keeps these in its local storage, not a folder"))
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn round_trip() {
let dir = std::env::temp_dir().join(format!("tr-storage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let a = dir.join("sub/a.json");
assert!(list(&dir.join("sub")).is_empty());
assert_eq!(read(&a).unwrap_err().kind(), io::ErrorKind::NotFound);
write_atomic(&a, b"{}").unwrap();
assert!(exists(&a));
assert_eq!(list(&dir.join("sub")), vec![a.clone()]);
let b = dir.join("sub/b.json");
rename(&a, &b).unwrap();
assert!(!exists(&a));
assert_eq!(read(&b).unwrap(), b"{}");
remove(&b).unwrap();
assert!(list(&dir.join("sub")).is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tr-migrate-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn migrate_moves_the_old_folder_and_links_it() {
let root = scratch("move");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("layouts/Desk.json"), b"{}").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("layouts/Desk.json")).unwrap(), b"{}");
#[cfg(unix)]
{
assert_eq!(std::fs::read_link(&old).unwrap(), Path::new("terminal"), "relative to the link's folder");
assert_eq!(read(&old.join("layouts/Desk.json")).unwrap(), b"{}", "old builds still find the files");
}
// Later launches find the new folder and leave everything as it is.
write_atomic(&new.join("settings.json"), b"[]").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("settings.json")).unwrap(), b"[]");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn migrate_never_replaces_an_existing_folder() {
let root = scratch("keep");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("settings.json"), b"old").unwrap();
write_atomic(&new.join("settings.json"), b"new").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("settings.json")).unwrap(), b"new");
assert_eq!(read(&old.join("settings.json")).unwrap(), b"old", "the old folder is left alone");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn migrate_without_an_old_folder_creates_nothing() {
let root = scratch("fresh");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
assert_eq!(migrate(&old, &new), new);
assert!(!old.exists() && !new.exists());
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(unix)]
#[test]
fn migrate_keeps_using_the_old_folder_when_it_cannot_move() {
use std::os::unix::fs::PermissionsExt;
let root = scratch("stuck");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("settings.json"), b"{}").unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap();
if std::fs::create_dir(root.join("probe")).is_ok() {
// Running as root, where a read-only folder doesn't stop the move.
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
return;
}
let used = migrate(&old, &new);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(used, old);
assert_eq!(read(&old.join("settings.json")).unwrap(), b"{}");
std::fs::remove_dir_all(&root).unwrap();
}
/// `localStorage` stand-in: a map that refuses writes once it holds `room` keys.
struct Keys {
map: std::collections::BTreeMap<String, String>,
room: usize,
}
impl Keys {
fn new(entries: &[(&str, &str)]) -> Self {
Self { map: entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), room: usize::MAX }
}
}
impl KeyStore for Keys {
fn keys(&self) -> Vec<String> {
self.map.keys().cloned().collect()
}
fn get(&self, key: &str) -> Option<String> {
self.map.get(key).cloned()
}
fn set(&mut self, key: &str, value: &str) -> bool {
if !self.map.contains_key(key) && self.map.len() >= self.room {
return false;
}
self.map.insert(key.into(), value.into());
true
}
fn remove(&mut self, key: &str) {
self.map.remove(key);
}
}
#[test]
fn copy_keys_copies_once_without_overwriting() {
let mut s = Keys::new(&[
("terminal-rs/settings.json", "old settings"),
("terminal-rs/layouts/Desk.json", "desk"),
("terminal/settings.json", "newer settings"),
("other-site-key", "untouched"),
]);
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/settings.json").as_deref(), Some("newer settings"), "newer data wins");
assert_eq!(s.get("terminal/layouts/Desk.json").as_deref(), Some("desk"));
assert_eq!(s.get("terminal-rs/layouts/Desk.json").as_deref(), Some("desk"), "the old keys stay");
assert_eq!(s.get("other-site-key").as_deref(), Some("untouched"));
// A layout deleted after the copy doesn't come back on the next load.
s.remove("terminal/layouts/Desk.json");
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/layouts/Desk.json"), None);
}
#[test]
fn copy_keys_undoes_a_partial_copy() {
let mut s = Keys::new(&[("terminal-rs/a.json", "a"), ("terminal-rs/b.json", "b")]);
s.room = 3;
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"), "storage is full: keep using the old keys");
assert_eq!(s.keys(), ["terminal-rs/a.json", "terminal-rs/b.json"]);
s.room = usize::MAX;
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/b.json").as_deref(), Some("b"));
}
#[test]
fn a_page_left_on_the_old_prefix_follows_another_tabs_copy() {
let mut s = Keys::new(&[("terminal-rs/settings.json", "a")]);
s.room = 1;
// Tab A can't copy, so it keeps the old prefix.
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"));
let key = |s: &Keys, k: &str| redirect(s, "terminal-rs", "terminal", k.into());
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal-rs/settings.json");
// Space frees up and tab B finishes the copy; tab A's saves now go where loads read.
s.room = usize::MAX;
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal/settings.json");
assert_eq!(key(&s, "terminal-rs/layouts"), "terminal/layouts");
assert_eq!(key(&s, "terminal-rs"), "terminal");
assert_eq!(key(&s, "terminal-rsx/a.json"), "terminal-rsx/a.json", "only that prefix");
}
#[test]
fn copy_keys_with_nothing_saved_writes_nothing() {
let mut s = Keys::new(&[]);
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert!(s.keys().is_empty());
}
}
+46 -1
View File
@@ -53,6 +53,9 @@ thread_local! {
static DENSITY: Cell<f32> = const { Cell::new(1.0) };
static UI_MONO: Cell<bool> = const { Cell::new(false) };
static FONTS: Cell<Option<MonoFont>> = const { Cell::new(None) };
/// Fonts fetched after start-up (the browser build's emoji fallbacks).
#[cfg(target_arch = "wasm32")]
static LATE_FONTS: std::cell::RefCell<Vec<(String, std::sync::Arc<FontData>)>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// Spacing multiplier from the density preference (compact < 1 < comfortable).
@@ -138,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) }
}
@@ -176,13 +185,49 @@ pub fn ui_font(size: f32) -> FontId {
pub const CARD_RADIUS: u8 = 10;
pub const CONTROL_RADIUS: u8 = 6;
/// egui's bundled fonts. The browser build starts with only the two text fonts, which keeps
/// the emoji fonts out of the download; they arrive later through [`add_late_font`].
pub fn base_fonts() -> FontDefinitions {
#[cfg(not(target_arch = "wasm32"))]
let fonts = FontDefinitions::default();
#[cfg(target_arch = "wasm32")]
let fonts = {
use std::sync::Arc;
let mut f = FontDefinitions::empty();
f.font_data.insert("Hack".into(), Arc::new(FontData::from_static(epaint_default_fonts::HACK_REGULAR)));
f.font_data.insert("Ubuntu-Light".into(), Arc::new(FontData::from_static(epaint_default_fonts::UBUNTU_LIGHT)));
f.families.insert(FontFamily::Monospace, vec!["Hack".into(), "Ubuntu-Light".into()]);
f.families.insert(FontFamily::Proportional, vec!["Ubuntu-Light".into()]);
LATE_FONTS.with_borrow(|late| {
for (name, data) in late {
f.font_data.insert(name.clone(), data.clone());
for family in [FontFamily::Proportional, FontFamily::Monospace] {
f.families.entry(family).or_default().push(name.clone());
}
}
});
f
};
fonts
}
/// Add a font fetched after start-up as the last fallback of every family.
#[cfg(target_arch = "wasm32")]
pub fn add_late_font(ctx: &egui::Context, name: &str, data: FontData) {
LATE_FONTS.with_borrow_mut(|late| late.push((name.to_owned(), std::sync::Arc::new(data))));
// Reinstall with the current monospace choice (or let the first configure pick it up).
if let Some(mono) = FONTS.take() {
install_fonts(ctx, mono);
}
}
/// Use the system UI fonts when present (read from the OS at runtime, not bundled).
fn install_fonts(ctx: &egui::Context, mono: MonoFont) {
if FONTS.with(Cell::get) == Some(mono) {
return;
}
FONTS.with(|f| f.set(Some(mono)));
let mut fonts = FontDefinitions::default();
let mut fonts = base_fonts();
let ui_paths: &[&str] = &[
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/SFNSText.ttf",
+20 -12
View File
@@ -10,6 +10,7 @@ use egui::Color32;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::prefs::{Density, MonoFont, UiFont};
use crate::storage;
use crate::theme::Palette;
/// Selection that follows the OS appearance using the configured dark/light pair.
@@ -561,11 +562,8 @@ impl ThemeStore {
/// built-in or earlier name is listed with a suffix rather than shadowing it.
pub fn load_all(&self) -> Vec<Theme> {
let mut out = builtins();
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.dir)
let mut files: Vec<PathBuf> = storage::list(&self.dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml" || x == "json"))
.collect();
files.sort();
@@ -606,7 +604,7 @@ impl ThemeStore {
.clone()
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.unwrap_or_else(|| self.path(&theme.name));
crate::layouts::write_atomic(&path, theme.to_toml().as_bytes())
storage::write_atomic(&path, theme.to_toml().as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
@@ -617,23 +615,33 @@ impl ThemeStore {
bail!("built-in themes cannot be deleted");
}
let path = theme.source.clone().unwrap_or_else(|| self.path(&theme.name));
if !path.exists() {
if !storage::exists(&path) {
bail!("no file for theme {:?}", theme.name);
}
Ok(std::fs::remove_file(path)?)
Ok(storage::remove(&path)?)
}
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else).
pub fn export(theme: &Theme, to: &Path) -> Result<()> {
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else); a download in the
/// browser. Returns where it went.
pub fn export(theme: &Theme, to: &Path) -> Result<String> {
let json = to.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
let text = if json { theme.to_json() } else { theme.to_toml() };
crate::layouts::write_atomic(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
storage::export(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
}
}
/// Read a TOML or JSON theme file.
/// Read a TOML or JSON theme file from the themes folder.
pub fn load_file(path: &Path) -> Result<Theme> {
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
parse_file(path, storage::read(path).with_context(|| format!("reading {}", path.display()))?)
}
/// Read a theme file the user imports.
pub fn import_file(path: &Path) -> Result<Theme> {
parse_file(path, storage::import(path).with_context(|| format!("reading {}", path.display()))?)
}
fn parse_file(path: &Path, bytes: Vec<u8>) -> Result<Theme> {
let text = String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8 text", path.display()))?;
let json = path.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
let mut t = Theme::parse(&text, json)?;
t.source = Some(path.to_path_buf());
+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");
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Browser entry point and page glue: start eframe's web runner on the page's canvas, log to
//! the console, and publish frame timings on `window.terminalPerf` for measurement.
//!
//! 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;
use tracing::{Level, Metadata};
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use wasm_bindgen::{JsCast, JsValue};
use web_time::Instant;
pub fn start() {
init_tracing();
let t0 = Instant::now();
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()
.start(
canvas,
eframe::WebOptions::default(),
Box::new(move |cc| {
// 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, server.as_deref()), 1, t0)?;
if perf {
app.show_perf();
}
Ok(Box::new(app))
}),
)
.await;
if let Err(e) = started {
fail(&format!("{e:?}"));
}
});
}
/// Fetch egui's emoji fonts, which scripts/build-web.sh puts in `fonts/` next to the page, and
/// add them as fallbacks. Until they arrive, emoji and some symbols draw as boxes.
async fn load_emoji_fonts(ctx: egui::Context) {
let fonts = [("NotoEmoji-Regular", 0.81), ("emoji-icon-font", 0.90)];
let fetched = futures::future::join_all(fonts.iter().map(|(name, _)| fetch(format!("fonts/{name}.ttf")))).await;
for ((name, scale), bytes) in fonts.into_iter().zip(fetched) {
match bytes {
Ok(bytes) => {
let tweak = egui::FontTweak { scale, ..Default::default() };
crate::theme::add_late_font(&ctx, name, egui::FontData::from_owned(bytes).tweak(tweak));
}
Err(e) => tracing::warn!("font {name}: {e}"),
}
}
ctx.request_repaint();
}
async fn fetch(url: String) -> Result<Vec<u8>, String> {
use wasm_bindgen_futures::JsFuture;
let window = web_sys::window().ok_or("no window")?;
let resp: web_sys::Response = JsFuture::from(window.fetch_with_str(&url))
.await
.map_err(|e| format!("{e:?}"))?
.dyn_into()
.map_err(|_| "not a response")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
let buf = JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
Ok(js_sys::Uint8Array::new(&buf).to_vec())
}
fn has_flag(query: &str, flag: &str) -> bool {
query.trim_start_matches('?').split('&').any(|p| p.split('=').next() == Some(flag))
}
fn canvas() -> Option<web_sys::HtmlCanvasElement> {
web_sys::window()?.document()?.get_element_by_id("terminal")?.dyn_into().ok()
}
/// Show why the app couldn't start in place of the loading message.
fn fail(why: &str) {
tracing::error!("terminal failed to start: {why}");
if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("status")) {
el.set_text_content(Some(&format!("Terminal couldn't start: {why}")));
}
}
/// Per-frame numbers from `TerminalApp::ui` (milliseconds).
pub struct FrameStats {
/// Smoothed CPU time inside `ui()`.
pub ui_ms: f64,
/// Smoothed time between frames.
pub interval_ms: f64,
/// eframe's CPU time for the whole previous frame (UI, tessellation and painting).
pub last_cpu_ms: f64,
pub frames: u64,
}
thread_local! {
static FIRST_FRAME: Cell<bool> = const { Cell::new(true) };
}
pub fn record_frame(s: &FrameStats) {
let Some(w) = web_sys::window() else { return };
let perf = match js_sys::Reflect::get(&w, &"terminalPerf".into()) {
Ok(v) if v.is_object() => v,
_ => {
let v: JsValue = js_sys::Object::new().into();
let _ = js_sys::Reflect::set(&w, &"terminalPerf".into(), &v);
v
}
};
let set = |k: &str, v: f64| {
let _ = js_sys::Reflect::set(&perf, &k.into(), &v.into());
};
if FIRST_FRAME.replace(false) {
let at = w.performance().map_or(0.0, |p| p.now());
tracing::info!("first frame built {at:.0} ms after navigation start");
set("firstFrameMs", at);
if let Some(el) = w.document().and_then(|d| d.get_element_by_id("status")) {
el.remove();
}
}
set("uiMs", s.ui_ms);
set("intervalMs", s.interval_ms);
set("cpuMs", s.last_cpu_ms);
set("frames", s.frames as f64);
}
fn init_tracing() {
let filter = tracing_subscriber::filter::Targets::new()
.with_default(Level::INFO)
.with_target("wgpu_core", Level::WARN)
.with_target("wgpu_hal", Level::WARN)
.with_target("naga", Level::WARN);
let fmt = tracing_subscriber::fmt::layer().with_writer(Console).without_time().with_ansi(false);
let _ = tracing_subscriber::registry().with(fmt).with(filter).try_init();
}
/// Sends each formatted log line to the browser console at its level.
struct Console;
struct Line {
level: Level,
buf: Vec<u8>,
}
impl std::io::Write for Line {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.buf.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Drop for Line {
fn drop(&mut self) {
let text = JsValue::from_str(String::from_utf8_lossy(&self.buf).trim_end());
match self.level {
Level::ERROR => web_sys::console::error_1(&text),
Level::WARN => web_sys::console::warn_1(&text),
_ => web_sys::console::log_1(&text),
}
}
}
impl<'a> MakeWriter<'a> for Console {
type Writer = Line;
fn make_writer(&'a self) -> Line {
Line { level: Level::INFO, buf: Vec::new() }
}
fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Line {
Line { level: *meta.level(), buf: Vec::new() }
}
}
+484 -61
View File
@@ -1,6 +1,10 @@
//! Background worker. All network, SQLite, indicator and LOD-pyramid work runs on a
//! tokio runtime; the UI thread only sends [`Request`]s and drains [`Response`]s, so it
//! never blocks.
//!
//! In the browser (`wasm32`) the same requests run as tasks on the page's event loop
//! ([`tr_data::rt`]). There is no bar cache or Congress ingest there, and CPU work such as
//! indicators runs inline.
use std::collections::HashMap;
use std::path::PathBuf;
@@ -11,12 +15,16 @@ use tokio::sync::Mutex;
use tr_chart::gpu::{LineKind, LineRef};
use tr_chart::scene::{Overlay, SeriesGpu, line_ref, marker_ref};
use tr_congress::db::{Alert, PoliticianSummary};
use tr_congress::model::NewTradeAlert;
use tr_congress::performance::{self, Performance, TradeReturn};
use tr_congress::refresh::{self, HttpSource, NewTradeAlert, RefreshConfig};
#[cfg(not(target_arch = "wasm32"))]
use tr_congress::refresh::{self, HttpSource, RefreshConfig};
use tr_congress::{Db, Politician, Trade};
use tr_core::indicators::registry::{Input, Plot, Tone};
use tr_core::{Bar, Security, Series, Timeframe};
use tr_data::{BarCache, Provider, Quote, SearchHit, YieldCurves};
#[cfg(not(target_arch = "wasm32"))]
use tr_data::BarCache;
use tr_data::{Provider, Quote, SearchHit, YieldCurves, rt};
use crate::command::IndicatorSpec;
use crate::theme;
@@ -26,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>),
@@ -60,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,
@@ -121,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
@@ -217,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,
@@ -230,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,
},
@@ -254,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,
@@ -268,20 +308,27 @@ pub enum Response {
},
Politicians(Vec<PoliticianSummary>),
Alerts(Vec<Alert>),
// Only the desktop's Congress ingest raises these.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
NewAlerts(Vec<NewTradeAlert>),
Status(String),
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
CongressRefreshed(String),
Curves(Box<YieldCurves>),
SearchHits {
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 {
tx: tokio::sync::mpsc::UnboundedSender<Request>,
pub rx: mpsc::Receiver<Response>,
#[cfg(not(target_arch = "wasm32"))]
_rt: tokio::runtime::Runtime,
}
@@ -293,6 +340,10 @@ 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>,
out: mpsc::Sender<Response>,
@@ -306,22 +357,35 @@ impl Shared {
}
}
/// Where saved files live. In the browser this is the key prefix in `localStorage` (see
/// [`crate::storage`]). The first call moves files saved under the app's old name, terminal-rs,
/// to the new one ([`crate::storage::migrate`]).
pub fn data_dir() -> PathBuf {
if let Some(d) = std::env::var_os("TR_DATA_DIR") {
return PathBuf::from(d);
}
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
if cfg!(target_os = "macos") {
home.join("Library/Application Support/terminal-rs")
} else {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".local/share"))
.join("terminal-rs")
}
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
// Unit tests never read, write or move the real folder.
if cfg!(test) {
return std::env::temp_dir().join(format!("tr-test-data-{}", std::process::id()));
}
let parent = if cfg!(target_arch = "wasm32") {
PathBuf::new()
} else if let Some(d) = std::env::var_os("TR_DATA_DIR") {
return PathBuf::from(d);
} else {
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
if cfg!(target_os = "macos") {
home.join("Library/Application Support")
} else {
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".local/share"))
}
};
crate::storage::migrate(&parent.join("terminal-rs"), &parent.join("terminal"))
})
.clone()
}
impl Worker {
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
let dir = data_dir();
std::fs::create_dir_all(&dir)?;
@@ -341,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,
@@ -376,11 +441,34 @@ impl Worker {
Ok(Self { tx, rx, _rt: rt })
}
/// The browser worker: tasks on the page's event loop, no disk cache, an empty Congress store.
#[cfg(target_arch = "wasm32")]
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(),
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();
rt::spawn(async move { handle(&s, req).await });
}
});
Ok(Self { tx, rx })
}
pub fn send(&self, req: Request) {
let _ = self.tx.send(req);
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn congress_refresh(s: &Shared) {
s.send(Response::Status("Refreshing congressional disclosures (Senate eFD + House Clerk)…".into()));
let source = match HttpSource::new() {
@@ -399,6 +487,35 @@ async fn congress_refresh(s: &Shared) {
}
}
// TODO(web proxy): Senate eFD and the House Clerk don't allow cross-origin requests.
#[cfg(target_arch = "wasm32")]
async fn congress_refresh(s: &Shared) {
s.send(Response::Status(crate::congress_view::WEB_NOTE.into()));
}
/// Bars saved by an earlier session, to paint before the network answers.
#[cfg(not(target_arch = "wasm32"))]
fn cached_bars(s: &Shared, symbol: &str, tf: Timeframe, limit: usize) -> Option<Vec<Bar>> {
s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(symbol, tf, limit).ok()))
}
#[cfg(not(target_arch = "wasm32"))]
fn cache_bars(s: &Shared, symbol: &str, tf: Timeframe, bars: &[Bar]) {
if let Ok(mut c) = s.cache.lock()
&& let Some(c) = c.as_mut()
{
let _ = c.store(symbol, tf, bars);
}
}
#[cfg(target_arch = "wasm32")]
fn cached_bars(_: &Shared, _: &str, _: Timeframe, _: usize) -> Option<Vec<Bar>> {
None
}
#[cfg(target_arch = "wasm32")]
fn cache_bars(_: &Shared, _: &str, _: Timeframe, _: &[Bar]) {}
fn overlay(values: &[f64], color: egui::Color32, kind: LineKind) -> Overlay {
let line = if kind == LineKind::Dots { marker_ref(values) } else { line_ref(values) };
Overlay { line, color: theme::rgba(color), kind, scale: 1.0 }
@@ -486,8 +603,7 @@ async fn handle(s: &Arc<Shared>, req: Request) {
return; // a newer load for this pane is already running
}
// Paint from the disk cache immediately, then refresh from the network.
let cached = s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(&symbol, tf, limit).ok()));
if let Some(bars) = cached.filter(|b| !b.is_empty()) {
if let Some(bars) = cached_bars(s, &symbol, tf, limit).filter(|b| !b.is_empty()) {
let bars = Arc::new(bars);
let gpu = build_gpu(bars.clone()).await;
s.send(Response::History {
@@ -500,44 +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() => {
if let Ok(mut c) = s.cache.lock()
&& let Some(c) = c.as_mut()
{
let _ = c.store(&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();
@@ -546,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);
@@ -562,24 +668,26 @@ 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 } => {
let built = tokio::task::spawn_blocking(move || build_indicators(&bars, tf, &specs, &palette))
.await
.unwrap_or_default();
let built = rt::blocking(move || build_indicators(&bars, tf, &specs, &palette)).await.unwrap_or_default();
s.send(Response::Indicators { pane, generation, built });
}
Request::Compare { pane, generation, symbol, tf, base } => {
let limit = base.len().max(100);
match s.provider.history(&symbol, tf, None, limit).await {
Ok(other) => {
let line = tokio::task::spawn_blocking(move || line_ref(&align_compare(&base, &other))).await;
if let Ok(line) = line {
let line = rt::blocking(move || line_ref(&align_compare(&base, &other))).await;
if let Some(line) = line {
s.send(Response::Compare { pane, generation, symbol, line });
}
}
@@ -596,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();
@@ -643,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:#}"))),
@@ -686,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
}
}
}
@@ -763,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 };
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_millis(250));
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,
}
}
@@ -804,17 +1015,31 @@ 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();
}
}
});
}
async fn build_gpu(bars: Arc<Vec<Bar>>) -> SeriesGpu {
tokio::task::spawn_blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
rt::blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
}
fn resolve_politician(db: &Db, query: &str) -> Option<Politician> {
@@ -885,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();
@@ -921,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,
@@ -935,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();
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>Terminal</title>
<link rel="icon" href="data:," />
<script>
// The WebGPU build when the browser has WebGPU, else the larger WebGL2 one (no charts).
// Start downloading now; the module below cancels this if there is no WebGPU adapter.
window.terminalLoad = { build: 'gpu' in navigator ? 'terminal' : 'terminal-webgl', abort: new AbortController() };
// Each build's JS and wasm sit in a folder named after a hash of its files, so they can be
// cached for good; scripts/build-web.sh fills in these paths.
terminalLoad.paths = { terminal: './{terminal}', 'terminal-webgl': './{terminal-webgl}' };
terminalLoad.wasm = fetch(`${terminalLoad.paths[terminalLoad.build]}_bg.wasm`, { signal: terminalLoad.abort.signal });
document.head.append(Object.assign(document.createElement('link'), { rel: 'modulepreload', href: `${terminalLoad.paths[terminalLoad.build]}.js` }));
</script>
<style>
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: #1e1e20;
}
@media (prefers-color-scheme: light) {
html,
body {
background: #f5f5f7;
}
}
canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: block;
outline: none;
}
#status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
margin: 0;
font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #8e8e93;
pointer-events: none;
}
</style>
</head>
<body>
<canvas id="terminal"></canvas>
<p id="status">Loading Terminal…</p>
<script type="module">
const load = window.terminalLoad;
if (load.build === 'terminal' && !(await navigator.gpu.requestAdapter().catch(() => null))) {
load.wasm.catch(() => {}); // the abort below rejects it
load.abort.abort();
load.build = 'terminal-webgl';
load.wasm = fetch(`${load.paths[load.build]}_bg.wasm`);
}
window.terminalBuild = load.build;
try {
const { default: init } = await import(`${load.paths[load.build]}.js`);
await init({ module_or_path: load.wasm }); // runs the app, which removes #status after its first frame
} catch (e) {
console.error(e);
document.getElementById('status').textContent = `Terminal couldn't start: ${e}`;
}
</script>
</body>
</html>
+12 -1
View File
@@ -12,15 +12,26 @@ serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
tracing.workspace = true
tokio.workspace = true
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
# The browser build never calls SnapTrade (see `SnapTrade::with_base`); the client only compiles.
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", default-features = false, features = ["sync", "time"] }
[dev-dependencies]
tokio = { workspace = true, features = ["io-util", "net"] }
reqwest.workspace = true
+19 -9
View File
@@ -156,26 +156,33 @@ impl DemoBook {
/// the caller can keep it aside instead of overwriting the user's history.
pub fn load(path: &Path, now: DateTime<Utc>) -> Result<Self> {
match std::fs::read(path) {
Ok(bytes) => {
let mut b: DemoBook = serde_json::from_slice(&bytes).context("the demo account file is damaged")?;
b.version = FILE_VERSION;
if !b.cash.is_finite() || !b.starting_cash.is_finite() {
anyhow::bail!("the demo account file has invalid balances");
}
Ok(b)
}
Ok(bytes) => Self::from_json(&bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new(now)),
Err(e) => Err(e).context("reading the demo account"),
}
}
/// Parse a saved account, rejecting damaged files.
pub fn from_json(bytes: &[u8]) -> Result<Self> {
let mut b: DemoBook = serde_json::from_slice(bytes).context("the demo account file is damaged")?;
b.version = FILE_VERSION;
if !b.cash.is_finite() || !b.starting_cash.is_finite() {
anyhow::bail!("the demo account file has invalid balances");
}
Ok(b)
}
pub fn to_json(&self) -> Result<Vec<u8>> {
Ok(serde_json::to_vec_pretty(self)?)
}
/// Write atomically (temporary file, then rename).
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
std::fs::write(&tmp, self.to_json()?)?;
std::fs::rename(&tmp, path).context("saving the demo account")
}
@@ -335,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);
@@ -515,6 +523,7 @@ impl DemoBook {
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
}),
}
} else {
@@ -554,6 +563,7 @@ impl DemoBook {
currency: "USD".into(),
trade_date: Some(now),
settlement_date: Some(now),
market: None,
},
);
Ok(())
+88 -5
View File
@@ -46,7 +46,7 @@ pub struct Connection {
pub disabled: bool,
pub created: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
/// Whether the connection allows trading (terminal-rs only trades when the user enables it).
/// Whether the connection allows trading (Terminal only trades when the user enables it).
pub trading: bool,
}
@@ -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(
+321 -20
View File
@@ -2,9 +2,12 @@
//!
//! The partner keys (`clientId`, `consumerKey`) and the per-user `userId`/`userSecret` are
//! stored together as one small JSON document: in the macOS Keychain (generic password,
//! service `terminal-rs`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
//! service `terminal`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
//! the app's data directory. `SNAPTRADE_*` environment variables override stored values
//! field by field. Nothing is ever written inside the repository.
//!
//! Before the app was renamed from terminal-rs, the Keychain service was `terminal-rs`; the
//! first load moves credentials from there.
use std::fmt;
use std::path::{Path, PathBuf};
@@ -12,7 +15,9 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const KEYCHAIN_SERVICE: &str = "terminal-rs";
pub const KEYCHAIN_SERVICE: &str = "terminal";
/// The service credentials were saved under before the rename.
pub const LEGACY_KEYCHAIN_SERVICE: &str = "terminal-rs";
pub const KEYCHAIN_ACCOUNT: &str = "snaptrade";
/// Environment variables that override stored credentials.
@@ -102,8 +107,8 @@ pub fn mask(secret: &str) -> String {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Backend {
/// macOS Keychain generic password.
Keychain,
/// macOS Keychain generic password under `service`, moved there from `legacy` on first load.
Keychain { service: String, legacy: Option<String> },
/// Owner-only JSON file.
File(PathBuf),
}
@@ -119,10 +124,19 @@ impl SecretStore {
pub fn platform(data_dir: &Path) -> Self {
let file = Backend::File(data_dir.join("secrets").join("snaptrade.json"));
let force_file = std::env::var("TR_SECRET_STORE").is_ok_and(|v| v.eq_ignore_ascii_case("file"));
let backend = if cfg!(target_os = "macos") && !force_file { Backend::Keychain } else { file };
let backend = if cfg!(target_os = "macos") && !force_file {
Backend::Keychain { service: KEYCHAIN_SERVICE.into(), legacy: Some(LEGACY_KEYCHAIN_SERVICE.into()) }
} else {
file
};
Self { backend }
}
/// The Keychain under `service`, taking over an item left under `legacy`.
pub fn keychain(service: &str, legacy: Option<&str>) -> Self {
Self { backend: Backend::Keychain { service: service.into(), legacy: legacy.map(Into::into) } }
}
pub fn file(path: impl Into<PathBuf>) -> Self {
Self { backend: Backend::File(path.into()) }
}
@@ -130,14 +144,14 @@ impl SecretStore {
/// Where credentials live, for the settings screen.
pub fn describe(&self) -> String {
match &self.backend {
Backend::Keychain => format!("macOS Keychain ({KEYCHAIN_SERVICE} / {KEYCHAIN_ACCOUNT})"),
Backend::Keychain { service, .. } => format!("macOS Keychain ({service} / {KEYCHAIN_ACCOUNT})"),
Backend::File(p) => format!("{} (owner-only)", p.display()),
}
}
pub fn load(&self) -> Result<Option<Credentials>> {
let text = match &self.backend {
Backend::Keychain => keychain::get()?,
Backend::Keychain { service, legacy } => load_keychain(service, legacy.as_deref())?,
Backend::File(p) => match std::fs::read_to_string(p) {
Ok(t) => Some(t),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
@@ -155,7 +169,7 @@ impl SecretStore {
pub fn save(&self, creds: &Credentials) -> Result<()> {
let json = serde_json::to_string(&creds.trimmed())?;
match &self.backend {
Backend::Keychain => keychain::set(&json),
Backend::Keychain { service, .. } => keychain::set(service, &json),
Backend::File(p) => write_private(p, &json),
}
}
@@ -163,7 +177,11 @@ impl SecretStore {
/// Remove stored credentials (environment variables are unaffected).
pub fn clear(&self) -> Result<()> {
match &self.backend {
Backend::Keychain => keychain::delete(),
Backend::Keychain { service, legacy } => {
keychain::delete(service)?;
// The old item too, or the credentials would come back from it on the next load.
legacy.as_deref().map_or(Ok(()), keychain::delete)
}
Backend::File(p) => match std::fs::remove_file(p) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
@@ -172,6 +190,24 @@ impl SecretStore {
}
}
/// Reads the Keychain item under `service`. If there is none, the item under `legacy` moves
/// there: it is deleted only after the copy is written, so a failed move leaves it in place.
fn load_keychain(service: &str, legacy: Option<&str>) -> Result<Option<String>> {
if let Some(text) = keychain::get(service)? {
return Ok(Some(text));
}
let Some(legacy) = legacy else { return Ok(None) };
let Some(text) = keychain::get(legacy)? else { return Ok(None) };
match keychain::set(service, &text) {
Ok(()) => match keychain::delete(legacy) {
Ok(()) => tracing::info!("moved SnapTrade credentials from Keychain service {legacy} to {service}"),
Err(e) => tracing::warn!("removing SnapTrade credentials from Keychain service {legacy}: {e:#}"),
},
Err(e) => tracing::warn!("moving SnapTrade credentials to Keychain service {service}: {e:#}"),
}
Ok(Some(text))
}
/// Write `text` to `path` readable only by the owner (0700 directory, 0600 file), replacing it
/// atomically.
fn write_private(path: &Path, text: &str) -> Result<()> {
@@ -204,26 +240,38 @@ mod keychain {
use anyhow::{Context, Result};
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
use super::{KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use super::KEYCHAIN_ACCOUNT;
/// `errSecItemNotFound`.
const NOT_FOUND: i32 = -25300;
pub fn get() -> Result<Option<String>> {
match get_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
pub fn get(service: &str) -> Result<Option<String>> {
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(value: &str) -> Result<()> {
set_generic_password(KEYCHAIN_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() -> Result<()> {
match delete_generic_password(KEYCHAIN_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(()),
}
@@ -234,15 +282,203 @@ mod keychain {
mod keychain {
use anyhow::{Result, bail};
pub fn get() -> Result<Option<String>> {
pub fn get(_: &str) -> Result<Option<String>> {
bail!("the Keychain is only available on macOS")
}
pub fn set(_: &str) -> Result<()> {
pub fn set(_: &str, _: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
pub fn delete() -> Result<()> {
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)]
@@ -253,7 +489,7 @@ mod tests {
Credentials {
client_id: "PARTNER-ID".into(),
consumer_key: "consumer-key-0123456789".into(),
user_id: "terminal-rs-1".into(),
user_id: "terminal-1".into(),
user_secret: "user-secret-abcdef".into(),
}
}
@@ -313,4 +549,69 @@ mod tests {
assert_eq!(store.load().unwrap(), None);
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")]
#[test]
#[ignore = "writes to the login Keychain"]
fn keychain_moves_credentials_from_the_old_service() {
let pid = std::process::id();
let (service, legacy) = (format!("terminal-test-{pid}"), format!("terminal-rs-test-{pid}"));
let store = SecretStore::keychain(&service, Some(&legacy));
let new_only = SecretStore::keychain(&service, None);
let old_only = SecretStore::keychain(&legacy, None);
old_only.save(&creds()).unwrap();
assert_eq!(store.load().unwrap(), Some(creds()), "found under the old service");
assert_eq!(new_only.load().unwrap(), Some(creds()), "and moved to the new one");
assert_eq!(old_only.load().unwrap(), None, "the old item is gone");
// A stale item under the old service never replaces what the new one holds.
old_only.save(&Credentials { client_id: "STALE".into(), ..creds() }).unwrap();
assert_eq!(store.load().unwrap(), Some(creds()));
// Forgetting credentials removes both, so the old item can't bring them back.
store.clear().unwrap();
assert_eq!(old_only.load().unwrap(), None);
assert_eq!(store.load().unwrap(), None);
}
}
+10 -1
View File
@@ -256,18 +256,27 @@ impl SnapTrade {
Self::with_base(creds, &base)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_base(creds: Credentials, base: &str) -> Result<Self> {
if !creds.has_keys() {
bail!("SnapTrade client ID and consumer key are not set");
}
let http = reqwest::Client::builder()
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()?;
let base = Url::parse(base.trim_end_matches('/')).context("invalid SnapTrade base URL")?;
Ok(Self { http, base, creds })
}
/// SnapTrade doesn't answer cross-origin requests, so the browser build can't make a client
/// (and so never sends an order).
/// TODO(web proxy): call SnapTrade through a same-origin proxy that keeps the keys server-side.
#[cfg(target_arch = "wasm32")]
pub fn with_base(_creds: Credentials, _base: &str) -> Result<Self> {
bail!("SnapTrade isn't available in the browser yet; use the desktop app for brokerage accounts")
}
pub fn credentials(&self) -> &Credentials {
&self.creds
}
+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;
};
+4 -4
View File
@@ -93,7 +93,7 @@ async fn mock(seen: Arc<Mutex<Vec<Seen>>>, failures: Arc<Mutex<Vec<String>>>) ->
|| path.starts_with("/connection/")
|| path == "/snapTrade/login";
if user_scoped
&& (param("userId").as_deref() != Some("terminal-rs-test")
&& (param("userId").as_deref() != Some("terminal-test")
|| param("userSecret").as_deref() != Some("s3cr3t/+="))
{
fail("missing or wrong user credentials".into());
@@ -156,7 +156,7 @@ fn creds() -> Credentials {
Credentials {
client_id: "TEST-CLIENT".into(),
consumer_key: CONSUMER_KEY.into(),
user_id: "terminal-rs-test".into(),
user_id: "terminal-test".into(),
user_secret: "s3cr3t/+=".into(),
}
}
@@ -244,8 +244,8 @@ async fn registers_a_user_opens_the_portal_and_disconnects() {
let partner =
SnapTrade::with_base(Credentials { user_id: String::new(), user_secret: String::new(), ..creds() }, &base)
.unwrap();
let (id, secret) = partner.register_user("terminal-rs-test").await.unwrap();
assert_eq!((id.as_str(), secret.as_str()), ("terminal-rs-test", "fresh-secret"));
let (id, secret) = partner.register_user("terminal-test").await.unwrap();
assert_eq!((id.as_str(), secret.as_str()), ("terminal-test", "fresh-secret"));
let client = SnapTrade::with_base(creds(), &base).unwrap();
let url = client.connection_portal_url(true, None).await.unwrap();
+1 -1
View File
@@ -58,7 +58,7 @@ fn client(base: &str) -> SnapTrade {
let creds = Credentials {
client_id: "TEST".into(),
consumer_key: "key".into(),
user_id: "terminal-rs-test".into(),
user_id: "terminal-test".into(),
user_secret: "s".into(),
};
SnapTrade::with_base(creds, base).unwrap()
+2 -2
View File
@@ -80,7 +80,7 @@ async fn mock(seen: Log, failures: Log) -> String {
let body: Option<Value> = (!body.is_empty()).then(|| serde_json::from_slice(&body).unwrap());
let sig = headers.iter().find(|(k, _)| k == "signature").map(|(_, v)| v.clone()).unwrap_or_default();
let ok = sig == signature(CONSUMER_KEY, path, query, body.as_ref())
&& param(query, "userId").as_deref() == Some("terminal-rs-test")
&& param(query, "userId").as_deref() == Some("terminal-test")
&& param(query, "userSecret").as_deref() == Some("s3cr3t");
let entry = Seen { method: method.clone(), path: path.to_string(), body: body.clone() };
if !ok {
@@ -161,7 +161,7 @@ fn client(base: &str) -> SnapTrade {
let creds = Credentials {
client_id: "TEST-CLIENT".into(),
consumer_key: CONSUMER_KEY.into(),
user_id: "terminal-rs-test".into(),
user_id: "terminal-test".into(),
user_secret: "s3cr3t".into(),
};
SnapTrade::with_base(creds, base).unwrap()
+5 -2
View File
@@ -6,8 +6,9 @@ edition.workspace = true
[dependencies]
tr-core.workspace = true
egui = "0.36"
egui-wgpu = "0.36"
egui = { version = "0.36", default-features = false }
# GPU backends come from the app (eframe), so the browser build can leave out WebGL.
egui-wgpu = { version = "0.36", default-features = false }
bytemuck = { version = "1", features = ["derive"] }
chrono.workspace = true
serde.workspace = true
@@ -15,6 +16,8 @@ pollster = "0.4"
[dev-dependencies]
criterion.workspace = true
# Tests, benches and examples render offscreen on a real backend.
egui-wgpu = "0.36"
[[bench]]
name = "frame"
+1 -1
View File
@@ -1,4 +1,4 @@
//! GPU chart rendering for terminal-rs.
//! GPU chart rendering for Terminal.
//!
//! - [`viewport`]: bar-index/price <-> pixel math, pan and zoom.
//! - [`lod`]: level-of-detail pyramids that bound per-frame work to the plot width.
+6 -3
View File
@@ -1,16 +1,19 @@
[package]
name = "tr-congress"
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for terminal-rs"
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for Terminal"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
regex.workspace = true
# Ingest and storage: desktop only (the browser build has no SQLite and can't reach the sources).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
thiserror.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
+1 -20
View File
@@ -7,6 +7,7 @@ use anyhow::Result;
use chrono::{NaiveDate, Utc};
use rusqlite::{Connection, OptionalExtension, Row, params};
pub use crate::model::{Alert, PoliticianSummary};
use crate::model::{AmountRange, Chamber, ParsedTrade, Party, Politician, Trade, TxType};
const SCHEMA: &str = r#"
@@ -111,17 +112,6 @@ pub struct FilingRecord {
pub is_amendment: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Alert {
pub id: i64,
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub created_at: i64,
pub message: String,
pub seen: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Stats {
pub politicians: i64,
@@ -130,15 +120,6 @@ pub struct Stats {
pub scanned: i64,
}
/// A politician with aggregate trade counts, for list views.
#[derive(Debug, Clone, PartialEq)]
pub struct PoliticianSummary {
pub politician: Politician,
pub trades: i64,
pub last_filed: Option<NaiveDate>,
pub tracked: bool,
}
pub struct Db {
conn: Connection,
}
+60
View File
@@ -0,0 +1,60 @@
//! The browser build's stand-in for the SQLite store: always empty, because the browser
//! can't fetch disclosures yet. It keeps the desktop store's method signatures so callers
//! compile unchanged.
//!
//! TODO(web proxy): fill this from a same-origin proxy for Senate eFD and the House Clerk.
use std::path::Path;
use anyhow::{Result, bail};
pub use crate::model::{Alert, PoliticianSummary};
use crate::model::{Politician, Trade};
pub struct Db;
impl Db {
pub fn open(_path: impl AsRef<Path>) -> Result<Self> {
Ok(Self)
}
pub fn open_in_memory() -> Result<Self> {
Ok(Self)
}
pub fn latest_trades(&self, _limit: usize) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn trades_for_ticker(&self, _ticker: &str, _limit: usize) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn trades_for_politician(&self, _politician_id: i64) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn politician(&self, _id: i64) -> Result<Option<Politician>> {
Ok(None)
}
pub fn search_politicians(&self, _query: &str, _limit: usize) -> Result<Vec<PoliticianSummary>> {
Ok(Vec::new())
}
pub fn set_tracked(&self, _politician_id: i64, _tracked: bool) -> Result<()> {
bail!("tracking needs congressional data, which the browser build can't load yet")
}
pub fn is_tracked(&self, _politician_id: i64) -> Result<bool> {
Ok(false)
}
pub fn alerts(&self, _unseen_only: bool, _limit: usize) -> Result<Vec<Alert>> {
Ok(Vec::new())
}
pub fn mark_alerts_seen(&self) -> Result<()> {
Ok(())
}
}
+11
View File
@@ -1,12 +1,23 @@
//! STOCK Act congressional trade disclosures, ingested directly from the
//! official sources: the Senate eFD system and the House Clerk's PTR filings.
//!
//! The browser build (`wasm32`) has no SQLite and can't reach either site (they don't allow
//! cross-origin requests), so there [`db`] is an empty stand-in and there is no ingest.
#[cfg(not(target_arch = "wasm32"))]
pub mod db;
#[cfg(target_arch = "wasm32")]
#[path = "db_web.rs"]
pub mod db;
#[cfg(not(target_arch = "wasm32"))]
pub mod house;
pub mod model;
pub mod performance;
#[cfg(not(target_arch = "wasm32"))]
pub mod refresh;
#[cfg(not(target_arch = "wasm32"))]
pub mod roster;
#[cfg(not(target_arch = "wasm32"))]
pub mod senate;
pub use db::Db;
+30
View File
@@ -243,6 +243,36 @@ pub fn normalize_ticker(raw: &str) -> Option<String> {
RE.is_match(&t).then_some(t)
}
/// An alert raised when a tracked politician files new trades.
#[derive(Debug, Clone, PartialEq)]
pub struct Alert {
pub id: i64,
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub created_at: i64,
pub message: String,
pub seen: bool,
}
/// A politician with aggregate trade counts, for list views.
#[derive(Debug, Clone, PartialEq)]
pub struct PoliticianSummary {
pub politician: Politician,
pub trades: i64,
pub last_filed: Option<NaiveDate>,
pub tracked: bool,
}
/// A new filing by a tracked politician, found during a refresh.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NewTradeAlert {
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
+2 -9
View File
@@ -12,6 +12,7 @@ use tracing::{debug, info, warn};
use crate::db::{Db, FilingRecord, FilingStatus, NewPolitician};
use crate::house::{self, IndexEntry, PdfError};
pub use crate::model::NewTradeAlert;
use crate::model::{Chamber, Party};
use crate::roster::{self, Roster};
use crate::senate::{self, ReportKind, ReportRef};
@@ -58,14 +59,6 @@ impl RefreshConfig {
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NewTradeAlert {
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct RefreshReport {
pub new_filings: usize,
@@ -394,7 +387,7 @@ impl HttpSource {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(concat!(
"terminal-rs/",
"terminal/",
env!("CARGO_PKG_VERSION"),
" (STOCK Act disclosure reader; +https://github.com/)"
))
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tr-core"
description = "Core market types, timeframes, indicators and sample data for terminal-rs"
description = "Core market types, timeframes, indicators and sample data for Terminal"
version.workspace = true
edition.workspace = true
+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]
+2 -1
View File
@@ -1,10 +1,11 @@
//! Core market types shared across terminal-rs: bars, timeframes, securities and
//! Core market types shared across Terminal: bars, timeframes, securities and
//! market keys, the security catalog, technical indicators and sample data.
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");
+17 -2
View File
@@ -6,19 +6,34 @@ edition.workspace = true
[dependencies]
tr-core.workspace = true
tr-proto.workspace = true
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
tracing.workspace = true
async-trait = "0.1"
futures = "0.3"
web-time = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio.workspace = true
rusqlite.workspace = true
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
# The browser build: no threads, SQLite or raw sockets. Fetch and WebSocket come from the
# browser, and chrono reads the clock through JavaScript.
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
chrono = { workspace = true, features = ["wasmbind"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "WebSocket", "MessageEvent", "CloseEvent", "Event", "BinaryType"] }
send_wrapper = { version = "0.6", features = ["futures"] }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
+36 -32
View File
@@ -6,13 +6,12 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use chrono::DateTime;
use futures::{SinkExt, StreamExt};
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tr_core::{Bar, Security, Timeframe, catalog};
use crate::{LiveFeed, Quote};
use crate::ws::Socket;
use crate::{LiveFeed, Quote, rt};
const BINANCE_REST: [&str; 2] = ["https://data-api.binance.vision", "https://api.binance.com"];
const BINANCE_WS: [&str; 2] = ["wss://data-stream.binance.vision/ws", "wss://stream.binance.com:9443/ws"];
@@ -186,25 +185,37 @@ pub fn parse_coinbase_book(json: &str, symbol: &str, depth: usize) -> Result<cra
Ok(b)
}
const TIMEOUT: Duration = Duration::from_secs(20);
pub struct CryptoProvider {
client: reqwest::Client,
}
impl CryptoProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(20))
.build()?;
Ok(Self { client })
let client = reqwest::Client::builder();
// Browsers set their own User-Agent, and fetch takes its timeout per request.
#[cfg(not(target_arch = "wasm32"))]
let client = client.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
Ok(Self { client: client.build()? })
}
async fn get(&self, url: &str, query: &[(&str, String)]) -> Result<String> {
let resp = self.client.get(url).query(query).send().await?;
if !resp.status().is_success() {
bail!("GET {url}: HTTP {}", resp.status());
}
Ok(resp.text().await?)
let req = self.client.get(url).query(query);
#[cfg(target_arch = "wasm32")]
let req = req.timeout(TIMEOUT);
let fetch = async move {
let resp = req.send().await?;
if !resp.status().is_success() {
bail!("GET {url}: HTTP {}", resp.status());
}
Ok(resp.text().await?)
};
// A browser fetch holds JavaScript values, which are not `Send`. The web build has
// one thread, so asserting it is sound and keeps the provider traits shared.
#[cfg(target_arch = "wasm32")]
let fetch = send_wrapper::SendWrapper::new(fetch);
fetch.await
}
async fn binance_history(
@@ -363,7 +374,7 @@ impl CryptoProvider {
let (tx, rx) = mpsc::channel(256);
let stream = format!("{}@kline_{}", binance_symbol(sec).to_ascii_lowercase(), binance_interval(tf));
let product = coinbase_product(sec);
tokio::spawn(async move {
rt::spawn(async move {
let mut backoff = 1;
let mut attempt = 0usize;
while !tx.is_closed() {
@@ -378,7 +389,7 @@ impl CryptoProvider {
tracing::debug!("live feed {stream}: {e:#}");
}
attempt += 1;
tokio::time::sleep(Duration::from_secs(backoff)).await;
rt::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(30);
}
});
@@ -387,32 +398,25 @@ impl CryptoProvider {
}
async fn binance_ws(url: &str, tx: &mpsc::Sender<Bar>) -> Result<()> {
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.context("binance websocket")?;
while let Some(msg) = ws.next().await {
match msg? {
Message::Text(t) => {
if let Some(bar) = parse_binance_ws(t.as_str())
&& tx.send(bar).await.is_err()
{
return Ok(());
}
}
Message::Close(_) => break,
_ => {}
let mut ws = Socket::connect(url).await.context("binance websocket")?;
while let Some(text) = ws.next().await {
if let Some(bar) = parse_binance_ws(&text?)
&& tx.send(bar).await.is_err()
{
return Ok(());
}
}
bail!("binance websocket closed")
}
async fn coinbase_ws(product: &str, tf: Timeframe, tx: &mpsc::Sender<Bar>) -> Result<()> {
let (mut ws, _) = tokio_tungstenite::connect_async(COINBASE_WS).await.context("coinbase websocket")?;
let mut ws = Socket::connect(COINBASE_WS).await.context("coinbase websocket")?;
let sub = serde_json::json!({"type": "subscribe", "product_ids": [product], "channels": ["ticker"]});
ws.send(Message::Text(sub.to_string().into())).await?;
ws.send(sub.to_string()).await?;
// Aggregate ticks into the current bar; the chart merges partial bars into history.
let mut bar: Option<Bar> = None;
while let Some(msg) = ws.next().await {
let Message::Text(t) = msg? else { continue };
let Some((ts, px, size)) = parse_coinbase_ws(t.as_str()) else { continue };
while let Some(text) = ws.next().await {
let Some((ts, px, size)) = parse_coinbase_ws(&text?) else { continue };
let bucket = tf.bucket_start(ts);
let b = match bar.as_mut() {
Some(b) if b.ts == bucket => {
+123 -9
View File
@@ -11,14 +11,33 @@
//!
//! 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. 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;
#[cfg(not(target_arch = "wasm32"))]
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;
/// 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;
use std::sync::Arc;
@@ -29,6 +48,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tr_core::{AssetClass, Bar, Security, Timeframe, catalog};
#[cfg(not(target_arch = "wasm32"))]
pub use cache::BarCache;
/// Latest quote for a symbol.
@@ -137,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 {
@@ -183,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.
@@ -254,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();
@@ -306,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
@@ -318,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());
}
@@ -329,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
}
}
}
@@ -349,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
@@ -360,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
@@ -367,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),
@@ -383,7 +440,19 @@ 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 {
// reqwest and the websocket client both use rustls; pin one crypto provider.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
@@ -398,12 +467,39 @@ 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. 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) => {
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:#}"),
}
}
Arc::new(sample::SampleProvider::default().with_live_ticks())
}
/// Keep at most `limit` bars ending at or before `end`.
pub(crate) fn clip(mut bars: Vec<Bar>, end: Option<i64>, limit: usize) -> Vec<Bar> {
if let Some(end) = end {
@@ -474,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
+132
View File
@@ -0,0 +1,132 @@
//! Background tasks and timers on either target: tokio on the desktop, the browser's event
//! loop on the web (`wasm32`), where there is one thread and no tokio runtime.
use std::future::Future;
use std::time::Duration;
use web_time::Instant;
/// Run `fut` in the background.
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn<F: Future<Output = ()> + Send + 'static>(fut: F) {
tokio::spawn(fut);
}
/// Run `fut` in the background.
#[cfg(target_arch = "wasm32")]
pub fn spawn<F: Future<Output = ()> + 'static>(fut: F) {
wasm_bindgen_futures::spawn_local(fut);
}
/// Run CPU-bound work off the async workers; `None` if it panicked. The web has one thread,
/// so there it runs inline.
#[cfg(not(target_arch = "wasm32"))]
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
tokio::task::spawn_blocking(f).await.ok()
}
/// Run CPU-bound work off the async workers; `None` if it panicked. The web has one thread,
/// so there it runs inline.
#[cfg(target_arch = "wasm32")]
pub async fn blocking<T>(f: impl FnOnce() -> T) -> Option<T> {
Some(f())
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn sleep(d: Duration) {
tokio::time::sleep(d).await;
}
/// 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 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 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
/// tick fires immediately and the next one is a full period after it.
pub struct Interval {
period: Duration,
next: Instant,
}
pub fn interval(period: Duration) -> Interval {
Interval { period, next: Instant::now() }
}
impl Interval {
/// Wait for the next tick. Dropping the future part-way (in `select!`) loses nothing.
pub async fn tick(&mut self) {
let now = Instant::now();
if self.next > now {
sleep(self.next - now).await;
}
self.next = self.next.max(Instant::now()) + self.period;
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[tokio::test]
async fn interval_spaces_ticks() {
let mut iv = interval(Duration::from_millis(20));
let t0 = Instant::now();
iv.tick().await;
assert!(t0.elapsed() < Duration::from_millis(15), "first tick is immediate");
iv.tick().await;
iv.tick().await;
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));
}
}
+27 -4
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, 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()?;
@@ -74,8 +97,8 @@ impl MarketData for SampleProvider {
let (tx, rx) = mpsc::channel(64);
let tick_vol = sec.class.sample_vol() * (0.4 / (365.0 * 86_400.0f64)).sqrt() * 8.0;
let mut rng = sample::Rng::new(sample::seed_for(symbol) ^ 0x5eed);
tokio::spawn(async move {
let mut iv = tokio::time::interval(Duration::from_millis(400));
rt::spawn(async move {
let mut iv = rt::interval(Duration::from_millis(400));
loop {
iv.tick().await;
let now = chrono::Utc::now().timestamp();
+1 -1
View File
@@ -113,7 +113,7 @@ pub struct TreasuryProvider {
impl TreasuryProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
.user_agent("Mozilla/5.0 (compatible; terminal)")
.timeout(Duration::from_secs(30))
.build()?;
Ok(Self { client, years: Mutex::new(HashMap::new()) })
+177
View File
@@ -0,0 +1,177 @@
//! Market data for the browser build.
//!
//! 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. 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.
use anyhow::Result;
use async_trait::async_trait;
use tr_core::{AssetClass, Bar, Timeframe};
use crate::crypto::CryptoProvider;
use crate::sample::SampleProvider;
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, is_perp, perp_needs_server, security};
pub struct WebMarkets {
crypto: CryptoProvider,
sample: SampleProvider,
}
impl WebMarkets {
pub fn new() -> Result<Self> {
Ok(Self { crypto: CryptoProvider::new()?, sample: SampleProvider::default() })
}
}
/// 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
}
#[async_trait]
impl MarketData for WebMarkets {
fn name(&self) -> &str {
"binance+sample"
}
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 {
self.sample.history(symbol, tf, end, limit).await
}
}
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
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();
match self.crypto.quotes(&secs).await {
Ok(q) => out.extend(q),
Err(e) if out.is_empty() => return Err(e),
Err(e) => tracing::warn!("crypto quotes: {e:#}"),
}
}
Ok(out)
}
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
is_crypto(symbol).then(|| CryptoProvider::live(&security(symbol), tf))
}
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
let sec = security(symbol);
if sec.class == AssetClass::Crypto {
self.crypto.order_book(symbol, &sec, depth).await
} else {
anyhow::bail!("no public order book for {} in the browser", sec.key())
}
}
async fn yield_curves(&self) -> Result<YieldCurves> {
self.sample.yield_curves().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn non_crypto_comes_from_sample_data() {
let m = WebMarkets::new().unwrap();
let bars = m.history("AAPL US Equity", Timeframe::D1, None, 50).await.unwrap();
assert_eq!(bars.len(), 50);
let q = m.quotes(&["AAPL US Equity".into(), "EURUSD Curncy".into()]).await.unwrap();
assert_eq!(q.len(), 2);
assert!(m.live("AAPL US Equity", Timeframe::M1).is_none());
assert!(m.order_book("AAPL US Equity", 10).await.is_err());
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");
}
}
+181
View File
@@ -0,0 +1,181 @@
//! 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 as WsMessage;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use super::Message;
pub struct Socket(WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>);
impl Socket {
pub async fn connect(url: &str) -> Result<Self> {
let (ws, _) = tokio_tungstenite::connect_async(url).await.with_context(|| format!("websocket {url}"))?;
Ok(Self(ws))
}
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 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(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;
}
}
}
#[cfg(target_arch = "wasm32")]
mod web {
use anyhow::{Result, anyhow, bail};
use tokio::sync::mpsc;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
use super::Message;
enum Event {
Open,
Message(Message),
Error,
Closed,
}
/// Browser websocket; its callbacks feed a channel. Closed when dropped.
pub struct Socket {
ws: WebSocket,
rx: mpsc::UnboundedReceiver<Event>,
_on_open: Closure<dyn FnMut()>,
_on_message: Closure<dyn FnMut(MessageEvent)>,
_on_error: Closure<dyn FnMut()>,
_on_close: Closure<dyn FnMut(CloseEvent)>,
}
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 || {
let _ = t.send(Event::Open);
});
let t = tx.clone();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
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 || {
let _ = t.send(Event::Error);
});
let on_close = Closure::<dyn FnMut(CloseEvent)>::new(move |_| {
let _ = tx.send(Event::Closed);
});
ws.set_onopen(Some(on_open.as_ref().unchecked_ref()));
ws.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
ws.set_onerror(Some(on_error.as_ref().unchecked_ref()));
ws.set_onclose(Some(on_close.as_ref().unchecked_ref()));
let mut socket =
Self { ws, rx, _on_open: on_open, _on_message: on_message, _on_error: on_error, _on_close: on_close };
match socket.rx.recv().await {
Some(Event::Open) => Ok(socket),
_ => bail!("websocket {url}: connection failed"),
}
}
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 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::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 {
fn drop(&mut self) {
// Detach the callbacks before their closures are freed.
self.ws.set_onopen(None);
self.ws.set_onmessage(None);
self.ws.set_onerror(None);
self.ws.set_onclose(None);
let _ = self.ws.close();
}
}
}
+2 -2
View File
@@ -25,7 +25,7 @@ impl YahooProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
// Yahoo rejects requests without a browser-like user agent.
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
.user_agent("Mozilla/5.0 (compatible; terminal)")
.timeout(Duration::from_secs(20))
.cookie_store(true)
.build()?;
@@ -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));
}
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more