Skip to content

feat(perp): Hyperliquid perpetual trading and bridge commands - #467

Open
kome12 wants to merge 30 commits into
mainfrom
feat/hl-perp-trading
Open

feat(perp): Hyperliquid perpetual trading and bridge commands#467
kome12 wants to merge 30 commits into
mainfrom
feat/hl-perp-trading

Conversation

@kome12

@kome12 kome12 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Adds two command families to the CLI: nansen perp for Hyperliquid perpetual trading, and nansen bridge for 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

nansen perp order --coin BTC --side buy --size 0.001 --price 50000 --type limit
nansen perp close --coin BTC --size 0.001 --side sell
nansen perp leverage --coin BTC --leverage 10 --margin-type cross
nansen perp transfer --direction spot-to-perp --amount 25
nansen perp positions | orders | account | meta

nansen bridge quote --from-chain base --to-chain hyperliquid --from-token USDC --amount 1000000
nansen bridge execute --quote <quoteId>
nansen bridge status --request-id <id>

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:

  1. The signing path is ours now. src/hl-action.js builds the L1 action (msgpack + action_hash + EIP-712) and src/hl-client.js submits it. A mistake here signs the wrong payload, so both have dedicated round-trip tests.
  2. Compliance checks happen before signing, and fail closed. Every mutating command screens the wallet against the sanctions list first and refuses to sign if that check cannot complete. The builder-fee approval is fetched from the API on the same path and also fails closed.

Reviewer guide

Area Files
Action building + hashing src/hl-action.js, src/__tests__/hl-action.test.js
Direct submission src/hl-client.js, src/__tests__/hl-client.test.js
Perp commands + screening/fee gate src/perp.js, src/__tests__/perp.test.js
Bridge commands src/bridge.js
Wiring, help text, schema src/cli.js, src/schema.json
Input validation shared with trade src/trading.js, src/limit-order.js, src/keychain.js

