feat(perp): Hyperliquid perpetual trading and bridge commands - #467
feat(perp): Hyperliquid perpetual trading and bridge commands#467kome12 wants to merge 30 commits into
Conversation
Add `nansen bridge` top-level command for EVM <-> Hyperliquid bridging: - `nansen bridge quote` — get bridge quotes via nansen-api/Relay - `nansen bridge execute` — sign and broadcast (EVM txs or EIP-712 for HL) - `nansen bridge status` — check bridge completion Reuses existing signing infra (signEvmTransaction, hashTypedData, signSecp256k1) and wallet providers (local + Privy). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add `nansen perp` top-level command for Hyperliquid perpetual trading: - `nansen perp order` — place market/limit orders with optional TP/SL - `nansen perp cancel` — cancel by order ID - `nansen perp close` — close position (reduce-only market) - `nansen perp leverage` — set leverage and margin mode - `nansen perp positions` — view open positions - `nansen perp orders` — view open orders - `nansen perp account` — view account state - `nansen perp meta` — view available assets Calls nansen-api /api/v1/perp/* endpoints. API prepares unsigned EIP-712 data, CLI signs with existing hashTypedData + signSecp256k1, then proxies signed action to Hyperliquid via /perp/execute. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The perp path coerced --side and --margin-type to booleans before anything
reached the backend, so a typo silently flipped to the false branch (short /
isolated) with real capital. Negative and non-numeric sizes/prices also slipped
through to the signer.
- perp order/close: validate --side against an allowlist (buy/long/sell/short
for order, buy/sell for close) before signing.
- perp leverage: validate --margin-type against {cross, isolated}.
- perp order/close/leverage/cancel: validate --size/--price/--leverage/--oid as
positive numbers, with specific messages instead of the generic usage banner.
- bridge execute: mark a quote consumed once funds are broadcast and refuse a
quote already executed, so a retry after a timeout can't double-bridge.
Add unit tests for perp validation and the bridge quote replay guard.
…d quotes Follow-up hardening across the trading commands: - perp: require an EVM wallet instead of querying for an "undefined" address when a wallet has no EVM key. - trade quote: validate --quote-index is in range, and bound --slippage / --max-auto-slippage to a 0..1 decimal so a percent-vs-decimal mix-up can't become a 100x slippage tolerance. - limit-order list: tolerate a non-integer amount from the backend instead of letting BigInt() throw and blank the whole list; use 10n ** BigInt(decimals). - quotes: tag swap quotes with a type and reject cross-type loads (a bridge quote run through `trade execute`, or a swap quote through `bridge execute`). Add tests for each.
…in, errors)
- perp meta: add --all and --filter <text> so assets past the first 20
(e.g. HYPE) are listable from the CLI.
- deprecated aliases: print the deprecation notice on stderr when the command
is actually run, not only in --help.
- limit-order: reject a zero-duration ("0h"/"0d") or past expiry instead of
silently creating an order that expires on arrival.
- keychain: strip only the trailing newline the OS tool appends instead of
trimming all whitespace, so passwords with leading/trailing spaces work.
- api: tighten the nested-error-message regex so a message containing an
apostrophe isn't truncated.
Add tests for each.
The branch repurposes top-level 'perp' from a deprecated alias for 'research perp' into the Hyperliquid trading command group, so it was correctly removed from DEPRECATED_TO_RESEARCH. Update the stale assertion to match and lock in the new behavior with a negative check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ain decimals Bridge --amount was always raw base units, but base-unit decimals differ by chain (USDC is 6 on EVM chains, 8 on Hyperliquid), so the same "5000000" is $5 on Base but $0.05 on HL — a silent 100x error. Add --amount-unit token|usd (default stays base units, matching `trade quote`). With it, the source token's decimals are resolved per chain (USDC hardcoded 6/EVM, 8/HL; other EVM tokens via an on-chain decimals() call) and the human amount is converted client-side. Hyperliquid USDC is additionally floored to the bridge's 6-decimal precision, mirroring Superapp's SUPER-13582 handling, so the Relay bridge's round-half-up can't push the amount past the wallet balance. Add unit tests for the resolver + flooring and an end-to-end conversion test.
…s fallback An --amount-unit value other than token/usd (e.g. a typo like "tokens") silently fell through to base-units mode, which can re-open the per-chain magnitude trap --amount-unit was added to prevent. Validate against the allowlist (case-insensitively) and reject anything else with an actionable error, matching the enum-validation approach used for perp --side/--margin-type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Over-max --leverage previously failed only at the backend with an opaque rejection. Look up the asset's max_leverage from meta and reject a too-high value client-side with a clear message before signing. Falls open if meta is unavailable or the coin isn't listed, so it never blocks an otherwise-valid request. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catch four classes of bad input before signing/sending to the backend: - NEW-1: parsePositiveNumber rejected trailing garbage by relying on parseFloat, so --size 100abc silently became 100. Add a strict /^\d*\.?\d+$/ check before parseFloat (covers --size and --price). - NEW-2: parsePositiveInt floored fractional input via parseInt, so --leverage 2.5 silently became 2. Add a digits-only /^\d+$/ check (covers --leverage and --oid). - NEW-3: --tif and --type were forwarded unvalidated. Add assertTif (Gtc/Ioc/Alo) and assertOrderType (limit/market) allowlists, case-insensitively normalised to the canonical value Hyperliquid expects (so --type LIMIT is accepted). - NEW-4: perp close didn't check direction. Fetch open positions and reject a wrong --side (sell closes a long, buy closes a short) with a clear message instead of the backend's opaque "reduce only would increase position". Falls open if positions can't be fetched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These order/close options still used raw parseFloat, so trailing garbage (--slippage 0.03abc), out-of-range values, and bad trigger prices slipped through to the backend. - Add parseSlippage: strict decimal in [0, 1] (mirrors the DEX --slippage check), rejecting trailing garbage and percent-vs-decimal mix-ups. - Validate --take-profit / --stop-loss with parsePositiveNumber. - Move the optional-arg parsing below the usage check so a missing required arg still shows usage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ECINT-6824 — duplicate-flag crashes: the arg parser collects a repeated
flag into an array. Perp string guards (--coin, --side) crashed with an
opaque "...is not a function". Add a scalar() guard that rejects a
repeated flag with a clear message across all perp options (the numeric
guards already rejected arrays via String() coercion).
ECINT-6826 N2 — coded errors: perp guards now throw CommandError with
'INVALID_INPUT' (usage banners use 'MISSING_PARAM') instead of bare
Error, so agents can branch on the code.
ECINT-6826 N3 — unified error envelope: route CommandError through
formatError in the runCLI catch block so perp/bridge/trade all emit
{success:false, error, code, status, details}. A CommandError's
structured data (e.g. PASSWORD_REQUIRED resolution steps) is preserved
under `details`. Success output is unchanged.
ECINT-6826 N4 — password message: distinguish "no password configured"
from "wrong password" in the perp signing path; reuse the
PASSWORD_REQUIRED message + resolution steps like trade/limit-order.
ECINT-6827 — docs/schema/--symbol: accept --symbol as an alias for
--coin; add the perp command tree to schema.json (now surfaced by
`nansen schema`); document perp trading in README and the nansen-trading
skill.
ECINT-6828 — perp account "Total PnL": sum per-position unrealized PnL
from assetPositions (already in the response) and relabel "Unrealized
PnL"; marginSummary.totalRawUsd is account collateral, not PnL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…API-14) New `nansen perp transfer --direction spot-to-perp|perp-to-spot --amount <usdc>` subcommand: moves USDC between a wallet's Spot and Perps balances via the new nansen-api /perp/transfer route, signed through the existing prepare/sign/execute flow (the generic signAgent already handles the user-signed EIP-712, so no new crypto). Validated with the shared scalar/parsePositiveNumber/coded-error guards. Also: `perp account` now shows the Spot USDC balance (from the account response), so funds that land in Spot via Hyperliquid "Send" are visible instead of looking like an empty account. Adds the command to the help banner, schema.json, the nansen-trading skill, and README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`bridge quote --amount-unit usd` failed from Hyperliquid because HL's USDC uses a sentinel address (0x000…0) the price API can't resolve, throwing "Could not resolve USD price". Since USDC is USD-pegged, short-circuit its price to $1 (via the existing isBridgeUsdc check) instead of calling the price API. Non-stable tokens still resolve a live price as before. Scoped to USDC only — the peg-deviation error is far under the bridge's default 0.5% slippage, and it only ever moves the user's own funds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…executed size/price - bridge quote: validate --slippage as whole basis points in [0, 10000], rejecting non-numeric/out-of-range values client-side with a clear message instead of forwarding them to an opaque backend 422 (mirrors perp's --slippage validation). - perp order/close: warn before signing when --size (or --price for order) is finer than the asset's Hyperliquid precision, which the exchange silently rounds. szDecimals comes from /perp/meta; fail open if meta is unavailable. - perp order/close: print the size and price the order actually executes at (post-rounding, slippage-adjusted for market orders) via the new prepare response fields, falling back to the signed order wire for an older backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chunk 1 of moving perp trades to direct-to-HL submission: build the L1 action + EIP-712 phantom-agent payload locally instead of asking the backend to build it. - msgpack encoder matching msgpack.packb byte-for-byte (ordered maps, smallest-width ints, utf-8 strings) so connectionId hashes match. - actionHash + l1Eip712 reproduce the Exchange-domain phantom agent the existing signAgent() already signs. - order/cancel/close/updateLeverage wire assembly, TP/SL normalTpsl grouping, builder-code attachment. - roundPrice/roundSize with Python-parity banker's rounding. - approveBuilderFee / usdClassTransfer user-signed payload builders. Correctness pinned against the live /perp/* prepare endpoints via golden-vector tests (action + connectionId match byte-for-byte). 18/18 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chunk 2: the one client-egress network call. submitExchange() POSTs a signed action straight to api.hyperliquid.xyz/exchange; reads and market-data stay on the Nansen proxy (Decision D4). Reproduces the backend proxy's (perp_execute.py) failure contract that it replaces: - top-level status "err" -> throw with the reason. - status "ok" with a per-action error in response.data.statuses[].error -> throw (ported extract_action_errors), so a rejected order can't be mistaken for a fill. Not retried (unique nonce, non-idempotent). Base URL overridable via NANSEN_HL_API_URL for testnet/tests. 10 unit tests via injected fetch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (Ch3-5)
Rewire the mutating perp commands off the proxy prepare/execute round-trip
onto client-side build + direct submission to api.hyperliquid.xyz.
- Ch3: replace prepareSignExecute with buildScreenSignSubmit — fetch asset
meta from the proxy, build the action locally (hl-action.js), sign, and
submit direct (hl-client.js). Reads and builder-fee status stay on the
proxy. Meta fetch is fail-open so it can't preempt clearer wallet/password
errors; requireAsset re-checks and aborts (META_UNAVAILABLE) before build.
resolveSigningCtx unifies the local-key/Privy paths across all commands.
- Ch4: per-trade OFAC screening (screenOrThrow -> /api/v1/sanctions/screen)
before signing; fail-closed on sanctioned / non-200 / network error / any
requested address missing from the results.
- Ch5: attach the {b,f} builder code (single-source /perp/builder-fee) to
order/close, auto-fire the one-time approveBuilderFee before the first
trade, and add a standalone `perp approve-builder-fee` command.
All 57 existing perp tests unchanged + 7 new (screening abort, fail-closed,
meta-required, builder attach, auto-approval, user-signed transfer). Full
suite green, lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two collisions needed resolving, both where main added something to a surface this branch had rewritten: src/rpc-urls.js — main added a `bsc` RPC entry for the x402 payment path while this branch added `bnb` for bridge/perp. Same chain (56), two spellings, each read by different code (x402.js reads CHAIN_RPCS.bsc; chain-ids.js and bridge.js use 'bnb'), so both keys are kept with the default each side shipped. Collapsing them would change which RPC the payment path talks to, which is unrelated to this branch. src/cli.js — this branch re-pointed bare `perp` at the trading dispatcher, which silently shadowed the pre-existing perp analytics command that main had just extended with new screener filters. git merged the file clean; the breakage was semantic. `nansen perp screener` and `nansen research perp screener` both threw "Unknown perp subcommand" (research dispatches through cmds[category], so both paths were affected). The dispatcher now falls through to the captured analytics handler for screener/leaderboard, and lists them in `perp help`. Verified: full suite green (1638 passed, 2 skipped — 7 were failing before the fall-through fix); `nansen perp screener` and `nansen research perp screener` both return live data; unknown subcommands still error with the combined list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
markBridgeQuoteExecuted ran once after the whole step loop, so a multi-step bridge that broadcast step 1 and then threw on step 2 left the quote unspent. A retry re-ran from step 0 and re-broadcast step 1 with a fresh nonce — the exact double-send the single-use marker exists to prevent. (Bounded in practice: Relay bridges are usually single-step, and where there are two the first is an ERC-20 approve rather than a fund move.) Marking now happens after every broadcast. executedAt is pinned on the first call so the quote is spent the moment anything goes out, and the step counters make the partial case legible: loadBridgeQuote reports "N of M steps were broadcast" and points at `bridge status`, rather than implying nothing happened. The count is monotonic so a stale marker can't reopen a spent quote as partial. Also adds a changeset for the error-envelope unification this branch introduced: routing every CommandError through formatError changed the top-level JSON shape for pre-existing trade/limit-order/API-key errors (payload preserved under `details`). Consumer-visible, so it needs release notes and a minor bump. Both raised in PR review. The third point there — that bridge does no client-side sanctions screening unlike perp — is intended and already covered: the API screens the plaintext sender on bridge/quote and cryptographically recovers plus screens the signer, vault and resolved master on bridge/execute, fail-closed. Bridge egress goes through that proxy, so server-side screening is authoritative; perp needs a local check only because it submits direct to HL. Verified: 1642 tests pass (5 new), eslint clean, and both refusal messages confirmed against the real module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…adcast Two issues raised in review, both about the gap between "we decided this is fine" and "funds actually move". Screening: perp re-screens the signing wallet fail-closed immediately before it signs, but bridge execute went from loadBridgeQuote() straight to credentials and signing. The quote was screened server-side when issued, but quotes live an hour, and — the part that matters — the EVM deposit leg broadcasts via eth_sendRawTransaction to a public RPC, so it never transits our backend at execute time. Only the HL-signature leg is proxied. So for a deposit, nothing re-checked the wallet between the quote and the fund-moving transaction. Now screenOrThrow runs before credentials are resolved (exported from perp.js rather than duplicated; worth its own module if a third caller appears). Replay marking: the previous fix marked after each STEP, but processEvmStep broadcasts and then waits for the receipt, so a tx accepted by the network whose receipt wait timed out left the quote unspent and a retry re-signed it. Marking now happens at each individual broadcast, before any receipt wait — and per item, since one step can carry several fund-moving items. Each broadcast is persisted with its tx hash, so the refusal names what is already in flight instead of implying nothing happened. eslint caught that the Privy signature path had the callback threaded but never invoked, which would have left that path unmarked; both of its POST sites now mark. All five broadcast points are covered: EVM raw tx, and the relay/HL POSTs in the local and Privy variants. Verified: 1650 tests pass (12 new, incl. the sanctioned / screening-unavailable / unverifiable-response gates and the receipt-timeout case), eslint clean, CLI runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e/wallet mismatch execute screened quoteData.walletAddress but resolved signing credentials separately from --wallet / the current default, so a changed default or an explicit --wallet could screen wallet A and sign with wallet B — moving funds from an address that was never checked. The cached tx data (nonce, from) belongs to the quote's wallet regardless, so a mismatch was never going to be correct. Now the signing wallet is resolved first, a mismatch against the quote is refused outright, and screening runs on that resolved address — which is provably the one that signs. The Privy branch reuses the same resolution instead of resolving a second time. Raised in review. 1652 tests pass (2 new: mismatch refused before screening with the quote left unconsumed, and screening receiving the signer's address), eslint clean, CLI runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code Review —
|
| Check | Result |
|---|---|
perp account / positions / orders / meta |
✅ |
perp order (ETH limit, resting) + perp cancel |
✅ |
perp leverage |
✅ |
bridge quote (HL → Base) |
✅ |
bridge execute (HL → Base specifically) |
✅ |
bridge execute (Base → HL) |
✅ |
| All unit tests (1650) | ✅ |
Prod API endpoints (/perp/meta, /perp/account, /perp/positions) |
✅ |
Suggested merge path
- Fix C1 (
CHAIN_MAP+ test) + M1 (usage banners) — highest blast radius - Fix M2 (schema) — required by
CLAUDE.md - Fix M3 (
cache: false,retry: false) + M5 (throw on missing EIP-712 types) — both are a few lines - M4, M6, M7, M8 can follow in a fast-follow if time-boxed, but M6.1 (misleading password error on
bridge execute) will generate support noise immediately
bridge quote accepted ethereum/arbitrum/polygon/bnb as deposit origins, but local EVM signing only supports Base — so a quote succeeded, wrote a quote file, and bridge execute then failed with "Unsupported EVM chain". Nothing was at risk (it throws before broadcast) but four of the five advertised EVM origins could not complete. Replace the flat chain set with an explicit [origin, destination] route matrix and reject unsupported routes at quote time. The matrix is asymmetric on purpose: deposits broadcast an EVM transaction locally and so need a signable origin chain, while withdrawals sign a Hyperliquid action and never touch the destination chain, so they mirror the API's supported pairs. polygon/bnb drop out entirely — neither direction supports them. Widening the deposit side means teaching signEvmTransaction the chain and putting real funds through it first. This also catches routes the API would have rejected server-side (base -> arbitrum, hyperliquid -> polygon) with a clearer local error. Verified with live quotes against prod: the four deposit origins are now refused before any API call, base -> hyperliquid still quotes, and all three kept withdrawal routes return well-formed quotes. Tests: 16 added. The withdrawal ones assert signEvmTransaction is never reached, with a deposit control test proving the spy is wired — that asymmetry is the whole justification for keeping hyperliquid -> ethereum and hyperliquid -> arbitrum, so it is pinned rather than commented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…chema M1 — the unified error envelope routed missing-argument banners through formatOutput, so every "you forgot an argument" response came back as a one-line JSON blob with the newlines escaped. The banners are multi-line help written to be read, so this hit the whole CLI, not just the new commands (`nansen trade quote` with no args was the reviewer's example). Usage codes (MISSING_PARAM, MISSING_ARGS) now print the message as written when stdout is an interactive terminal and no output format was requested. Piped output and any --pretty/--table/--format csv/--stream run still get the envelope, so nothing parsing the CLI sees a new shape. isTTY is injectable so both renderings are covered by tests. bridge's own usage banners threw plain Errors (code UNKNOWN), so they now carry MISSING_PARAM like the perp ones already did. M2 — bridge was missing from schema.json entirely, so agents driving the CLI off `nansen schema` could not discover a whole top-level command group. Added with all three subcommands, their real options, and the supported route list. `bridge --help` and `bridge <sub> --help` render from the schema, so they produced nothing before this. Also corrected the mutating perp subcommands, which still named /api/v1/perp/* as their endpoint after the direct-submit refactor. They now declare submitsTo: api.hyperliquid.xyz/exchange plus an apiEndpoints list of the routes they actually read (screening, meta, builder-fee). endpoint is what the help renderer resolves a credit cost against, so naming an uncalled route was wrong twice over; those internal endpoints are absent from the public cost map, so no cost was being misreported today. Read-only subcommands keep their endpoint unchanged. Verified in a real pty: banners render as text, piped runs still emit MISSING_PARAM JSON, and bridge/perp help both render. 24 tests added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…EIP-712
M3 — every perp/bridge API call now passes cache: false, and bridge
execute also passes retry: false.
- Compliance screening must be a live check. Each mutating command
re-screens the signing wallet immediately before signing; under
--cache that verdict could come from a store written up to five
minutes earlier, which is the exact window the check closes.
- postBridgeExecute proxies Relay /authorize and HL /exchange, neither
idempotent, so the inherited 3x retry on 500/502 could submit the
same signed action twice. hl-client documents this for the direct
path; this is the proxied one.
- Bridge status polling was broken under --cache, which the review did
not cover: the cache key is endpoint + body, so every poll for a
request id is the same key and the loop re-read one stale verdict for
the whole TTL instead of observing completion.
- Perp reads and bridge quotes bypass it too — they either report live
balances or feed a signing decision (close sizes its order from
positions, asset ids come from meta).
M5 — signEip712Local refused to sign when types[primaryType] yields no
fields. `|| []` made that case silent: hashStruct hashes
typeHash("PrimaryType()") over none of the message, so we returned a
well-formed signature committing to nothing about the action. Guarding in
signEip712Local covers both the Relay authorize and HL action branches,
and a primaryType matching no key fails the same way as an absent map.
The error names the step.
Verified: cache bypass confirmed on disk — a --cache run of bridge quote
and the perp reads writes no cache entry, while a cacheable research call
does, so the empty cache is the bypass and not an inert cache. Live perp
meta/account and bridge quote still work. 13 tests added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A legacy type-0 transaction pays exactly its gasPrice, so once the base fee passes that value it is not slow — it is permanently unincludable at that nonce. signEvmTransaction now emits type 2 when the quote carries fee caps (the normal case: both the trading API and Relay supply them) and keeps legacy only for gasPrice-only quotes. Closes the standing TODO. bridge execute was also overwriting Relay's maxFeePerGas / maxPriorityFeePerGas with a bare eth_gasPrice reading, which priced the transaction at roughly the current base fee with ~0.001 gwei of priority — below what Base schedules. resolveEvmStepFees keeps the quoted fees, floors the priority fee at 0.01 gwei (above the observed p90 reward of 0.0066), and lifts the cap to 3x base fee + priority so a rising base fee cannot strand it. The DEX path was less exposed only because it passes the aggregator's transaction through untouched. Also: refuse to sign when a quote supplies no gas information at all, rather than falling back to a 1 wei gasPrice that burns the nonce on an unmineable transaction; and raise the receipt wait from 30s to 180s, since the transaction is already broadcast by then and giving up early reports a failure without undoing anything. Verified against Base with real funds: two transactions built by this code were included and executed (type=0x2, status=0x1), which is the chain validating the envelope, signature and sender recovery. 16 tests added, including RLP field-order pinning and signature recovery to the signing address. Known gap, not addressed here: getEvmNonce reads the 'pending' count, so a stale queued transaction inflates it and the next signature goes out on an unexecutable future nonce. That compounded across retries during testing (nonce 32 against an account at 20) and needs nonce reconciliation plus a fee-override escape hatch before the Base -> HL deposit leg can be re-tested end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A random 64-hex literal reads as a real private key to secret scanners. Use the same dummy scalar as perp.test.js; the recovery test derives its expected address from the key, so nothing depends on the value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the HL network
Review follow-ups M4, M6, M7, M8 plus the related minors.
M4 — the builder fee arrives from the API, and approveBuilderFee authorises a
*maximum* rate on Hyperliquid, so an unbounded value would have been signed as
given. Bound it at its single entry point (fetchBuilderFee), which covers both
the per-order builder code and the approval derived from the same number, and
name the rate and beneficiary in the log before signing. The ceiling is the
published rate rather than a loose multiple: a fee change should ship as a
release, not take effect silently on every installed client.
M6/M7 — bridge.js never got the wallet hardening perp.js has. Both copies of the
wallet/key resolution now live in wallet-signing.js, so bridge execute reports
PASSWORD_REQUIRED instead of a misleading "Incorrect password" for a password
that was never entered, refuses a wallet with no EVM address instead of sending
wallet_address: null, and resolves the wallet once for both the screening and the
signature instead of twice. --recipient and a base-unit --amount are validated
client-side; a decimal in base units is a units mix-up, not a small amount.
M8 — source ('a'/'b') and hyperliquidChain ('Mainnet'/'Testnet') were hardcoded,
so NANSEN_HL_API_URL pointed at the testnet signed mainnet-shaped actions the
testnet rejects. Both now derive from the resolved base URL via hl-env.js, which
is its own module so the action builders don't have to import the submit client.
Minors: drop effectiveOrderValues (dead since the direct-to-HL refactor); refuse
a usdClassTransfer amount that toFixed() would render as "1e+21"; report the poll
error in bridge polling instead of a bare "poll error"; stop emitting
"--request-id undefined" in the timeout hint; and route `research perp help` to
the analytics help rather than advertising trading subcommands it can't run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…recovery path The deposit leg that would not mine was a double hex-decode, not fee pricing. getEvmNonce returns a decimal number, and bridge.js ran parseInt(nonce, 16) over it: parseInt(20, 16) is 32, so a wallet at nonce 20 signed at 32 and no node could execute it. Present since the bridge was added (63e238f) and invisible below nonce 10, where decimal and hex digits coincide — which is why earlier bridge testing never hit it. "Nonce 32 signed against an account at 20" was this, not a pending-count feedback loop. getEvmNonce now also reconciles pending against latest. pending is still what callers want, since the bridge numbers its approve and deposit steps back to back, but a transaction that cannot be mined holds the count up and every later signature queues behind it — visible only as "no receipt". Past a gap of 2 it refuses, names the stuck nonce, and gives the command to replace it. Its doc comment now states the return is decimal. Recovery, which did not exist: fees were recomputed identically, so no retry could replace a stuck transaction — every attempt returned "replacement transaction underpriced", and clearing one needed a hand-written script. bridge execute takes --priority-fee/--max-fee (gwei, converted digit-wise so 0.05 is exactly 50000000 wei) and --nonce, which together are a replacement: reuse the stuck nonce, outbid the original. --nonce deliberately bypasses the reconciliation above, since reusing a queued nonce is the whole point, and it numbers a multi-step quote consecutively from there. Overrides are reported before signing and refused on a withdrawal leg, which has no transaction to price. Verified with real funds: Base -> Hyperliquid, 5 USDC, first attempt. Approve and deposit mined at nonces 20 and 21 (blocks 49250636/49250637, type 0x2). Base USDC -5.000000, Hyperliquid +4.979524 against a quoted 4.979523, gas 0.00000164 ETH. This completes the leg PR #467 had never landed; the withdrawal leg passed on 2026-07-28. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All of C1 and M1–M8 are done, plus the five minors you listed — pushed as Two things need you:
While finishing the Base → HL leg I found why it never landed: Base → HL, 5 USDC, first attempt, nonces 20/21 in blocks 49250636/49250637 — the deposit leg is now verified with real funds, as the withdrawal leg was. The recovery flags are unit-tested only; nothing was stuck to try them against. |
Adds two command families to the CLI:
nansen perpfor Hyperliquid perpetual trading, andnansen bridgefor moving funds between EVM chains and Hyperliquid.Large diff (~4k lines across 27 files), so the reviewer guide below groups it by concern.
What you can do with it
How orders are submitted
Orders are built, signed and submitted from the user's own machine straight to
api.hyperliquid.xyz/exchange— they are not relayed through the Nansen backend. Reads and market data still go through the API.That has two consequences worth reviewing carefully:
src/hl-action.jsbuilds the L1 action (msgpack +action_hash+ EIP-712) andsrc/hl-client.jssubmits it. A mistake here signs the wrong payload, so both have dedicated round-trip tests.Reviewer guide
src/hl-action.js,src/__tests__/hl-action.test.jssrc/hl-client.js,src/__tests__/hl-client.test.jssrc/perp.js,src/__tests__/perp.test.jssrc/bridge.jssrc/cli.js,src/schema.jsontradesrc/trading.js,src/limit-order.js,src/keychain.jsMerge with main
Main moved a long way while this branch sat (Node 18 dropped, vitest 4.1, x402 BSC support, new perp screener filters), so
mainis merged in —35a9f1e— rather than rebased, to keep the tested history intact. Two collisions needed resolving:src/rpc-urls.js— main added abscRPC entry for the x402 payment path; this branch addedbnbfor bridge/perp. Same chain (56) under two spellings, each read by different code (x402.jsreadsCHAIN_RPCS.bsc;chain-ids.js/bridge.jsuse'bnb'), so both keys are kept with the default each side shipped. Worth unifying later — deliberately not done here, since collapsing them changes which RPC the payment path uses.src/cli.js— this branch re-pointed bareperpat the trading dispatcher, which silently shadowed the existing perp analytics command that main had just extended. git merged the file clean; the breakage was semantic —nansen perp screenerandnansen research perp screenerboth threwUnknown perp subcommand(research dispatches throughcmds[category], so both forms broke). Fixed by falling through to the analytics handler forscreener/leaderboard. This is the part of the diff most worth a second pair of eyes.Known nit, matching main's existing split:
perp helplists the analytics subcommands,perp --help(schema-driven) does not, because main documents them underresearch perpinschema.json. Left as-is.Release notes
Four changesets ship with this PR;
.changeset/error-envelope-unification.mdis the one that matters to existing consumers:error-envelope-unification.md{success, error, code, status, details}. Previously aCommandErrorprinted its payload at the top level, sotrade,limit-orderand the API-key flows differed from everything else. Nothing is lost (payload preserved underdetails), but anything parsing those errors positionally must readdetails.bridge-execute-screening.mdbridge execute, plus refusal when the signing wallet isn't the quote's wallet.hl-direct-submit.mdhl-action-builder.mdperp-bridge-input-safety.mdVerification
0627a12. (7 were failing on the merge before theperp screenerfall-through fix; the later counts include 14 tests added for the review fixes.)perp --help,perp help,bridge --help,perp screenerandresearch perp screener(both return live data), and an unknown subcommand still errors with the combined list.Review fixes since the merge
54386c0— consume the bridge quote on the first broadcast, not the last (partial-failure retry could re-send an already-broadcast step).8ab8bb4— fail-closed sanctions screening before signing onbridge execute, and mark at each individual broadcast before receipt polling. Worth knowing for the compliance story: the EVM deposit leg broadcasts viaeth_sendRawTransactionstraight to a public RPC, so it never transits the Nansen API at execute time — only the HL-signature leg is proxied. This client-side screen is what covers deposits between quote and broadcast.0627a12— screen the wallet that actually signs, and refuse a quote/wallet mismatch.🤖 Generated with Claude Code