Files
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

42 lines
971 B
JSON

{
"name": "terminal-site",
"private": true,
"version": "0.0.1",
"type": "module",
"engines": {
"node": ">=22.17"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-vercel": "^7.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"svelte": "^5.57.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.3.0"
},
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
},
"dependencies": {
"@turnkey/core": "2.13.1",
"@turnkey/iframe-stamper": "2.11.1",
"@upstash/redis": "^1.39.0",
"@vercel/analytics": "^2.0.1"
},
"overrides": {
"@vercel/analytics": {
"@sveltejs/kit": "$@sveltejs/kit"
}
}
}