Merge 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 main is merged in — 35a9f1e — rather than rebased, to keep the tested history intact. Two collisions needed resolving:

  • src/rpc-urls.js — main added a bsc RPC entry for the x402 payment path; this branch added bnb for bridge/perp. Same chain (56) under two spellings, each read by different code (x402.js reads CHAIN_RPCS.bsc; chain-ids.js/bridge.js use '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 bare perp at the trading dispatcher, which silently shadowed the existing perp analytics command that main had just extended. git merged the file clean; the breakage was semanticnansen perp screener and nansen research perp screener both threw Unknown perp subcommand (research dispatches through cmds[category], so both forms broke). Fixed by falling through to the analytics handler for screener/leaderboard. This is the part of the diff most worth a second pair of eyes.

Known nit, matching main's existing split: perp help lists the analytics subcommands, perp --help (schema-driven) does not, because main documents them under research perp in schema.json. Left as-is.

Release notes

Four changesets ship with this PR; .changeset/error-envelope-unification.md is the one that matters to existing consumers:

Changeset Bump Covers
error-envelope-unification.md minor Output shape change — every failure now serializes as {success, error, code, status, details}. Previously a CommandError printed its payload at the top level, so trade, limit-order and the API-key flows differed from everything else. Nothing is lost (payload preserved under details), but anything parsing those errors positionally must read details.
bridge-execute-screening.md patch Pre-sign fail-closed sanctions screening on bridge execute, plus refusal when the signing wallet isn't the quote's wallet.
hl-direct-submit.md patch Direct-to-Hyperliquid submission path.
hl-action-builder.md patch Client-side action builder.
perp-bridge-input-safety.md patch Client-side input validation for perp/bridge.

Verification

  • Full suite green: 1652 passed, 2 skipped at 0627a12. (7 were failing on the merge before the perp screener fall-through fix; the later counts include 14 tests added for the review fixes.)
  • eslint clean — it caught a real gap while fixing the replay marking: the Privy signature path had the callback threaded through but never invoked, which would have left that path unmarked.
  • Ran against the real CLI: perp --help, perp help, bridge --help, perp screener and research perp screener (both return live data), and an unknown subcommand still errors with the combined list.
  • The end-to-end trade and bridge paths were last exercised against mainnet with real funds before this merge; they need an unlocked wallet and funds, so they were not re-run as part of it. Worth one live order + one bridge leg before release.

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 on bridge execute, and mark at each individual broadcast before receipt polling. Worth knowing for the compliance story: the EVM deposit leg broadcasts via eth_sendRawTransaction straight 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

kome12 and others added 20 commits June 9, 2026 11:02
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>
@kome12 kome12 self-assigned this Jul 27, 2026
kome12 and others added 3 commits July 27, 2026 19:44
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>
@gulshngill

Copy link
Copy Markdown
Contributor

Code Review — feat/hl-perp-trading

Reviewed at 0627a12. All findings below were verified live against the prod API and CLI (not inferred from static analysis alone). Unit tests: 1650 pass / 2 fail — both failures are in package.test.js and reproduce on main, not caused by this PR.


🔴 Critical (blocks merge)

C1 — bridge execute crashes on 4 of 5 advertised EVM origin chains

BRIDGE_CHAINS (src/bridge.js:41) lists ethereum, base, arbitrum, polygon, bnb, hyperliquid. src/rpc-urls.js adds RPC endpoints for arbitrum/polygon/bnb. USDC token addresses are defined for all four in src/bridge.js:32-36. But signEvmTransaction (src/trading.js:517) resolves the chain through CHAIN_MAP (src/trading.js:25-28), which only contains solana and base.

Verified live:

  • bridge quote --from-chain arbitrum → succeeds, quote file written
  • bridge execute on that quote → Unsupported EVM chain: arbitrum
  • Same for polygon, bnb. Ethereum gets further (RPC auth error) but hits the same wall.

No funds at risk — throws before broadcast. But the feature is non-functional on 4 of 6 advertised chains.

Fix: add ethereum (1), arbitrum (42161), polygon (137), bnb (56) to CHAIN_MAP in trading.js, or trim BRIDGE_CHAINS to ['base', 'hyperliquid'] and update help/README.


🟠 Major

M1 — Usage banners regressed to escaped JSON blobs across the whole CLI

The unified error envelope (src/cli.js:1995-2002) now routes MISSING_PARAM/MISSING_ARGS through formatOutput, which serialises \n as literals. Hits pre-existing commands, not just the new ones.

Verified live — nansen trade quote (no args):

{"success":false,"error":"\nUsage: nansen trade quote --chain <chain>...","code":"MISSING_ARGS","status":null}

Same for nansen perp order --coin ETH (missing side/size/price).

Fix: when stdout is a TTY and no --json/--pretty/--csv, print error.message raw for usage codes instead of serialising.


M2 — bridge is absent from schema.json; perp endpoints are stale

Verified:

node src/index.js schema | jq '.commands | has("bridge")'  # → false

bridge is a new top-level command group and is completely missing from schema — breaks any agent consuming nansen schema.

Also: every mutating perp subcommand (order/cancel/close/leverage/transfer) still lists /api/v1/perp/* as endpoint. After the direct-to-HL refactor, signing goes to api.hyperliquid.xyz — the schema is wrong.


M3 — Compliance screening and postBridgeExecute are cacheable/retryable

postBridgeExecute (src/bridge.js:114-116) passes no options to request(), inheriting the default 3× retry on [429, 500, 502, 503, 504]. Relay's /authorize is not idempotent — a 500 retry can double-submit.

Same issue with screenOrThrow under --cache: a cached SDN verdict from within the 5-minute TTL is returned instead of a live check, undermining the "every action re-screens the signing wallet" guarantee.

hl-client.js:59-62 already documents why submitExchange must not retry — postBridgeExecute should follow the same pattern.

Fix: pass { cache: false } on every call in perp.js/bridge.js, and { cache: false, retry: false } on postBridgeExecute.


M4 — Builder fee is auto-signed from an unbounded server-supplied value

ensureBuilderApproved (src/perp.js:294-305) fires silently on first trade — no fee displayed, no confirmation, no ceiling. required_fee: 10000 → signs "10%" with no user awareness. Only validation is Number.isInteger(required_fee).

The only threat model here is a compromised API (we trust our own API over TLS), so this is defence-in-depth rather than an active exploit. Suggest a MAX_BUILDER_FEE_TENTHS_BP constant and logging the approved rate before signing.


M5 — Bridge EIP-712 signing produces a degenerate digest when API omits types

processSignatureStepLocal (src/bridge.js) does (types[primaryType] || []).map(...). With types = {}, fields is empty and hashStruct (src/x402-evm.js:111-124) hashes keccak256(typeHash("HyperliquidTransaction()")) — a valid-looking signature over none of the action's actual contents.

Verified live: called hashTypedData with fields=[] — it returned a hash instead of throwing.

Fix:

if (!signData.eip712Types?.[primaryType]?.length) {
  throw new Error(`Bridge step ${step.id} is missing EIP-712 types; refusing to sign.`);
}

M6 — bridge.js didn't receive the wallet hardening that perp.js got

Three divergences:

  1. Misleading password errorresolveWalletCredentials calls exportWallet(name, null) when no password is found, producing "Incorrect password" even though nothing was entered. perp.js:158-170 fixes this exact issue with PASSWORD_REQUIRED + resolution steps. Lift that into a shared helper.
  2. No EVM address validationbridge.js:429 returns wallet.evm unchecked. perp.js:137-141 rejects wallets with no valid 0x…40 EVM address. A Solana-only wallet sends address: null to the quote endpoint.
  3. Double wallet resolutionexecute resolves signer at :612 then resolveWalletCredentials(walletName) independently at :630. Agree today, subtle divergence risk later.

M7 — --recipient and base-unit --amount unvalidated client-side on bridge quote

Verified: both --recipient not-an-address and --amount abc reach the API and get a 422 back — so no fund-loss risk, the API is the backstop. But there's no client-side guard. validateAddress is already exported from api.js.


M8 — NANSEN_HL_API_URL testnet support is broken

source: 'a' (src/hl-action.js:400) and hyperliquidChain: 'Mainnet' (:449, :467) are hardcoded. Pointing the env var at testnet still produces mainnet signatures that testnet rejects.

Fix: derive source/hyperliquidChain from the resolved base URL, or narrow the doc comment to "for tests only, not testnet".


🟡 Minor (selected)

  • Dead code: effectiveOrderValues (src/perp.js:182) is exported but nothing outside tests imports it — made redundant by the direct-submit refactor.
  • nansen research perp help now shows the trading command help instead of the analytics help (screener/leaderboard). The screener/leaderboard passthrough still routes correctly — just help is wrong.
  • pollBridgeCompletion swallows all errors (src/bridge.js:405-408) — any non-BRIDGE_FAILED error logs "poll error — retrying..." for up to 10 minutes with no detail.
  • request_id may be undefined at src/bridge.js:695 — timeout message reads nansen bridge status --request-id undefined.
  • buildUsdClassTransferAction uses .toFixed(8) (src/hl-action.js:463) which returns exponential notation for values ≥ 1e21 — parsePositiveNumber has no upper bound so an absurd --amount produces a malformed wire string.
  • Full minor list (13 items) in the detailed review file at /home/node/repos/nansen-cli/.worktrees/pr467-review/PR467-code-review.md.

✅ What passed in live testing

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

  1. Fix C1 (CHAIN_MAP + test) + M1 (usage banners) — highest blast radius
  2. Fix M2 (schema) — required by CLAUDE.md
  3. Fix M3 (cache: false, retry: false) + M5 (throw on missing EIP-712 types) — both are a few lines
  4. 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

kome12 and others added 5 commits July 28, 2026 17:09
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>
kome12 and others added 2 commits July 29, 2026 10:48
… 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>
@kome12

kome12 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

All of C1 and M1–M8 are done, plus the five minors you listed — pushed as 57efabf and 0a182d3. 1783 tests passing, eslint clean.

Two things need you:

  1. M4 — I set the ceiling equal to the published rate rather than a loose multiple. Strongest version of what you asked for, but it couples the fee to a release: raise the rate and every installed CLI refuses to trade until upgraded. Happy to widen it if you'd rather have headroom.
  2. The other 8 minors — your comment lists 5 and points at a detailed review file in your own environment, which I can't read. Could you paste them here?

While finishing the Base → HL leg I found why it never landed: getEvmNonce returns a decimal number and bridge.js decoded it as hex a second time, so parseInt(20, 16) === 32 — a wallet at nonce 20 signed at 32. It's been there since the bridge commands were added and is invisible below nonce 10, where the two coincide, so it only bites once a wallet passes 9. Fixed with a regression test; getEvmNonce now also reconciles pending against latest. Separately, bridge execute takes --priority-fee/--max-fee/--nonce so a stuck transaction can actually be replaced — previously the fees were recomputed identically and every retry returned replacement transaction underpriced.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants