diff --git a/.changeset/bridge-execute-screening.md b/.changeset/bridge-execute-screening.md new file mode 100644 index 00000000..82c3e382 --- /dev/null +++ b/.changeset/bridge-execute-screening.md @@ -0,0 +1,7 @@ +--- +"nansen-cli": patch +--- + +`bridge execute` now re-screens the wallet against the compliance blocklist immediately before signing, and fails closed if the check can't be completed — matching what the perp commands already did. Bridge quotes stay valid for an hour and the EVM deposit leg broadcasts straight to a public RPC, so previously nothing re-checked the wallet between the quote and the transaction that moves funds. + +It also refuses to execute a quote with a wallet other than the one the quote was created for. Previously the signing wallet was resolved from `--wallet` or the current default independently of the quote, so a changed default (or an explicit `--wallet`) could sign with a different wallet than the one screened. diff --git a/.changeset/bridge-perp-schema.md b/.changeset/bridge-perp-schema.md new file mode 100644 index 00000000..671a0f8c --- /dev/null +++ b/.changeset/bridge-perp-schema.md @@ -0,0 +1,7 @@ +--- +"nansen-cli": patch +--- + +`nansen schema` now describes the `bridge` command group — its three subcommands, their options, and the supported routes — so agents driving the CLI off the schema can discover it. + +The mutating `perp` subcommands (`order`, `cancel`, `close`, `leverage`, `transfer`, `approve-builder-fee`) now declare `submitsTo: "https://api.hyperliquid.xyz/exchange"` in place of an `endpoint`, which is where they actually send a signed action, alongside an `apiEndpoints` list of the Nansen routes each one reads for compliance screening, market metadata and builder-fee status. Read-only subcommands keep their `endpoint` unchanged. diff --git a/.changeset/bridge-supported-routes.md b/.changeset/bridge-supported-routes.md new file mode 100644 index 00000000..9c0ee326 --- /dev/null +++ b/.changeset/bridge-supported-routes.md @@ -0,0 +1,7 @@ +--- +"nansen-cli": patch +--- + +`nansen bridge` supports `base -> hyperliquid` for deposits, and `hyperliquid -> base`, `hyperliquid -> ethereum`, `hyperliquid -> arbitrum` for withdrawals. Any other combination is rejected at quote time with the supported routes listed. + +The route set is asymmetric because the two directions need different things from the client: a deposit broadcasts an EVM transaction and so needs a locally signable origin chain, while a withdrawal signs a Hyperliquid action and never touches the destination chain. diff --git a/.changeset/error-envelope-unification.md b/.changeset/error-envelope-unification.md new file mode 100644 index 00000000..5f30ba72 --- /dev/null +++ b/.changeset/error-envelope-unification.md @@ -0,0 +1,9 @@ +--- +"nansen-cli": minor +--- + +**Output shape change:** every command failure now serializes through the same error envelope — `{success: false, error, code, status, details}`. Previously a `CommandError` printed its structured payload at the top level instead, so errors from `trade`, `limit-order` and the API-key flows (`NOT_A_TTY`, `API_KEY_REQUIRED`, `INVALID_API_KEY`, `VERIFICATION_FAILED`) came back in a different shape from everything else. + +Nothing is lost — the previous top-level payload is preserved verbatim under `details` — but anything parsing those errors positionally needs to read `details` instead of the root object. The new `perp` and `bridge` commands raise `CommandError` throughout, so without this they would have been the third distinct error shape in the CLI. + +One deliberate exception: a missing-argument usage banner (`MISSING_PARAM`, `MISSING_ARGS`) prints as plain text when stdout is an interactive terminal and no output format was requested, because those messages are multi-line help written to be read and serializing them renders every newline as a literal `\n`. Piped output, and any run with `--pretty`, `--table`, `--format csv` or `--stream`, still gets the envelope — so nothing consuming the CLI programmatically sees a different shape. diff --git a/.changeset/evm-eip1559-signing.md b/.changeset/evm-eip1559-signing.md new file mode 100644 index 00000000..f22108b9 --- /dev/null +++ b/.changeset/evm-eip1559-signing.md @@ -0,0 +1,11 @@ +--- +"nansen-cli": patch +--- + +EVM transactions are now signed as EIP-1559 (type 2) when the quote supplies fee caps, instead of being flattened into a legacy (type 0) transaction with a single gas price. + +A legacy transaction pays exactly its `gasPrice`, so once the base fee rises above that value it is not merely slow — it can never be included at that nonce. A type-2 transaction pays base fee plus priority up to its cap, so it tolerates the fee moving between signing and inclusion. This affects `trade execute` and `bridge execute`, which share the signer. + +`bridge execute` also stops discarding the fee fields the bridge quote provides. It previously overwrote them with a bare `eth_gasPrice` reading, producing a transaction priced at roughly the current base fee with almost no priority fee — which is what it takes to sit unmined on Base. Quoted fees are now kept, with the priority fee raised to a floor that Base will actually schedule and the cap lifted to cover both that and base-fee movement. + +Two related robustness changes: signing now refuses outright when a quote carries no gas information at all, rather than falling back to a 1 wei gas price that produces a permanently unmineable transaction; and the receipt wait is longer, because by the time it runs the transaction is already broadcast, so giving up early reports a failure without undoing anything. diff --git a/.changeset/hl-action-builder.md b/.changeset/hl-action-builder.md new file mode 100644 index 00000000..fdf6e0ba --- /dev/null +++ b/.changeset/hl-action-builder.md @@ -0,0 +1,13 @@ +--- +"nansen-cli": patch +--- + +Add a client-side Hyperliquid action builder (`src/hl-action.js`), the groundwork for submitting perp trades straight to Hyperliquid instead of round-tripping through the Nansen backend to build them. + +- msgpack encoder that reproduces the reference `msgpack.packb` output byte-for-byte (insertion-ordered maps, smallest-width ints, utf-8 strings), so an action's `connectionId` hash matches the known-good path. +- `actionHash` + `l1Eip712` reproduce the phantom-agent EIP-712 payload (Exchange domain, mainnet `source: "a"`) that the existing signer already knows how to sign. +- Order-wire assembly for market/limit orders, cancels, closes and leverage updates, including TP/SL (`normalTpsl`) grouping and the builder-code attachment. +- Price/size rounding (`roundPrice`/`roundSize`) ported with Python-parity banker's rounding, so over-precise values that Hyperliquid would reject are rounded identically to the server path. +- `approveBuilderFee` and `usdClassTransfer` user-signed payload builders. + +Pinned against the live prepare endpoints with golden-vector tests that assert both the built action and its `connectionId` match byte-for-byte. diff --git a/.changeset/hl-direct-submit.md b/.changeset/hl-direct-submit.md new file mode 100644 index 00000000..1cfc7738 --- /dev/null +++ b/.changeset/hl-direct-submit.md @@ -0,0 +1,7 @@ +--- +"nansen-cli": patch +--- + +Add `src/hl-client.js`, the single direct-to-Hyperliquid submission path: `submitExchange()` POSTs a signed action straight to `api.hyperliquid.xyz/exchange` from the user's machine instead of routing it through the Nansen backend. Reads and market-data stay on the proxy. + +It reproduces the backend proxy's failure handling that it replaces — throwing on a top-level `status: "err"` and on a per-action error nested in `response.data.statuses[].error` (a rejected order that Hyperliquid otherwise reports under a top-level `"ok"`) — so a rejected order can never be mistaken for a fill. The submit is deliberately not retried, since each carries a unique nonce and is not idempotent. The HL base URL is overridable via `NANSEN_HL_API_URL` (for testnet / tests). diff --git a/.changeset/perp-bridge-input-safety.md b/.changeset/perp-bridge-input-safety.md new file mode 100644 index 00000000..9b7869a6 --- /dev/null +++ b/.changeset/perp-bridge-input-safety.md @@ -0,0 +1,22 @@ +--- +"nansen-cli": patch +--- + +Harden perp, swap, and bridge command safety: + +- `perp order`/`close` now reject an invalid `--side` instead of silently opening the opposite direction, and `perp leverage` rejects an invalid `--margin-type` instead of silently switching to isolated. +- Perp numeric args (`--size`, `--price`, `--leverage`, `--oid`) are validated as positive numbers, with specific error messages instead of a generic usage banner. +- Perp commands now require an EVM wallet, with a clear error instead of querying for an `"undefined"` address. +- `trade quote` validates `--quote-index`, `--slippage`, and `--max-auto-slippage`, rejecting out-of-range values (e.g. a percent-vs-decimal slippage mix-up). +- `bridge quote` now validates `--slippage` client-side (whole basis points in `[0, 10000]`), rejecting non-numeric or out-of-range values with a clear message instead of forwarding them to an opaque backend 422 (matching how `perp` validates `--slippage`). +- `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. +- `perp order`/`close` now report the size and price the order actually executes at (post-rounding, slippage-adjusted for market orders) instead of echoing the raw input, so the printed values match the fill. +- `limit-order list` no longer aborts the whole render when one order has a non-integer amount. +- Quote loaders reject a cross-type quote (a bridge quote sent to `trade execute`, or a swap quote sent to `bridge execute`). +- `bridge execute` now refuses a quote that has already been executed, preventing an accidental double-bridge on retry. +- `bridge quote` accepts human amounts via `--amount-unit token|usd` (default stays base units), resolving token decimals per chain so the same `5` isn't 100x off between chains (USDC is 6 decimals on EVM, 8 on Hyperliquid). Hyperliquid USDC is floored to the bridge's 6-decimal precision to avoid a round-up-past-balance rejection. +- `perp meta` supports `--all` and `--filter ` so assets past the first 20 (e.g. HYPE) are listable. +- Deprecated top-level aliases now print a deprecation notice on stderr when run, not only in `--help`. +- `limit-order` rejects a zero-duration or past expiry instead of creating an order that expires immediately. +- A password with leading/trailing whitespace is no longer mangled when read back from the OS keychain. +- Nested backend error messages containing an apostrophe are no longer truncated. diff --git a/.changeset/perp-bridge-request-safety.md b/.changeset/perp-bridge-request-safety.md new file mode 100644 index 00000000..dba2de30 --- /dev/null +++ b/.changeset/perp-bridge-request-safety.md @@ -0,0 +1,12 @@ +--- +"nansen-cli": patch +--- + +`perp` and `bridge` no longer serve any API response from the `--cache` store, and `bridge execute` no longer retries its submission. + +- **Compliance screening** is always a live check. Every mutating command re-screens the signing wallet immediately before signing; with `--cache` that verdict could previously come from a cache written up to five minutes earlier, which is exactly the window the check exists to close. +- **`bridge execute`** sets `retry: false`. It proxies to Relay's `/authorize` and Hyperliquid's `/exchange`, neither of which is idempotent, so an automatic re-send on a 500 or 502 could submit the same signed action twice. +- **Bridge status polling** now observes progress under `--cache`. The cache key is endpoint plus body, so every poll for a given request id hit the same key and the loop would re-read one stale verdict for the whole TTL. +- **Perp reads** (`positions`, `orders`, `account`, `meta`) and **bridge quotes** bypass the cache too: they either report live balances to the user or feed a signing decision — `close` sizes its order from the positions read, and asset ids come from `meta`. + +`bridge execute` also refuses to sign a step whose EIP-712 type definition is missing or whose `primaryType` matches no entry in it. With an empty field list the digest is still well-formed but commits to none of the action's contents, so it would have produced a valid-looking signature over nothing. diff --git a/README.md b/README.md index cd7a34bc..32e75e4c 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,25 @@ nansen trade limit-order update --order --trigger-price 85 For EVM chains, there's no native limit-order surface — pair an external venue's resting order with a `common-token-transfer` smart alert on the settlement wallet as a best-effort fill signal. See the `nansen-limit-orders` skill for details. +## Perpetuals + +Hyperliquid perpetual trading via `nansen perp`. Uses the same wallet and `NANSEN_WALLET_PASSWORD` as DEX trading (requires an EVM wallet). Select the asset with `--coin` (`--symbol` is accepted as an alias). + +```bash +nansen perp meta --filter ETH # list assets + max leverage +nansen perp order --coin ETH --side buy --size 0.1 --price 1600 --type limit +nansen perp order --coin BTC --side sell --size 0.001 --price 95000 --type market \ + --take-profit 90000 --stop-loss 98000 +nansen perp close --coin ETH --size 0.1 --price 1600 --side sell # sell closes a long +nansen perp cancel --coin ETH --oid +nansen perp leverage --coin ETH --leverage 5 --margin-type cross # or isolated +nansen perp transfer --direction spot-to-perp --amount 25 # Spot<->Perps (or perp-to-spot) +nansen perp positions +nansen perp account # value, unrealized PnL, margin, spot USDC +``` + +`--side` is `buy`/`long` or `sell`/`short` to open (`buy`/`sell` to close); `--tif` is `Gtc`/`Ioc`/`Alo`; `--slippage` is a decimal in `[0,1]`; `--leverage` is a whole integer capped at the asset max. Perp orders are irreversible once signed. USDC sent to a wallet via Hyperliquid's **Send** lands in the **Spot** balance (shown as `Spot USDC`); move it to Perps with `perp transfer` before trading. See the `nansen-trading` skill for details. + ## Wallet ```bash @@ -195,7 +214,8 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo |---------|-----| | `command not found` | `npm install -g nansen-cli` | | `UNAUTHORIZED` after login | `cat ~/.nansen/config.json` or set `NANSEN_API_KEY` | -| Empty perp results | Use `--symbol BTC`, not `--token`. Perps are Hyperliquid-only. | +| Empty perp _research_ results | Use `--symbol BTC`, not `--token`. Perps are Hyperliquid-only. | +| `perp` _trading_ prints the usage banner | Trading needs `--coin BTC` (`--symbol` also works); see the Perpetuals section. | | `UNSUPPORTED_FILTER` on token holders | Remove `--smart-money` — not all tokens have that data. | | Huge JSON response | Use `--fields` to select columns. | diff --git a/skills/nansen-trading/SKILL.md b/skills/nansen-trading/SKILL.md index e8d34cd6..d3b40df2 100644 --- a/skills/nansen-trading/SKILL.md +++ b/skills/nansen-trading/SKILL.md @@ -1,6 +1,6 @@ --- name: nansen-trading -description: Execute DEX swaps on Solana or Base, including cross-chain bridges. Use when buying or selling a token, getting a swap quote, or executing a trade. +description: Execute DEX swaps on Solana or Base (including cross-chain bridges) and Hyperliquid perpetual trades. Use when buying or selling a token, getting a swap quote, executing a trade, or opening/closing/managing a perp position. metadata: openclaw: requires: @@ -191,6 +191,54 @@ If the user says "$20 worth of X", use `--amount-unit usd` directly — no manua - A wallet is required even for quotes (the API builds sender-specific transactions). - ERC-20 swaps may require an approval step — execute handles this automatically. +# Perp Trading + +Use `nansen perp` for Hyperliquid perpetual trading. Uses the same wallet and `NANSEN_WALLET_PASSWORD` as DEX trading; requires an **EVM** wallet. **Perp orders are irreversible once signed.** + +Subcommands: `order`, `cancel`, `close`, `leverage`, `positions`, `orders`, `account`, `meta`. + +The asset is selected with `--coin` (e.g. `BTC`, `ETH`); `--symbol` is accepted as an alias. List tradable assets and their max leverage with `nansen perp meta` (use `--filter ` or `--all` to see beyond the first 20). + +## Open a position + +```bash +# Limit long: 0.1 ETH at $1600 +nansen perp order --coin ETH --side buy --size 0.1 --price 1600 --type limit + +# Market short with optional take-profit / stop-loss +nansen perp order --coin BTC --side sell --size 0.001 --price 95000 --type market \ + --take-profit 90000 --stop-loss 98000 +``` + +- `--side`: `buy`/`long` to open a long, `sell`/`short` to open a short. +- `--size`: position size in base asset units (positive number). +- `--price`: limit price (or mark price for market orders). +- `--type`: `limit` (default) or `market`. `--tif`: `Gtc` (default), `Ioc`, `Alo`. +- `--slippage`: decimal in `[0,1]` for market orders (default `0.03` = 3%). + +## Close / cancel + +```bash +# Close: sell to close a long, buy to close a short (validated against your open position) +nansen perp close --coin ETH --size 0.1 --price 1600 --side sell + +# Cancel a resting order by id +nansen perp cancel --coin ETH --oid 123456 +``` + +## Leverage, transfers & account + +```bash +nansen perp leverage --coin ETH --leverage 5 --margin-type cross # or isolated +nansen perp transfer --direction spot-to-perp --amount 25 # or perp-to-spot +nansen perp positions +nansen perp account # account value, unrealized PnL, margin used, withdrawable, spot USDC +``` + +`--leverage` must be a whole integer and is capped at the asset's maximum (see `perp meta`). + +**Spot vs Perps:** perp trading draws from the **Perps** balance, but USDC sent to a wallet via Hyperliquid's **Send** lands in **Spot** (and shows as `Spot USDC` in `perp account`). Move it across with `perp transfer --direction spot-to-perp --amount ` before trading. (Deposits via the bridge land in Perps directly.) + ## Source - npm: https://www.npmjs.com/package/nansen-cli diff --git a/src/__tests__/api.test.js b/src/__tests__/api.test.js index cc566326..24f6d035 100644 --- a/src/__tests__/api.test.js +++ b/src/__tests__/api.test.js @@ -2404,6 +2404,22 @@ describe('NansenAPI', () => { await expect(api.smartMoneyNetflow({})).rejects.toThrow('Invalid API key'); }); + it('extracts a nested error message containing an apostrophe (L7)', async () => { + if (LIVE_TEST) return; + + const errorResponse = { + ok: false, + status: 400, + headers: new Map(), + json: async () => ({ detail: "{'message': 'Order can't be filled', 'code': 'X'}" }) + }; + errorResponse.headers.get = () => null; + + mockFetch.mockResolvedValueOnce(errorResponse); + + await expect(api.smartMoneyNetflow({})).rejects.toThrow("Order can't be filled"); + }); + it('should show login guidance for 401 when no API key', async () => { if (LIVE_TEST) return; diff --git a/src/__tests__/bridge-amount.test.js b/src/__tests__/bridge-amount.test.js new file mode 100644 index 00000000..1cb387e3 --- /dev/null +++ b/src/__tests__/bridge-amount.test.js @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(() => ({ evm: '0x' + 'a'.repeat(40), provider: 'local' })), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(), +})); + +import { + buildBridgeCommands, + resolveBridgeTokenDecimals, + floorHyperliquidUsdcBridgeAmount, +} from '../bridge.js'; + +const BASE_USDC = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; +const HL_USDC = '0x00000000000000000000000000000000'; + +describe('resolveBridgeTokenDecimals', () => { + it('returns 8 for Hyperliquid USDC', async () => { + expect(await resolveBridgeTokenDecimals(HL_USDC, 'hyperliquid')).toBe(8); + }); + + it('returns 6 for EVM USDC', async () => { + expect(await resolveBridgeTokenDecimals(BASE_USDC, 'base')).toBe(6); + }); + + it('throws for a non-USDC token on Hyperliquid (no decimals source)', async () => { + await expect( + resolveBridgeTokenDecimals('0x' + '1'.repeat(40), 'hyperliquid'), + ).rejects.toThrow(/Cannot resolve decimals/); + }); +}); + +describe('floorHyperliquidUsdcBridgeAmount', () => { + it('floors HL USDC (8 dec) down to 6-decimal precision', () => { + // 27.17457999 USDC at 8 decimals -> floor dust below 1e-6 + expect(floorHyperliquidUsdcBridgeAmount('2717457999', 8, HL_USDC, 'hyperliquid')).toBe('2717457900'); + }); + + it('leaves an already-6-decimal-aligned HL amount unchanged', () => { + expect(floorHyperliquidUsdcBridgeAmount('500000000', 8, HL_USDC, 'hyperliquid')).toBe('500000000'); + }); + + it('passes non-Hyperliquid amounts through untouched', () => { + expect(floorHyperliquidUsdcBridgeAmount('5000000', 6, BASE_USDC, 'base')).toBe('5000000'); + }); +}); + +describe('bridge quote --amount-unit (M2)', () => { + let tmpHome; + let prevHome; + + beforeEach(() => { + prevHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-bridge-amt-')); + process.env.HOME = tmpHome; + }); + + afterEach(() => { + process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + function fakeApi() { + const captured = {}; + return { + captured, + request: async (_path, params) => { + captured.params = params; + return { details: {}, fees: {}, steps: [] }; + }, + }; + } + + it('converts the same human amount to per-chain base units (HL 8 vs Base 6)', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + + const hlApi = fakeApi(); + await cmds.quote([], hlApi, {}, { + 'from-chain': 'hyperliquid', 'to-chain': 'base', + 'from-token': 'USDC', amount: '5', 'amount-unit': 'token', wallet: 'w', + }); + expect(hlApi.captured.params.amount).toBe('500000000'); // 5 * 10^8 + + const baseApi = fakeApi(); + await cmds.quote([], baseApi, {}, { + 'from-chain': 'base', 'to-chain': 'hyperliquid', + 'from-token': 'USDC', amount: '5', 'amount-unit': 'token', wallet: 'w', + }); + expect(baseApi.captured.params.amount).toBe('5000000'); // 5 * 10^6 + }); + + it('passes --amount through unchanged when no --amount-unit is given (base units)', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + const api = fakeApi(); + await cmds.quote([], api, {}, { + 'from-chain': 'base', 'to-chain': 'hyperliquid', + 'from-token': 'USDC', amount: '5000000', wallet: 'w', + }); + expect(api.captured.params.amount).toBe('5000000'); + }); + + it('rejects an unknown --amount-unit instead of silently using base units', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + const api = fakeApi(); + await expect( + cmds.quote([], api, {}, { + 'from-chain': 'base', 'to-chain': 'hyperliquid', + 'from-token': 'USDC', amount: '5', 'amount-unit': 'tokens', wallet: 'w', + }), + ).rejects.toThrow(/Invalid --amount-unit/); + expect(api.captured.params).toBeUndefined(); + }); + + it('accepts a case-insensitive --amount-unit', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + const api = fakeApi(); + await cmds.quote([], api, {}, { + 'from-chain': 'base', 'to-chain': 'hyperliquid', + 'from-token': 'USDC', amount: '5', 'amount-unit': 'Token', wallet: 'w', + }); + expect(api.captured.params.amount).toBe('5000000'); + }); + + // --amount-unit usd on USDC must NOT call the price API: USDC is $1, and HL's + // USDC uses a sentinel address the price API can't resolve. fakeApi() has no + // generalSearch, so reaching resolveUsdPrice would throw — passing proves the + // $1 short-circuit fired. + it('treats USDC as $1 for --amount-unit usd from Hyperliquid (no price lookup)', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + const api = fakeApi(); + await cmds.quote([], api, {}, { + 'from-chain': 'hyperliquid', 'to-chain': 'base', + 'from-token': 'USDC', amount: '5', 'amount-unit': 'usd', wallet: 'w', + }); + expect(api.captured.params.amount).toBe('500000000'); // $5 -> 5 USDC at 8 dec + }); + + it('treats USDC as $1 for --amount-unit usd on an EVM chain (no price lookup)', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + const api = fakeApi(); + await cmds.quote([], api, {}, { + 'from-chain': 'base', 'to-chain': 'hyperliquid', + 'from-token': 'USDC', amount: '5', 'amount-unit': 'usd', wallet: 'w', + }); + expect(api.captured.params.amount).toBe('5000000'); // $5 -> 5 USDC at 6 dec + }); +}); + +// The CLI offers a deliberately narrower, asymmetric route set than the API +// accepts: deposits need a locally signable origin chain (Base only), while +// withdrawals sign an HL action and so work to any API-supported destination. +describe('bridge quote supported routes', () => { + let tmpHome; + let prevHome; + + beforeEach(() => { + prevHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-bridge-routes-')); + process.env.HOME = tmpHome; + }); + + afterEach(() => { + process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + function fakeApi() { + const captured = {}; + return { + captured, + request: async (_path, params) => { + captured.params = params; + return { details: {}, fees: {}, steps: [] }; + }, + }; + } + + function quoteWith(api, fromChain, toChain) { + return buildBridgeCommands({ log: () => {} }).quote([], api, {}, { + 'from-chain': fromChain, 'to-chain': toChain, + 'from-token': 'USDC', amount: '5000000', wallet: 'w', + }); + } + + // The bug this guards: these origins have RPCs, chain IDs and USDC addresses + // wired, so a quote used to succeed and only blow up at execute time inside + // signEvmTransaction ("Unsupported EVM chain"), after the user had a quote + // file in hand. Reject at quote time instead. + for (const origin of ['ethereum', 'arbitrum', 'polygon', 'bnb']) { + it(`rejects a ${origin} -> hyperliquid deposit (not locally signable)`, async () => { + const api = fakeApi(); + await expect(quoteWith(api, origin, 'hyperliquid')).rejects.toThrow( + /Unsupported bridge route/, + ); + expect(api.captured.params).toBeUndefined(); + }); + } + + it('accepts the base -> hyperliquid deposit', async () => { + const api = fakeApi(); + await quoteWith(api, 'base', 'hyperliquid'); + expect(api.captured.params.origin_chain).toBe('base'); + }); + + for (const destination of ['base', 'ethereum', 'arbitrum']) { + it(`accepts the hyperliquid -> ${destination} withdrawal`, async () => { + const api = fakeApi(); + await quoteWith(api, 'hyperliquid', destination); + expect(api.captured.params.destination_chain).toBe(destination); + }); + } + + // Mirrors the API's own pair matrix, which has no HL -> polygon/bnb route. + for (const destination of ['polygon', 'bnb']) { + it(`rejects the hyperliquid -> ${destination} withdrawal`, async () => { + const api = fakeApi(); + await expect(quoteWith(api, 'hyperliquid', destination)).rejects.toThrow( + /Unsupported bridge route/, + ); + expect(api.captured.params).toBeUndefined(); + }); + } + + it('rejects an EVM -> EVM route (not a Hyperliquid bridge)', async () => { + const api = fakeApi(); + await expect(quoteWith(api, 'base', 'arbitrum')).rejects.toThrow( + /Unsupported bridge route/, + ); + expect(api.captured.params).toBeUndefined(); + }); + + it('lists the supported routes in the rejection message', async () => { + await expect(quoteWith(fakeApi(), 'polygon', 'hyperliquid')).rejects.toThrow( + /base -> hyperliquid/, + ); + }); +}); diff --git a/src/__tests__/bridge-eip712-types-guard.test.js b/src/__tests__/bridge-eip712-types-guard.test.js new file mode 100644 index 00000000..847f7ad1 --- /dev/null +++ b/src/__tests__/bridge-eip712-types-guard.test.js @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({ defaultWallet: 'w' })), + exportWallet: vi.fn(() => ({ evm: { privateKey: '11'.repeat(32) } })), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: 'pw', source: 'keychain' })), +})); + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { showWallet } from '../wallet.js'; +import { buildBridgeCommands } from '../bridge.js'; +import { hashTypedData } from '../x402-evm.js'; + +const WALLET = '0x8CB9c3F23C7d600fB430bbd171a313D9ea61cEBc'; + +let tmpHome; +let prevHome; +let quotesDir; + +beforeEach(() => { + prevHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-eip712-')); + process.env.HOME = tmpHome; + quotesDir = path.join(tmpHome, '.nansen', 'quotes'); + fs.mkdirSync(quotesDir, { recursive: true }); + showWallet.mockReturnValue({ name: 'w', evm: WALLET, provider: 'local' }); +}); + +afterEach(() => { + process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +// An empty field list is not a signing error at the hashing layer — it produces +// a perfectly well-formed digest over none of the message's contents. So the +// refusal has to live above it. +describe('hashTypedData with no fields', () => { + it('still returns a hash, which is why the caller must guard', () => { + const hash = hashTypedData( + { name: 'HyperliquidSignTransaction', version: '1', chainId: 1, verifyingContract: '0x' + '0'.repeat(40) }, + 'HyperliquidTransaction', + [], + {}, + ); + expect(hash).toBeDefined(); + expect(hash.length).toBe(32); + }); +}); + +function writeQuote(quoteId, step) { + fs.writeFileSync(path.join(quotesDir, `${quoteId}.json`), JSON.stringify({ + quoteId, + type: 'bridge', + originChain: 'hyperliquid', + destinationChain: 'base', + walletProvider: 'local', + walletAddress: WALLET, + timestamp: Date.now(), + response: { execution_type: 'hyperliquid_signature', request_id: 'req-1', steps: [step] }, + })); +} + +function api() { + return { + request: async (endpoint, body) => { + if (endpoint.includes('/sanctions/screen')) { + return { results: (body?.addresses || []).map(address => ({ address, sanctioned: false })) }; + } + if (endpoint.includes('/bridge/status')) return { status: 'success' }; + return { ok: true }; + }, + }; +} + +const execute = (quoteId) => + buildBridgeCommands({ log: () => {} }).execute([], api(), {}, { quote: quoteId }); + +describe('refuses to sign a bridge step with no EIP-712 type definition', () => { + it('throws when the HL action step omits eip712Types', async () => { + writeQuote('bridge-no-types', { + id: 'deposit', + kind: 'transaction', + items: [{ + data: { + action: { type: 'sendAsset', parameters: { destination: WALLET, amount: '5' } }, + eip712PrimaryType: 'HyperliquidTransaction:SendAsset', + eip712Types: {}, + nonce: 1700000000000, + }, + }], + }); + await expect(execute('bridge-no-types')).rejects.toThrow( + /missing its EIP-712 type definition.*Refusing to sign/s, + ); + }); + + it('names the offending step so the failure is actionable', async () => { + writeQuote('bridge-named', { + id: 'deposit', + items: [{ + data: { + action: { type: 'sendAsset', parameters: {} }, + eip712PrimaryType: 'HyperliquidTransaction:SendAsset', + nonce: 1, + }, + }], + }); + await expect(execute('bridge-named')).rejects.toThrow(/Bridge step "deposit"/); + }); + + // A primaryType that doesn't match any key in types is the same failure as an + // absent types map: zero fields, so a signature over nothing. + it('throws when primaryType does not match the supplied types', async () => { + writeQuote('bridge-mismatch', { + id: 'authorize', + items: [{ + data: { + sign: { + domain: { name: 'Relay', version: '1', chainId: 1, verifyingContract: '0x' + '0'.repeat(40) }, + types: { Authorize: [{ name: 'nonce', type: 'uint256' }] }, + primaryType: 'SomethingElse', + value: { nonce: '1' }, + }, + post: { endpoint: '/authorize', body: {} }, + }, + }], + }); + await expect(execute('bridge-mismatch')).rejects.toThrow( + /missing its EIP-712 type definition for "SomethingElse"/, + ); + }); + + it('still signs a step whose types are present', async () => { + writeQuote('bridge-ok', { + id: 'authorize', + items: [{ + data: { + sign: { + domain: { name: 'Relay', version: '1', chainId: 1, verifyingContract: '0x' + '0'.repeat(40) }, + types: { Authorize: [{ name: 'nonce', type: 'uint256' }] }, + primaryType: 'Authorize', + value: { nonce: '1' }, + }, + post: { endpoint: '/authorize', body: {} }, + }, + }], + }); + await expect(execute('bridge-ok')).resolves.toBeUndefined(); + }); +}); diff --git a/src/__tests__/bridge-evm-fees.test.js b/src/__tests__/bridge-evm-fees.test.js new file mode 100644 index 00000000..e9e26f53 --- /dev/null +++ b/src/__tests__/bridge-evm-fees.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { evmRpcCall } = vi.hoisted(() => ({ evmRpcCall: vi.fn() })); + +vi.mock('../trading.js', async (importOriginal) => ({ + ...(await importOriginal()), + evmRpcCall, +})); + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(), +})); + +import { resolveEvmStepFees } from '../bridge.js'; + +const GWEI = 1000000000n; +const MIN_PRIORITY = GWEI / 100n; // 0.01 gwei, the floor bridge.js applies + +// Shaped after a real Relay quote for a Base -> Hyperliquid approve step. Its +// priority fee is the value that stranded a real deposit: Base was including at +// ~0.008 gwei while Relay asked for 0.0011. +const RELAY_QUOTE_FEES = { + maxFeePerGas: '6600000', // 0.0066 gwei + maxPriorityFeePerGas: '1100000', // 0.0011 gwei +}; + +function mockBaseFee(wei) { + evmRpcCall.mockImplementation(async (_chain, method) => { + if (method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x' + BigInt(wei).toString(16) }; + if (method === 'eth_gasPrice') return '0x' + (6000000).toString(16); + throw new Error(`unexpected ${method}`); + }); +} + +beforeEach(() => { + evmRpcCall.mockReset(); +}); + +describe('resolveEvmStepFees', () => { + it('raises a too-low quote priority fee to the floor', async () => { + mockBaseFee(5000000n); + const fees = await resolveEvmStepFees('base', RELAY_QUOTE_FEES); + expect(BigInt(fees.maxPriorityFeePerGas)).toBe(MIN_PRIORITY); + // And the cap must cover it. + expect(BigInt(fees.maxFeePerGas)).toBeGreaterThanOrEqual(BigInt(fees.maxPriorityFeePerGas)); + }); + + it('keeps a quote priority fee that already clears the floor', async () => { + mockBaseFee(5000000n); + const fees = await resolveEvmStepFees('base', { + maxFeePerGas: String(GWEI), + maxPriorityFeePerGas: String(GWEI / 2n), + }); + expect(BigInt(fees.maxPriorityFeePerGas)).toBe(GWEI / 2n); + }); + + it('lifts the cap to base fee headroom plus priority', async () => { + const baseFee = 10000000n; // 0.01 gwei + mockBaseFee(baseFee); + const fees = await resolveEvmStepFees('base', RELAY_QUOTE_FEES); + expect(BigInt(fees.maxFeePerGas)).toBe(baseFee * 3n + MIN_PRIORITY); + }); + + it('never returns a cap below the priority fee', async () => { + // A base fee of 0 would leave the cap at Relay's thin 0.0066 gwei, which is + // below the floor — nodes reject maxFeePerGas < maxPriorityFeePerGas. + mockBaseFee(0n); + const fees = await resolveEvmStepFees('base', RELAY_QUOTE_FEES); + expect(BigInt(fees.maxFeePerGas)).toBeGreaterThanOrEqual(BigInt(fees.maxPriorityFeePerGas)); + }); + + it('still clears the priority fee when the base-fee lookup fails', async () => { + evmRpcCall.mockRejectedValue(new Error('rpc down')); + const fees = await resolveEvmStepFees('base', RELAY_QUOTE_FEES); + expect(BigInt(fees.maxFeePerGas)).toBeGreaterThanOrEqual(BigInt(fees.maxPriorityFeePerGas)); + expect(BigInt(fees.maxPriorityFeePerGas)).toBe(MIN_PRIORITY); + }); + + it('does not raise a quote cap that is already generous', async () => { + mockBaseFee(5000000n); + const generous = String(GWEI * 5n); + const fees = await resolveEvmStepFees('base', { + maxFeePerGas: generous, + maxPriorityFeePerGas: String(GWEI), + }); + expect(fees.maxFeePerGas).toBe(generous); + }); + + // Pre-1559 quote shape: nothing to preserve, so fall back to the node. + it('falls back to eth_gasPrice when the quote has no fee caps', async () => { + mockBaseFee(5000000n); + const fees = await resolveEvmStepFees('base', { gasPrice: '123' }); + expect(fees.gasPrice).toBe('0x' + (6000000).toString(16)); + expect(fees.maxFeePerGas).toBeUndefined(); + }); +}); diff --git a/src/__tests__/bridge-fee-overrides.test.js b/src/__tests__/bridge-fee-overrides.test.js new file mode 100644 index 00000000..605e901e --- /dev/null +++ b/src/__tests__/bridge-fee-overrides.test.js @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: null, source: null })), +})); + +const { evmRpcCall, getEvmNonce, signEvmTransaction, waitForReceipt } = vi.hoisted(() => ({ + evmRpcCall: vi.fn(), + getEvmNonce: vi.fn(async () => 7), + signEvmTransaction: vi.fn(() => '0xsigned'), + waitForReceipt: vi.fn(async () => ({ status: '0x1', blockNumber: '0x1' })), +})); + +vi.mock('../trading.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, evmRpcCall, getEvmNonce, signEvmTransaction, waitForReceipt }; +}); + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { exportWallet, getWalletConfig, showWallet } from '../wallet.js'; +import { buildBridgeCommands, parseGweiToWei, resolveEvmStepFees } from '../bridge.js'; + +// P2: once a transaction is stuck, the CLI recomputed identical fees, so no retry +// could ever replace it — every attempt returned "replacement transaction +// underpriced". These pin the override path that makes replacement possible. + +const ADDR = '0x' + 'ab'.repeat(20); + +describe('parseGweiToWei', () => { + it('converts whole gwei', () => { + expect(parseGweiToWei('1', 'priority-fee')).toBe(1000000000n); + }); + + it('converts a fractional gwei exactly, without float drift', () => { + expect(parseGweiToWei('0.05', 'priority-fee')).toBe(50000000n); + expect(parseGweiToWei('0.001', 'priority-fee')).toBe(1000000n); + expect(parseGweiToWei('1.234567891', 'max-fee')).toBe(1234567891n); + }); + + it('rejects garbage, zero and negatives', () => { + expect(() => parseGweiToWei('abc', 'priority-fee')).toThrow(/Give a fee in gwei/); + expect(() => parseGweiToWei('0.05abc', 'priority-fee')).toThrow(/Give a fee in gwei/); + expect(() => parseGweiToWei('-1', 'priority-fee')).toThrow(/Give a fee in gwei/); + expect(() => parseGweiToWei('0', 'priority-fee')).toThrow(/greater than zero/); + }); +}); + +describe('resolveEvmStepFees overrides', () => { + const quoted = { maxFeePerGas: '1000000', maxPriorityFeePerGas: '1100000' }; + + beforeEach(() => { + vi.clearAllMocks(); + evmRpcCall.mockResolvedValue({ baseFeePerGas: '0x1' }); + }); + + it('uses the quoted fees, floored, with no overrides', async () => { + const fees = await resolveEvmStepFees('base', quoted); + // Below the 0.01 gwei floor, so lifted to it. + expect(fees.maxPriorityFeePerGas).toBe('10000000'); + }); + + it('lets --priority-fee win over the computed floor', async () => { + const fees = await resolveEvmStepFees('base', quoted, { priorityFeeWei: 50000000n }); + expect(fees.maxPriorityFeePerGas).toBe('50000000'); + // The cap has to cover it. + expect(BigInt(fees.maxFeePerGas)).toBeGreaterThanOrEqual(50000000n); + }); + + it('lets --priority-fee go BELOW the floor when asked', async () => { + // The floor is a default, not a policy — an operator pricing for a quiet + // chain must be able to go under it. + const fees = await resolveEvmStepFees('base', quoted, { priorityFeeWei: 1000n }); + expect(fees.maxPriorityFeePerGas).toBe('1000'); + }); + + it('uses --max-fee as the cap verbatim', async () => { + const fees = await resolveEvmStepFees('base', quoted, { + priorityFeeWei: 50000000n, + maxFeeWei: 900000000n, + }); + expect(fees).toEqual({ maxFeePerGas: '900000000', maxPriorityFeePerGas: '50000000' }); + // An explicit cap is authoritative, so no base-fee lookup is needed. + expect(evmRpcCall).not.toHaveBeenCalled(); + }); + + it('refuses a cap below the priority fee', async () => { + await expect( + resolveEvmStepFees('base', quoted, { priorityFeeWei: 50000000n, maxFeeWei: 100n }), + ).rejects.toThrow(/--max-fee is below --priority-fee/); + }); + + it('produces type-2 fields from overrides even for a legacy-shaped quote', async () => { + const fees = await resolveEvmStepFees('base', { gasPrice: '1000' }, { priorityFeeWei: 50000000n }); + expect(fees.maxPriorityFeePerGas).toBe('50000000'); + expect(fees.gasPrice).toBeUndefined(); + }); +}); + +describe('bridge execute overrides', () => { + let tmpHome; + let prevHome; + let quotesDir; + + function writeQuote(quoteId, overrides = {}) { + const data = { + quoteId, + type: 'bridge', + originChain: 'base', + destinationChain: 'hyperliquid', + walletProvider: 'local', + walletAddress: ADDR, + timestamp: Date.now(), + response: { + execution_type: 'evm_transaction', + request_id: 'r1', + steps: [ + { + id: 'deposit', + kind: 'transaction', + items: [{ status: 'incomplete', data: { from: ADDR, to: ADDR, data: '0x', maxFeePerGas: '1000000' } }], + }, + ], + }, + ...overrides, + }; + fs.writeFileSync(path.join(quotesDir, `${quoteId}.json`), JSON.stringify(data, null, 2)); + } + + const api = { + request: vi.fn(async (endpoint) => { + if (String(endpoint).includes('/bridge/status')) return { status: 'success', destination_tx_hashes: [] }; + return { results: [{ address: ADDR, sanctioned: false }] }; + }), + }; + + beforeEach(() => { + vi.clearAllMocks(); + prevHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-bridge-fees-')); + process.env.HOME = tmpHome; + quotesDir = path.join(tmpHome, '.nansen', 'quotes'); + fs.mkdirSync(quotesDir, { recursive: true }); + getWalletConfig.mockReturnValue({}); + showWallet.mockReturnValue({ name: 'w', evm: ADDR, provider: 'local' }); + exportWallet.mockReturnValue({ evm: { privateKey: '11'.repeat(32) } }); + evmRpcCall.mockImplementation(async (chain, method) => { + if (method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }; + if (method === 'eth_sendRawTransaction') return '0x' + 'cd'.repeat(32); + return '0x0'; + }); + getEvmNonce.mockResolvedValue(7); + api.request.mockImplementation(async (endpoint) => { + if (String(endpoint).includes('/bridge/status')) return { status: 'success', destination_tx_hashes: [] }; + return { results: [{ address: ADDR, sanctioned: false }] }; + }); + }); + + afterEach(() => { + process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('signs at the next nonce by default', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-1'); + await cmds.execute([], api, {}, { quote: 'bridge-1', wallet: 'w' }); + expect(getEvmNonce).toHaveBeenCalled(); + expect(signEvmTransaction).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'base', 7); + }); + + it('signs at --nonce, bypassing the pending reconciliation', async () => { + // Replacing a stuck transaction means reusing its nonce, which is exactly + // what getEvmNonce refuses to hand out. + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-2'); + await cmds.execute([], api, {}, { quote: 'bridge-2', wallet: 'w', nonce: '20' }); + expect(getEvmNonce).not.toHaveBeenCalled(); + expect(signEvmTransaction).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'base', 20); + }); + + it('passes --priority-fee through to the signed transaction', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-3'); + await cmds.execute([], api, {}, { quote: 'bridge-3', wallet: 'w', 'priority-fee': '0.05' }); + const [txData] = signEvmTransaction.mock.calls[0]; + expect(txData.maxPriorityFeePerGas).toBe('50000000'); + }); + + it('reports the overrides in use rather than applying them silently', async () => { + const lines = []; + const cmds = buildBridgeCommands({ log: (m) => lines.push(m) }); + writeQuote('bridge-4'); + await cmds.execute([], api, {}, { + quote: 'bridge-4', wallet: 'w', nonce: '20', 'priority-fee': '0.05', + }); + const line = lines.find(l => l.includes('Overrides:')); + expect(line).toMatch(/priority fee 50000000 wei/); + expect(line).toMatch(/starting nonce 20/); + }); + + it('rejects a non-numeric --nonce', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-5'); + await expect( + cmds.execute([], api, {}, { quote: 'bridge-5', wallet: 'w', nonce: '20abc' }), + ).rejects.toThrow(/Invalid --nonce/); + }); + + it('refuses overrides on a withdrawal leg, which has no transaction to price', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-6', { + originChain: 'hyperliquid', + destinationChain: 'base', + response: { execution_type: 'hyperliquid_signature', steps: [], request_id: 'r1' }, + }); + await expect( + cmds.execute([], api, {}, { quote: 'bridge-6', wallet: 'w', 'priority-fee': '0.05' }), + ).rejects.toThrow(/apply only to EVM deposit legs/); + }); + + it('numbers a multi-step quote consecutively from --nonce', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + const twoSteps = { + response: { + execution_type: 'evm_transaction', + request_id: 'r1', + steps: [ + { + id: 'approve', + kind: 'transaction', + items: [{ status: 'incomplete', data: { from: ADDR, to: ADDR, data: '0x', maxFeePerGas: '1000000' } }], + }, + { + id: 'deposit', + kind: 'transaction', + items: [{ status: 'incomplete', data: { from: ADDR, to: ADDR, data: '0x', maxFeePerGas: '1000000' } }], + }, + ], + }, + }; + writeQuote('bridge-7', twoSteps); + await cmds.execute([], api, {}, { quote: 'bridge-7', wallet: 'w', nonce: '20' }); + const nonces = signEvmTransaction.mock.calls.map(c => c[3]); + expect(nonces).toEqual([20, 21]); + }); +}); diff --git a/src/__tests__/bridge-input-validation.test.js b/src/__tests__/bridge-input-validation.test.js new file mode 100644 index 00000000..5062a6b8 --- /dev/null +++ b/src/__tests__/bridge-input-validation.test.js @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: null, source: null })), +})); + +import { showWallet } from '../wallet.js'; +import { buildBridgeCommands } from '../bridge.js'; + +// M7 / M6.2: --recipient and a base-unit --amount reached the API unchecked, with +// a 422 as the only backstop. Both are validated before the wallet is resolved, +// so these need no wallet — the assertions below rely on that ordering. + +const cmds = buildBridgeCommands({ log: () => {} }); + +const base = { + 'from-chain': 'base', + 'to-chain': 'hyperliquid', + 'from-token': 'USDC', + amount: '5000000', + // Named so the "accepts" cases reach wallet resolution — the sentinel below is + // how they prove validation passed. + wallet: 'w', +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Any resolution attempt should fail loudly, proving validation ran first. + showWallet.mockImplementation(() => { throw new Error('wallet resolved too early'); }); +}); + +describe('bridge quote --recipient validation', () => { + it('rejects a truncated address', async () => { + let err; + try { + await cmds.quote([], null, {}, { ...base, recipient: '0xabc' }); + } catch (e) { + err = e; + } + expect(err.code).toBe('INVALID_ADDRESS'); + expect(err.message).toMatch(/Invalid --recipient "0xabc"/); + }); + + it('rejects a non-hex address of the right length', async () => { + await expect( + cmds.quote([], null, {}, { ...base, recipient: '0x' + 'zz'.repeat(20) }), + ).rejects.toThrow(/Invalid --recipient/); + }); + + it('rejects a Solana address, which no supported destination takes', async () => { + await expect( + cmds.quote([], null, {}, { ...base, recipient: 'So11111111111111111111111111111111111111112' }), + ).rejects.toThrow(/Invalid --recipient/); + }); + + it('accepts a well-formed EVM address', async () => { + await expect( + cmds.quote([], null, {}, { ...base, recipient: '0x' + 'ab'.repeat(20) }), + ).rejects.toThrow(/wallet resolved too early/); + }); +}); + +describe('bridge quote --amount validation', () => { + it('rejects a decimal in base units — a units mix-up, not a small amount', async () => { + let err; + try { + await cmds.quote([], null, {}, { ...base, amount: '5.5' }); + } catch (e) { + err = e; + } + expect(err.code).toBe('INVALID_INPUT'); + expect(err.message).toMatch(/positive whole number/); + }); + + it('rejects trailing garbage', async () => { + await expect(cmds.quote([], null, {}, { ...base, amount: '5000000abc' })).rejects.toThrow( + /Invalid --amount/, + ); + }); + + it('rejects zero and negative amounts', async () => { + await expect(cmds.quote([], null, {}, { ...base, amount: '0' })).rejects.toThrow(/Invalid --amount/); + await expect(cmds.quote([], null, {}, { ...base, amount: '-1' })).rejects.toThrow(/Invalid --amount/); + }); + + it('accepts a base-unit integer', async () => { + await expect(cmds.quote([], null, {}, { ...base })).rejects.toThrow(/wallet resolved too early/); + }); + + it('accepts a decimal with --amount-unit token', async () => { + await expect( + cmds.quote([], null, {}, { ...base, amount: '5.5', 'amount-unit': 'token' }), + ).rejects.toThrow(/wallet resolved too early/); + }); + + it('still rejects garbage with --amount-unit set', async () => { + await expect( + cmds.quote([], null, {}, { ...base, amount: 'abc', 'amount-unit': 'usd' }), + ).rejects.toThrow(/Must be a positive number when --amount-unit is usd/); + }); +}); diff --git a/src/__tests__/bridge-perp-request-options.test.js b/src/__tests__/bridge-perp-request-options.test.js new file mode 100644 index 00000000..dd1b5421 --- /dev/null +++ b/src/__tests__/bridge-perp-request-options.test.js @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(() => ({ name: 'w', evm: '0x' + 'a'.repeat(40), provider: 'local' })), + getWalletConfig: vi.fn(() => ({ defaultWallet: 'w' })), + exportWallet: vi.fn(() => ({ evm: { privateKey: '11'.repeat(32) } })), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: 'pw', source: 'keychain' })), +})); + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { buildBridgeCommands } from '../bridge.js'; +import { buildPerpCommands, screenOrThrow } from '../perp.js'; + +const WALLET = '0x8CB9c3F23C7d600fB430bbd171a313D9ea61cEBc'; + +let tmpHome; +let prevHome; + +// Records the per-request options each call passed, which is the whole point: +// these are money-moving paths where a cached response or an automatic re-send +// is a correctness problem rather than an optimisation. +function recordingApi(respond = () => ({})) { + const calls = []; + return { + calls, + optionsFor: (match) => calls.find(c => c.endpoint.includes(match))?.options, + request: async (endpoint, body, options = {}) => { + calls.push({ endpoint, body, options }); + return respond(endpoint, body); + }, + }; +} + +beforeEach(() => { + prevHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-reqopts-')); + process.env.HOME = tmpHome; + fs.mkdirSync(path.join(tmpHome, '.nansen', 'quotes'), { recursive: true }); +}); + +afterEach(() => { + process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('compliance screening is never served from cache', () => { + it('passes cache: false on the screen call', async () => { + const api = recordingApi(() => ({ results: [{ address: WALLET, sanctioned: false }] })); + await screenOrThrow(api, [WALLET]); + expect(api.calls).toHaveLength(1); + expect(api.calls[0].endpoint).toContain('/api/v1/sanctions/screen'); + expect(api.calls[0].options.cache).toBe(false); + }); +}); + +describe('bridge request options', () => { + it('reads status with cache: false so polling can observe progress', async () => { + const api = recordingApi(() => ({ status: 'pending' })); + await buildBridgeCommands({ log: () => {} }).status([], api, {}, { 'request-id': 'req-1' }); + const opts = api.optionsFor('/perp/bridge/status'); + expect(opts.cache).toBe(false); + }); + + it('quotes with cache: false', async () => { + const api = recordingApi(() => ({ details: {}, fees: {}, steps: [] })); + await buildBridgeCommands({ log: () => {} }).quote([], api, {}, { + 'from-chain': 'base', 'to-chain': 'hyperliquid', + 'from-token': 'USDC', amount: '5000000', wallet: 'w', + }); + expect(api.optionsFor('/perp/bridge/quote').cache).toBe(false); + }); + + // Relay's /authorize and HL's /exchange are not idempotent, so an automatic + // retry on a 500/502 can submit the same signed action twice. + it('posts execute with retry: false', async () => { + const quotesDir = path.join(tmpHome, '.nansen', 'quotes'); + fs.writeFileSync(path.join(quotesDir, 'bridge-r1.json'), JSON.stringify({ + quoteId: 'bridge-r1', + type: 'bridge', + originChain: 'hyperliquid', + destinationChain: 'base', + walletProvider: 'local', + walletAddress: WALLET, + timestamp: Date.now(), + response: { + execution_type: 'hyperliquid_signature', + request_id: 'req-r1', + steps: [{ + id: 'authorize', + items: [{ + data: { + sign: { + domain: { + name: 'Relay', version: '1', chainId: 1, + verifyingContract: '0x' + '0'.repeat(40), + }, + types: { Authorize: [{ name: 'nonce', type: 'uint256' }] }, + primaryType: 'Authorize', + value: { nonce: '1' }, + }, + post: { endpoint: '/authorize', body: {} }, + }, + }], + }], + }, + })); + + const api = recordingApi((endpoint) => { + if (endpoint.includes('/sanctions/screen')) { + return { results: [{ address: WALLET, sanctioned: false }] }; + } + if (endpoint.includes('/bridge/status')) return { status: 'success' }; + return { ok: true }; + }); + + const { showWallet } = await import('../wallet.js'); + showWallet.mockReturnValue({ name: 'w', evm: WALLET, provider: 'local' }); + + await buildBridgeCommands({ log: () => {} }).execute([], api, {}, { quote: 'bridge-r1' }); + + const opts = api.optionsFor('/perp/bridge/execute'); + expect(opts).toBeDefined(); + expect(opts.retry).toBe(false); + expect(opts.cache).toBe(false); + }); +}); + +describe('perp reads bypass the cache', () => { + // close sizes its order from the positions read, and asset ids come from meta, + // so a stale hit would be signed against. + for (const [sub, endpoint] of [ + ['positions', '/perp/positions'], + ['orders', '/perp/orders'], + ['account', '/perp/account'], + ['meta', '/perp/meta'], + ]) { + it(`${sub} passes cache: false`, async () => { + const api = recordingApi(() => ({ data: [] })); + await buildPerpCommands({ log: () => {} })[sub]([], api, {}, { wallet: 'w' }); + const opts = api.optionsFor(endpoint); + expect(opts).toBeDefined(); + expect(opts.cache).toBe(false); + }); + } +}); diff --git a/src/__tests__/bridge-quote.test.js b/src/__tests__/bridge-quote.test.js new file mode 100644 index 00000000..d4b46186 --- /dev/null +++ b/src/__tests__/bridge-quote.test.js @@ -0,0 +1,308 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// bridge execute resolves the signing wallet before screening, so the gate tests +// need a wallet to resolve. Mocked at the module boundary, as perp.test.js does. +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: null, source: null })), +})); +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { getWalletConfig, showWallet } from '../wallet.js'; +import { buildBridgeCommands, loadBridgeQuote, markBridgeQuoteExecuted, parseSlippageBps } from '../bridge.js'; + +// loadBridgeQuote/markBridgeQuoteExecuted resolve the quotes dir from +// process.env.HOME, so point HOME at a throwaway dir for each test. + +let tmpHome; +let prevHome; +let quotesDir; + +function writeQuote(quoteId, overrides = {}) { + const data = { + quoteId, + type: 'bridge', + originChain: 'base', + destinationChain: 'hyperliquid', + walletProvider: 'local', + walletAddress: '0xabc', + timestamp: Date.now(), + response: { execution_type: 'evm_transaction', steps: [], request_id: 'r1' }, + ...overrides, + }; + fs.writeFileSync(path.join(quotesDir, `${quoteId}.json`), JSON.stringify(data, null, 2)); + return data; +} + +beforeEach(() => { + prevHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-bridge-')); + process.env.HOME = tmpHome; + quotesDir = path.join(tmpHome, '.nansen', 'quotes'); + fs.mkdirSync(quotesDir, { recursive: true }); +}); + +afterEach(() => { + process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('bridge quote replay protection', () => { + it('loads a fresh, unexecuted quote', () => { + writeQuote('bridge-1'); + const data = loadBridgeQuote('bridge-1'); + expect(data.quoteId).toBe('bridge-1'); + expect(data.executedAt).toBeUndefined(); + }); + + it('refuses a quote that has already been executed', () => { + writeQuote('bridge-2'); + markBridgeQuoteExecuted('bridge-2'); + expect(() => loadBridgeQuote('bridge-2')).toThrow(/already executed/); + }); + + it('marking sets an executedAt timestamp on disk', () => { + writeQuote('bridge-3'); + markBridgeQuoteExecuted('bridge-3'); + const onDisk = JSON.parse( + fs.readFileSync(path.join(quotesDir, 'bridge-3.json'), 'utf8'), + ); + expect(typeof onDisk.executedAt).toBe('number'); + }); + + it('throws for a missing quote', () => { + expect(() => loadBridgeQuote('nope')).toThrow(/not found/); + }); + + it('deletes and rejects an expired quote', () => { + writeQuote('bridge-old', { timestamp: Date.now() - 2 * 3600000 }); + expect(() => loadBridgeQuote('bridge-old')).toThrow(/expired/); + expect(fs.existsSync(path.join(quotesDir, 'bridge-old.json'))).toBe(false); + }); + + it('marking a missing quote is a no-op (does not throw)', () => { + expect(() => markBridgeQuoteExecuted('ghost')).not.toThrow(); + }); + + it('rejects a non-bridge quote loaded through the bridge path', () => { + writeQuote('swap-1', { type: 'swap' }); + expect(() => loadBridgeQuote('swap-1')).toThrow(/not a bridge quote/); + }); + + it('consumes the quote after the FIRST broadcast of a multi-step bridge', () => { + // The double-send case: step 1 goes out, step 2 throws. The quote must + // already be spent, or a retry re-runs from step 0 and re-broadcasts step 1. + writeQuote('bridge-partial'); + markBridgeQuoteExecuted('bridge-partial', { broadcastSteps: 1, totalSteps: 2 }); + expect(() => loadBridgeQuote('bridge-partial')).toThrow(/partially executed/); + expect(() => loadBridgeQuote('bridge-partial')).toThrow(/1 of 2 steps/); + }); + + it('keeps the original executedAt when a later step is marked', () => { + writeQuote('bridge-progress'); + markBridgeQuoteExecuted('bridge-progress', { broadcastSteps: 1, totalSteps: 2 }); + const first = JSON.parse( + fs.readFileSync(path.join(quotesDir, 'bridge-progress.json'), 'utf8'), + ).executedAt; + markBridgeQuoteExecuted('bridge-progress', { broadcastSteps: 2, totalSteps: 2 }); + const after = JSON.parse( + fs.readFileSync(path.join(quotesDir, 'bridge-progress.json'), 'utf8'), + ); + expect(after.executedAt).toBe(first); + expect(after.broadcastSteps).toBe(2); + }); + + it('never regresses the broadcast count', () => { + // Marking is best-effort and idempotent; a stale/lower count must not + // reopen a fully-executed quote as "partial". + writeQuote('bridge-monotonic'); + markBridgeQuoteExecuted('bridge-monotonic', { broadcastSteps: 2, totalSteps: 2 }); + markBridgeQuoteExecuted('bridge-monotonic', { broadcastSteps: 1, totalSteps: 2 }); + const onDisk = JSON.parse( + fs.readFileSync(path.join(quotesDir, 'bridge-monotonic.json'), 'utf8'), + ); + expect(onDisk.broadcastSteps).toBe(2); + expect(() => loadBridgeQuote('bridge-monotonic')).toThrow(/already executed/); + }); + + it('a fully executed multi-step quote reports as executed, not partial', () => { + writeQuote('bridge-full'); + markBridgeQuoteExecuted('bridge-full', { broadcastSteps: 2, totalSteps: 2 }); + expect(() => loadBridgeQuote('bridge-full')).toThrow(/already executed/); + }); + + it('consumes the quote on a single broadcast, before any receipt is seen', () => { + // The receipt-timeout case: eth_sendRawTransaction succeeded, waitForReceipt + // then threw. Nothing has "completed", but a tx is in flight — a retry must + // not re-sign it. + writeQuote('bridge-inflight'); + markBridgeQuoteExecuted('bridge-inflight', { + broadcast: { step: 'deposit', txHash: '0xabc' }, + totalSteps: 1, + }); + expect(() => loadBridgeQuote('bridge-inflight')).toThrow(/partially executed/); + expect(() => loadBridgeQuote('bridge-inflight')).toThrow(/0xabc/); + }); + + it('records every individual broadcast, not just one per step', () => { + // A single step can carry several fund-moving items. + writeQuote('bridge-items'); + markBridgeQuoteExecuted('bridge-items', { broadcast: { step: 'deposit', txHash: '0x1' }, totalSteps: 1 }); + markBridgeQuoteExecuted('bridge-items', { broadcast: { step: 'deposit', txHash: '0x2' }, totalSteps: 1 }); + const onDisk = JSON.parse( + fs.readFileSync(path.join(quotesDir, 'bridge-items.json'), 'utf8'), + ); + expect(onDisk.broadcasts.map(b => b.txHash)).toEqual(['0x1', '0x2']); + expect(() => loadBridgeQuote('bridge-items')).toThrow(/2 transaction\(s\) already broadcast/); + }); + + it('reports a completed run as executed even though broadcasts were recorded', () => { + writeQuote('bridge-done'); + markBridgeQuoteExecuted('bridge-done', { broadcast: { step: 'deposit', txHash: '0x9' }, totalSteps: 1 }); + markBridgeQuoteExecuted('bridge-done', { broadcastSteps: 1, totalSteps: 1 }); + expect(() => loadBridgeQuote('bridge-done')).toThrow(/already executed/); + }); + + it('a signature-step broadcast with no tx hash still consumes the quote', () => { + writeQuote('bridge-sig'); + markBridgeQuoteExecuted('bridge-sig', { broadcast: { step: 'authorize', txHash: null }, totalSteps: 2 }); + expect(() => loadBridgeQuote('bridge-sig')).toThrow(/1 transaction\(s\) already broadcast/); + }); +}); + +describe('bridge execute compliance gate', () => { + const WALLET = '0x8CB9c3F23C7d600fB430bbd171a313D9ea61cEBc'; + + // The bridge quote was screened server-side when issued, but it stays valid for + // an hour and the EVM deposit leg broadcasts straight to a public RPC — so the + // re-screen here is the only thing standing between a newly-listed address and + // a fund-moving transaction. + function mockApi(screen) { + return { + request: async (endpoint, body) => { + if (endpoint.startsWith('/api/v1/sanctions/screen')) return screen(body); + throw new Error(`unexpected endpoint ${endpoint}`); + }, + }; + } + + function executableQuote(quoteId) { + writeQuote(quoteId, { + walletAddress: WALLET, + response: { + execution_type: 'evm_transaction', + steps: [{ id: 'deposit', items: [] }], + request_id: 'req-1', + }, + }); + } + + const execute = (api, quoteId, options = {}) => + buildBridgeCommands({ log: () => {} }).execute([], api, {}, { quote: quoteId, ...options }); + + beforeEach(() => { + // Signing wallet matches the quote's wallet unless a test overrides it. + getWalletConfig.mockReturnValue({ defaultWallet: 'w' }); + showWallet.mockReturnValue({ name: 'w', evm: WALLET, provider: 'local' }); + }); + + it('refuses to sign when the resolved wallet is not the quote wallet', async () => { + // Screening one address while signing with another would move funds from an + // unscreened wallet — and the cached nonce/from belong to the quote's wallet. + executableQuote('bridge-wrong-wallet'); + showWallet.mockReturnValue({ + name: 'other', + evm: '0x1111111111111111111111111111111111111111', + provider: 'local', + }); + let screened = false; + const api = { + request: async () => { + screened = true; + return { results: [] }; + }, + }; + await expect(execute(api, 'bridge-wrong-wallet', { wallet: 'other' })).rejects.toThrow( + /was created for .* but the signing wallet is/, + ); + // Refused before screening, so nothing was signed or broadcast either. + expect(screened).toBe(false); + const onDisk = JSON.parse( + fs.readFileSync(path.join(quotesDir, 'bridge-wrong-wallet.json'), 'utf8'), + ); + expect(onDisk.executedAt).toBeUndefined(); + }); + + it('screens the address that actually signs', async () => { + executableQuote('bridge-screens-signer'); + const seen = []; + const api = { + request: async (endpoint, body) => { + seen.push(...(body?.addresses || [])); + return { results: [{ address: WALLET, sanctioned: true }] }; + }, + }; + await expect(execute(api, 'bridge-screens-signer')).rejects.toThrow(/compliance blocklist/); + expect(seen).toEqual([WALLET]); + }); + + it('refuses to sign when the wallet is sanctioned', async () => { + executableQuote('bridge-sanctioned'); + const api = mockApi(() => ({ results: [{ address: WALLET, sanctioned: true }] })); + await expect(execute(api, 'bridge-sanctioned')).rejects.toThrow(/compliance blocklist/); + }); + + it('refuses to sign when screening is unavailable (fail closed)', async () => { + executableQuote('bridge-screen-down'); + const api = mockApi(() => { + throw new Error('503 snapshot unavailable'); + }); + await expect(execute(api, 'bridge-screen-down')).rejects.toThrow(/screening is unavailable/); + }); + + it('refuses to sign when the response omits the wallet (unverifiable)', async () => { + executableQuote('bridge-screen-partial'); + const api = mockApi(() => ({ results: [] })); + await expect(execute(api, 'bridge-screen-partial')).rejects.toThrow(/did not cover all addresses/); + }); + + it('leaves the quote unconsumed when screening blocks it — nothing was broadcast', async () => { + executableQuote('bridge-blocked'); + const api = mockApi(() => ({ results: [{ address: WALLET, sanctioned: true }] })); + await expect(execute(api, 'bridge-blocked')).rejects.toThrow(); + const onDisk = JSON.parse( + fs.readFileSync(path.join(quotesDir, 'bridge-blocked.json'), 'utf8'), + ); + expect(onDisk.executedAt).toBeUndefined(); + }); +}); + +describe('bridge --slippage validation', () => { + it('rejects a non-numeric value client-side instead of forwarding it to the API', () => { + expect(() => parseSlippageBps('abc')).toThrow(/Invalid --slippage "abc"/); + }); + + it('rejects trailing garbage that parseInt would otherwise truncate', () => { + expect(() => parseSlippageBps('999abc')).toThrow(/Invalid --slippage/); + }); + + it('rejects a negative value', () => { + expect(() => parseSlippageBps('-1')).toThrow(/Invalid --slippage "-1"/); + }); + + it('rejects a value above 10000 bps (100%)', () => { + expect(() => parseSlippageBps('10001')).toThrow(/between 0 and 10000/); + }); + + it('accepts valid basis points', () => { + expect(parseSlippageBps('50')).toBe(50); + expect(parseSlippageBps('0')).toBe(0); + expect(parseSlippageBps('10000')).toBe(10000); + }); +}); diff --git a/src/__tests__/bridge-wallet-hardening.test.js b/src/__tests__/bridge-wallet-hardening.test.js new file mode 100644 index 00000000..06878839 --- /dev/null +++ b/src/__tests__/bridge-wallet-hardening.test.js @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: null, source: null })), +})); + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { exportWallet, getWalletConfig, showWallet } from '../wallet.js'; +import { retrievePassword } from '../keychain.js'; +import { buildBridgeCommands } from '../bridge.js'; + +// M6: bridge.js missed the wallet hardening perp.js had. These run through the +// real `bridge execute` so they pin the wiring, not just the shared helper. + +const ADDR = '0x' + 'ab'.repeat(20); + +let tmpHome; +let prevHome; +let quotesDir; + +function writeQuote(quoteId, overrides = {}) { + const data = { + quoteId, + type: 'bridge', + originChain: 'base', + destinationChain: 'hyperliquid', + walletProvider: 'local', + walletAddress: ADDR, + timestamp: Date.now(), + response: { execution_type: 'evm_transaction', steps: [], request_id: 'r1' }, + ...overrides, + }; + fs.writeFileSync(path.join(quotesDir, `${quoteId}.json`), JSON.stringify(data, null, 2)); +} + +// Screening has to pass for execution to reach the key-resolution step, and a +// quote with no steps runs straight into completion polling — which loops for ten +// minutes unless the status call answers. +async function respond(endpoint) { + if (String(endpoint).includes('/bridge/status')) { + return { status: 'success', raw_status: 'done', destination_tx_hashes: ['0x' + 'de'.repeat(32)] }; + } + return { results: [{ address: ADDR, sanctioned: false }] }; +} + +const cleanApi = { request: vi.fn(respond) }; + +beforeEach(() => { + vi.clearAllMocks(); + prevHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-bridge-hardening-')); + process.env.HOME = tmpHome; + quotesDir = path.join(tmpHome, '.nansen', 'quotes'); + fs.mkdirSync(quotesDir, { recursive: true }); + getWalletConfig.mockReturnValue({}); + retrievePassword.mockReturnValue({ password: null, source: null }); + cleanApi.request.mockImplementation(respond); +}); + +afterEach(() => { + process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('bridge execute wallet hardening', () => { + it('reports PASSWORD_REQUIRED, not "Incorrect password", when nothing was entered', async () => { + // M6.1, the item flagged as immediate support noise. + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-1'); + showWallet.mockReturnValue({ name: 'w', evm: ADDR, provider: 'local' }); + getWalletConfig.mockReturnValue({ passwordHash: 'h' }); + + let err; + try { + await cmds.execute([], cleanApi, {}, { quote: 'bridge-1', wallet: 'w' }); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.code).toBe('PASSWORD_REQUIRED'); + expect(err.message).not.toMatch(/Incorrect password/); + expect(exportWallet).not.toHaveBeenCalled(); + }); + + it('refuses a wallet with no EVM address before any network call', async () => { + // M6.2: this used to pass wallet.evm through as null and 422 at the API. + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-2'); + showWallet.mockReturnValue({ name: 'sol-only', evm: null, provider: 'local' }); + + await expect( + cmds.execute([], cleanApi, {}, { quote: 'bridge-2', wallet: 'sol-only' }), + ).rejects.toThrow(/no valid EVM address\. Bridging requires an EVM wallet/); + expect(cleanApi.request).not.toHaveBeenCalled(); + }); + + it('resolves the wallet exactly once for the signer and its key', async () => { + // M6.3: two resolutions could screen one wallet and sign with another if the + // default changed in between. + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-3'); + showWallet.mockReturnValue({ name: 'w', evm: ADDR, provider: 'local' }); + exportWallet.mockReturnValue({ evm: { privateKey: '11'.repeat(32) } }); + + await cmds.execute([], cleanApi, {}, { quote: 'bridge-3', wallet: 'w' }).catch(() => {}); + expect(showWallet).toHaveBeenCalledTimes(1); + }); + + it('does not try to export a key for a Privy wallet', async () => { + const cmds = buildBridgeCommands({ log: () => {} }); + writeQuote('bridge-4', { + walletProvider: 'privy', + response: { execution_type: 'hyperliquid_signature', steps: [], request_id: 'r1' }, + }); + showWallet.mockReturnValue({ + name: 'p', + evm: ADDR, + provider: 'privy', + privyWalletIds: { evm: 'pw-1' }, + }); + + await cmds.execute([], cleanApi, {}, { quote: 'bridge-4', wallet: 'p' }).catch(() => {}); + expect(exportWallet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/bridge-withdraw-no-evm-signing.test.js b/src/__tests__/bridge-withdraw-no-evm-signing.test.js new file mode 100644 index 00000000..f9c16bc9 --- /dev/null +++ b/src/__tests__/bridge-withdraw-no-evm-signing.test.js @@ -0,0 +1,208 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Withdrawal destinations (hyperliquid -> ethereum/arbitrum) are supported even +// though local EVM signing only knows Base, because a withdrawal signs a +// Hyperliquid action and never transacts on the destination chain. That is the +// entire justification for those routes being in BRIDGE_ROUTES, so it gets a +// test rather than a comment: signEvmTransaction is replaced with a spy that +// throws, and a full withdrawal execute has to complete without touching it. +// +// Relay labels the second withdrawal step `kind: "transaction"` even though its +// payload is a Hyperliquid action, so this also pins the dispatch to +// execution_type rather than the step's own kind. +// vi.hoisted, because vi.mock's factory is hoisted above normal top-level consts. +const { signEvmTransaction, evmRpcCall, getEvmNonce } = vi.hoisted(() => ({ + signEvmTransaction: vi.fn(() => { + throw new Error('signEvmTransaction must not be reached on a withdrawal'); + }), + // Stubbed so the deposit control test below can reach the signing call + // without touching a real RPC. + evmRpcCall: vi.fn(async () => '0x1'), + getEvmNonce: vi.fn(async () => 0), +})); + +vi.mock('../trading.js', async (importOriginal) => ({ + ...(await importOriginal()), + signEvmTransaction, + evmRpcCall, + getEvmNonce, +})); + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({ defaultWallet: 'w' })), + exportWallet: vi.fn(() => ({ evm: { privateKey: '11'.repeat(32) } })), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: 'pw', source: 'keychain' })), +})); + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { showWallet } from '../wallet.js'; +import { buildBridgeCommands } from '../bridge.js'; + +const WALLET = '0x8CB9c3F23C7d600fB430bbd171a313D9ea61cEBc'; + +let tmpHome; +let prevHome; +let quotesDir; + +// Shaped after a live `hyperliquid -> arbitrum` quote: an authorize signature +// step, then an HL action step that Relay marks as a transaction. +function writeWithdrawQuote(quoteId, destinationChain) { + const data = { + quoteId, + type: 'bridge', + originChain: 'hyperliquid', + destinationChain, + walletProvider: 'local', + walletAddress: WALLET, + timestamp: Date.now(), + response: { + execution_type: 'hyperliquid_signature', + request_id: 'req-withdraw-1', + steps: [ + { + id: 'authorize', + kind: 'signature', + items: [ + { + data: { + sign: { + domain: { + name: 'Relay', + version: '1', + chainId: 1, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { Authorize: [{ name: 'nonce', type: 'uint256' }] }, + primaryType: 'Authorize', + value: { nonce: '1' }, + }, + post: { endpoint: '/authorize', body: {} }, + }, + }, + ], + }, + { + id: 'deposit', + kind: 'transaction', + items: [ + { + data: { + action: { type: 'sendAsset', parameters: { destination: WALLET, amount: '5' } }, + eip712PrimaryType: 'HyperliquidTransaction:SendAsset', + eip712Types: { + 'HyperliquidTransaction:SendAsset': [ + { name: 'destination', type: 'string' }, + { name: 'amount', type: 'string' }, + ], + }, + nonce: 1700000000000, + }, + }, + ], + }, + ], + }, + }; + fs.writeFileSync(path.join(quotesDir, `${quoteId}.json`), JSON.stringify(data, null, 2)); +} + +beforeEach(() => { + prevHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-bridge-wd-')); + process.env.HOME = tmpHome; + quotesDir = path.join(tmpHome, '.nansen', 'quotes'); + fs.mkdirSync(quotesDir, { recursive: true }); + signEvmTransaction.mockClear(); + showWallet.mockReturnValue({ name: 'w', evm: WALLET, provider: 'local' }); +}); + +afterEach(() => { + process.env.HOME = prevHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('hyperliquid withdrawals never sign an EVM transaction', () => { + for (const destination of ['base', 'ethereum', 'arbitrum']) { + it(`completes a hyperliquid -> ${destination} withdrawal without EVM signing`, async () => { + writeWithdrawQuote(`bridge-wd-${destination}`, destination); + + const posted = []; + const api = { + request: async (endpoint, body) => { + // Screening is fail-closed, so the stub has to cover every requested + // address or execute aborts before reaching the signing path at all. + if (endpoint.startsWith('/api/v1/sanctions/screen')) { + return { results: (body?.addresses || []).map(address => ({ address, sanctioned: false })) }; + } + // Terminal status straight away, so execute returns instead of + // sitting in pollBridgeCompletion's 10-minute retry loop. + if (endpoint.startsWith('/api/v1/perp/bridge/status')) { + return { status: 'success', destination_tx_hashes: ['0xdone'] }; + } + posted.push(body?.target_url ?? endpoint); + return { status: 'ok' }; + }, + }; + + await buildBridgeCommands({ log: () => {} }).execute([], api, {}, { + quote: `bridge-wd-${destination}`, + }); + + // The load-bearing assertion: local EVM signing was never entered, so the + // destination chain being absent from CHAIN_MAP cannot matter. + expect(signEvmTransaction).not.toHaveBeenCalled(); + // Both steps went out via the HL/Relay signature path. + expect(posted).toHaveLength(2); + }); + } + + // Control: without this, the assertions above would pass just as happily if + // the spy were never wired to the module at all. + it('does reach EVM signing on a deposit, proving the spy is wired', async () => { + const quoteId = 'bridge-deposit-control'; + fs.writeFileSync( + path.join(quotesDir, `${quoteId}.json`), + JSON.stringify({ + quoteId, + type: 'bridge', + originChain: 'base', + destinationChain: 'hyperliquid', + walletProvider: 'local', + walletAddress: WALLET, + timestamp: Date.now(), + response: { + execution_type: 'evm_transaction', + request_id: 'req-deposit-1', + steps: [ + { + id: 'deposit', + kind: 'transaction', + items: [{ data: { from: WALLET, to: WALLET, data: '0x', value: '0', gas: '21000' } }], + }, + ], + }, + }), + ); + + const api = { + request: async (endpoint, body) => { + if (endpoint.startsWith('/api/v1/sanctions/screen')) { + return { results: (body?.addresses || []).map(address => ({ address, sanctioned: false })) }; + } + return { status: 'success' }; + }, + }; + + // The spy throws, so reaching it surfaces as a rejection. + await expect( + buildBridgeCommands({ log: () => {} }).execute([], api, {}, { quote: quoteId }), + ).rejects.toThrow(/must not be reached on a withdrawal/); + expect(signEvmTransaction).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/__tests__/builder-fee-ceiling.test.js b/src/__tests__/builder-fee-ceiling.test.js new file mode 100644 index 00000000..12979447 --- /dev/null +++ b/src/__tests__/builder-fee-ceiling.test.js @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(() => ({ name: 'w', evm: '0x' + 'ab'.repeat(20), provider: 'local' })), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(() => ({ evm: { privateKey: '11'.repeat(32) } })), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: null, source: null })), +})); + +vi.mock('../hl-client.js', () => ({ + submitExchange: vi.fn(async () => ({ status: 'ok', response: { data: { statuses: [{ resting: {} }] } } })), +})); + +import { submitExchange } from '../hl-client.js'; +import { buildPerpCommands } from '../perp.js'; + +// M4: the builder fee arrives from the API and approveBuilderFee authorises a +// *maximum* rate on Hyperliquid, so an unbounded value would be signed as given. +// Only threat model is a compromised or misconfigured API — defence in depth. + +// Stub the proxy reads the order path makes: /perp/meta, /perp/builder-fee and +// /sanctions/screen. `requiredFee` is what the ceiling is being tested against. +function makeApi({ requiredFee, approved = false }) { + return { + request: vi.fn(async (endpoint) => { + if (endpoint.includes('/perp/meta')) { + return { assets: [{ name: 'ETH', asset_id: 1, sz_decimals: 4, max_leverage: 25 }] }; + } + if (endpoint.includes('/perp/builder-fee')) { + return { + approved, + required_fee: requiredFee, + max_fee_rate: '0.08%', + builder_address: '0x' + 'CD'.repeat(20), + }; + } + if (endpoint.includes('/sanctions/screen')) { + return { results: [{ address: '0x' + 'ab'.repeat(20), sanctioned: false }] }; + } + throw new Error(`unexpected endpoint ${endpoint}`); + }), + }; +} + +const order = { coin: 'ETH', side: 'buy', size: '0.01', price: '2000', type: 'limit', wallet: 'w' }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('builder fee ceiling', () => { + it('accepts the published rate', async () => { + const cmds = buildPerpCommands({ log: () => {} }); + await expect(cmds.order([], makeApi({ requiredFee: 80 }), {}, { ...order })).resolves.toBeUndefined(); + expect(submitExchange).toHaveBeenCalled(); + }); + + it('refuses a fee above the ceiling, before signing anything', async () => { + const cmds = buildPerpCommands({ log: () => {} }); + let err; + try { + await cmds.order([], makeApi({ requiredFee: 500 }), {}, { ...order }); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.code).toBe('BUILDER_FEE_TOO_HIGH'); + expect(err.message).toMatch(/500 tenths of a basis point/); + // Nothing may reach Hyperliquid: not the approval, not the order. + expect(submitExchange).not.toHaveBeenCalled(); + }); + + it('refuses a negative fee', async () => { + const cmds = buildPerpCommands({ log: () => {} }); + await expect(cmds.order([], makeApi({ requiredFee: -1 }), {}, { ...order })).rejects.toThrow( + /builder fee/, + ); + expect(submitExchange).not.toHaveBeenCalled(); + }); + + it('catches a units slip (a rate given in percent or bps reads far out of range)', async () => { + const cmds = buildPerpCommands({ log: () => {} }); + // 8 bps expressed as 8000 tenths-of-a-bp by mistake = 0.8%, 10x the real fee. + await expect(cmds.order([], makeApi({ requiredFee: 8000 }), {}, { ...order })).rejects.toThrow( + /this CLI accepts/, + ); + }); + + it('names the rate and beneficiary before signing the approval', async () => { + const lines = []; + const cmds = buildPerpCommands({ log: (m) => lines.push(m) }); + await cmds.order([], makeApi({ requiredFee: 80, approved: false }), {}, { ...order }); + const approval = lines.find(l => l.includes('Approving Nansen builder fee')); + expect(approval).toMatch(/max 0\.08%/); + expect(approval).toMatch(new RegExp('0x' + 'cd'.repeat(20))); + }); + + it('skips the approval when the wallet has already approved', async () => { + const lines = []; + const cmds = buildPerpCommands({ log: (m) => lines.push(m) }); + await cmds.order([], makeApi({ requiredFee: 80, approved: true }), {}, { ...order }); + expect(lines.some(l => l.includes('Approving Nansen builder fee'))).toBe(false); + // Just the order, no approval action. + expect(submitExchange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 710497b0..abf52428 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -4211,9 +4211,11 @@ describe('deprecation warnings', () => { expect(DEPRECATED_TO_RESEARCH.has('profiler')).toBe(true); expect(DEPRECATED_TO_RESEARCH.has('token')).toBe(true); expect(DEPRECATED_TO_RESEARCH.has('search')).toBe(true); - expect(DEPRECATED_TO_RESEARCH.has('perp')).toBe(true); expect(DEPRECATED_TO_RESEARCH.has('portfolio')).toBe(true); expect(DEPRECATED_TO_RESEARCH.has('points')).toBe(true); + // 'perp' is a top-level trading command (nansen perp order|close|...), not a + // deprecated alias for 'research perp', so it must not be in this set. + expect(DEPRECATED_TO_RESEARCH.has('perp')).toBe(false); }); it('should include quote and execute in DEPRECATED_TO_TRADE', () => { @@ -4298,9 +4300,17 @@ describe('SCHEMA structure', () => { expect(SCHEMA.commands.profiler).toBeUndefined(); expect(SCHEMA.commands.token).toBeUndefined(); expect(SCHEMA.commands.search).toBeUndefined(); - expect(SCHEMA.commands.perp).toBeUndefined(); expect(SCHEMA.commands.portfolio).toBeUndefined(); expect(SCHEMA.commands.points).toBeUndefined(); + // Note: `perp` IS a valid top-level command — Hyperliquid perp *trading* + // (nansen perp order/close/...). Perp *analytics* lives under `research perp`. + }); + + it('documents perp trading as a top-level command', () => { + expect(SCHEMA.commands.perp).toBeDefined(); + expect(SCHEMA.commands.perp.subcommands.order).toBeDefined(); + expect(SCHEMA.commands.perp.subcommands.close).toBeDefined(); + expect(SCHEMA.commands.perp.subcommands.account.endpoint).toBe('/api/v1/perp/account'); }); }); diff --git a/src/__tests__/cli.test.js b/src/__tests__/cli.test.js index d0b9943f..f43989f1 100644 --- a/src/__tests__/cli.test.js +++ b/src/__tests__/cli.test.js @@ -68,13 +68,22 @@ describe('CLI Smoke Tests', () => { it('should show schema', () => { const { stdout, exitCode } = runCLI('schema'); - + expect(exitCode).toBe(0); const schema = JSON.parse(stdout); expect(schema.version).toBeDefined(); expect(schema.commands).toBeDefined(); }); + it('warns on stderr when running a deprecated alias (L3)', () => { + // "quote" is deprecated in favour of "trade quote"; running it (even when it + // then errors on missing args) should print the notice to stderr. + const { stdout, stderr } = runCLI('quote', { env: { NANSEN_API_KEY: 'invalid-key' } }); + const combined = (stdout || '') + (stderr || ''); + expect(combined).toContain('"nansen quote" is deprecated'); + expect(combined).toContain('trade quote'); + }); + // =================== JSON Output Format =================== it('should output valid JSON on error', () => { diff --git a/src/__tests__/eip1559-signing.test.js b/src/__tests__/eip1559-signing.test.js new file mode 100644 index 00000000..79499326 --- /dev/null +++ b/src/__tests__/eip1559-signing.test.js @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import { RLP } from '@ethereumjs/rlp'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { keccak256 } from '../crypto.js'; +import { signEvmTransaction, signEip1559Transaction } from '../trading.js'; + +// Valid secp256k1 scalar, matching the convention in perp.test.js. Deliberately +// not a random 64-hex literal: those read as a real private key to secret +// scanners, and nothing here depends on the key's value — the recovery test +// derives the expected address from it. +const KEY = '11'.repeat(32); +const BASE_CHAIN_ID = 8453; + +function addressForKey(privHex) { + const pub = secp256k1.getPublicKey(Buffer.from(privHex, 'hex'), false); + return '0x' + Buffer.from(keccak256(Buffer.from(pub).subarray(1))).subarray(12).toString('hex'); +} + +function bufToBigInt(u8) { + const hex = Buffer.from(u8).toString('hex'); + return hex === '' ? 0n : BigInt('0x' + hex); +} + +// Decode 0x02 || RLP([...]) back into its 12 fields. +function decodeType2(raw) { + const bytes = Buffer.from(raw.slice(2), 'hex'); + expect(bytes[0]).toBe(0x02); + const fields = RLP.decode(Uint8Array.from(bytes.subarray(1))); + return { fields, payload: bytes.subarray(1) }; +} + +const TX = { + nonce: 18, + // Numbers, not hand-written hex: the fee values are deliberately different so + // a maxFee/priority transposition fails the ordering assertions below. + maxPriorityFeePerGas: 1100000, + maxFeePerGas: 6600000, + gasLimit: 73112, + to: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + value: '0x0', + data: '0x095ea7b3', + chainId: BASE_CHAIN_ID, +}; + +describe('signEip1559Transaction envelope', () => { + const raw = signEip1559Transaction(TX, KEY); + + it('is a type-2 envelope with 12 RLP fields', () => { + const { fields } = decodeType2(raw); + expect(fields).toHaveLength(12); + }); + + // Pins the EIP-1559 field order independently of the signer: the fee values + // differ, so swapping maxFee and priority would fail here. + it('orders fields per EIP-1559', () => { + const { fields } = decodeType2(raw); + expect(bufToBigInt(fields[0])).toBe(BigInt(BASE_CHAIN_ID)); + expect(bufToBigInt(fields[1])).toBe(18n); + expect(bufToBigInt(fields[2])).toBe(1100000n); // maxPriorityFeePerGas + expect(bufToBigInt(fields[3])).toBe(6600000n); // maxFeePerGas + expect(bufToBigInt(fields[4])).toBe(73112n); // gasLimit + expect('0x' + Buffer.from(fields[5]).toString('hex')).toBe(TX.to); + expect(bufToBigInt(fields[6])).toBe(0n); // value + expect('0x' + Buffer.from(fields[7]).toString('hex')).toBe(TX.data); + expect(fields[8]).toEqual([]); // accessList + }); + + it('uses a raw yParity of 0 or 1, not an EIP-155 v', () => { + const { fields } = decodeType2(raw); + const yParity = bufToBigInt(fields[9]); + expect([0n, 1n]).toContain(yParity); + // EIP-155 would put chainId*2+35+bit here, which for Base is >= 16941. + expect(yParity).toBeLessThan(2n); + }); + + // The real check: recover the signer from the signature over the unsigned + // payload and confirm it is the key's own address. A wrong hash, wrong + // yParity or mis-serialised r/s all fail here. + it('recovers to the signing address', () => { + const { fields } = decodeType2(raw); + const unsignedPayload = Buffer.concat([ + Buffer.from([0x02]), + Buffer.from(RLP.encode(fields.slice(0, 9))), + ]); + const msgHash = keccak256(unsignedPayload); + + const r = Buffer.from(fields[10]).toString('hex').padStart(64, '0'); + const s = Buffer.from(fields[11]).toString('hex').padStart(64, '0'); + const yParity = Number(bufToBigInt(fields[9])); + + const sig = secp256k1.Signature + .fromBytes(Buffer.from(r + s, 'hex'), 'compact') + .addRecoveryBit(yParity); + const pub = sig.recoverPublicKey(msgHash).toBytes(false); + const recovered = '0x' + Buffer.from(keccak256(Buffer.from(pub).subarray(1))).subarray(12).toString('hex'); + + expect(recovered).toBe(addressForKey(KEY)); + }); +}); + +describe('signEvmTransaction transaction-type selection', () => { + const base = { + to: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + data: '0x095ea7b3', + value: '0', + gas: '73112', + }; + + it('emits type 2 when the quote carries fee caps', () => { + const raw = signEvmTransaction( + { ...base, maxFeePerGas: '6600000', maxPriorityFeePerGas: '1100000' }, + KEY, 'base', 18, + ); + expect(raw.startsWith('0x02')).toBe(true); + }); + + it('preserves both fee fields rather than flattening them', () => { + const raw = signEvmTransaction( + { ...base, maxFeePerGas: '6600000', maxPriorityFeePerGas: '1100000' }, + KEY, 'base', 18, + ); + const { fields } = decodeType2(raw); + expect(bufToBigInt(fields[2])).toBe(1100000n); + expect(bufToBigInt(fields[3])).toBe(6600000n); + }); + + it('falls back to the fee cap when only maxFeePerGas is given', () => { + const raw = signEvmTransaction({ ...base, maxFeePerGas: '6600000' }, KEY, 'base', 18); + const { fields } = decodeType2(raw); + expect(bufToBigInt(fields[2])).toBe(6600000n); + expect(bufToBigInt(fields[3])).toBe(6600000n); + }); + + it('still emits legacy for a gasPrice-only quote', () => { + const raw = signEvmTransaction({ ...base, gasPrice: '6600000' }, KEY, 'base', 18); + expect(raw.startsWith('0x02')).toBe(false); + }); + + // The old fallback signed a 1-wei transaction: unmineable, and it burns the + // nonce for every later attempt. + it('refuses to sign when the quote has no fee information', () => { + expect(() => signEvmTransaction({ ...base }, KEY, 'base', 18)).toThrow( + /no gas price.*Refusing to sign/s, + ); + }); +}); diff --git a/src/__tests__/evm-nonce.test.js b/src/__tests__/evm-nonce.test.js new file mode 100644 index 00000000..d8b3c4d5 --- /dev/null +++ b/src/__tests__/evm-nonce.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { getEvmNonce } from '../trading.js'; + +// evmRpcCall goes through global fetch, so stubbing fetch exercises the real +// getEvmNonce end to end. +function mockRpc({ pending, latest }) { + return vi.fn(async (url, init) => { + const body = JSON.parse(init.body); + const result = body.params?.[1] === 'pending' ? pending : latest; + const payload = JSON.stringify({ jsonrpc: '2.0', id: body.id, result }); + return { ok: true, status: 200, text: async () => payload }; + }); +} + +const ADDR = '0x' + 'ab'.repeat(20); +let prevFetch; + +beforeEach(() => { + prevFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = prevFetch; +}); + +describe('getEvmNonce', () => { + it('returns a decimal number, not a hex string', async () => { + // The regression that wedged a real deposit: bridge.js decoded the result a + // second time, and parseInt(20, 16) is 32 — a wallet at nonce 20 signed at + // nonce 32, which no node can execute. It stayed invisible below nonce 10, + // where decimal and hex digits coincide. + globalThis.fetch = mockRpc({ pending: '0x14', latest: '0x14' }); + const nonce = await getEvmNonce('base', ADDR); + expect(nonce).toBe(20); + expect(typeof nonce).toBe('number'); + }); + + it('returns the pending count so sequential steps can be numbered', async () => { + // approve then deposit: the second has to be numbered after the first while + // the first is still in the mempool. + globalThis.fetch = mockRpc({ pending: '0x15', latest: '0x14' }); + expect(await getEvmNonce('base', ADDR)).toBe(21); + }); + + it('allows a small pending gap', async () => { + globalThis.fetch = mockRpc({ pending: '0x16', latest: '0x14' }); + expect(await getEvmNonce('base', ADDR)).toBe(22); + }); + + it('refuses to sign past a pile of unmined transactions', async () => { + globalThis.fetch = mockRpc({ pending: '0x20', latest: '0x14' }); + await expect(getEvmNonce('base', ADDR)).rejects.toThrow( + /has 12 unmined transactions queued on base/, + ); + }); + + it('names the stuck nonce and the recovery command', async () => { + globalThis.fetch = mockRpc({ pending: '0x1e', latest: '0x14' }); + let err; + try { + await getEvmNonce('base', ADDR); + } catch (e) { + err = e; + } + expect(err.message).toMatch(/next mined nonce 20, next pending 30/); + expect(err.message).toMatch(/--nonce 20 --priority-fee/); + // The load-balanced-RPC caveat cost a debugging session; keep it in the message. + expect(err.message).toMatch(/do not diagnose from one endpoint/); + }); + + it('reports an unreadable nonce rather than signing on NaN', async () => { + globalThis.fetch = mockRpc({ pending: 'garbage', latest: '0x14' }); + await expect(getEvmNonce('base', ADDR)).rejects.toThrow(/Could not read the nonce/); + }); +}); diff --git a/src/__tests__/hl-action.test.js b/src/__tests__/hl-action.test.js new file mode 100644 index 00000000..292d0c59 --- /dev/null +++ b/src/__tests__/hl-action.test.js @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; +import { + encodeMsgpack, + floatToWire, + pyRound, + roundPrice, + roundSize, + actionHash, + l1Eip712, + buildOrderAction, + buildCancelAction, + buildCloseAction, + buildLeverageAction, +} from '../hl-action.js'; + +// The Nansen builder code the API attaches to every order/close fill. +const BUILDER = { b: '0x93053f1e7a5efeda532fe69cbbe43cbec3a0f13f', f: 80 }; +const ETH = { assetId: 1, szDecimals: 4 }; +const BTC = { assetId: 0, szDecimals: 5 }; + +// ── Golden vectors ─────────────────────────────────────────────────── +// +// Captured from the live nansen-api /perp/* prepare endpoints (the known-good +// Python path via hyperliquid-python-sdk) on 2026-07-23. Each asserts that the +// locally-built action reproduces the API's `action` AND that the phantom-agent +// connectionId (keccak of msgpack(action)‖nonce‖vault) matches byte-for-byte. +// If msgpack, rounding, or wire assembly drifts, the connectionId diverges and +// the test fails — no mainnet guess required. Regenerate with +// scratchpad/verify_batch.mjs against the live endpoints if the SDK bumps. + +const GOLDEN = [ + { + name: 'limit order (buy, Gtc, builder attached)', + build: () => buildOrderAction({ coin: 'ETH', isBuy: true, size: 0.0123, price: 1850.7, orderType: 'limit', tif: 'Gtc', slippage: 0.03, reduceOnly: false, builder: BUILDER }, ETH), + nonce: 1784805814604, + action: { type: 'order', orders: [{ a: 1, b: true, p: '1850.7', s: '0.0123', r: false, t: { limit: { tif: 'Gtc' } } }], grouping: 'na', builder: BUILDER }, + connectionId: '0x0fa7293cb6fc5802d8b2169839d90eeec30a0ef0d09856b32d7977e807d2bd11', + }, + { + name: 'market order (slippage folded into Ioc price, size rounded)', + build: () => buildOrderAction({ coin: 'ETH', isBuy: true, size: 0.0071111, price: 1850.7, orderType: 'market', slippage: 0.03, reduceOnly: false, builder: BUILDER }, ETH), + nonce: 1784805870819, + action: { type: 'order', orders: [{ a: 1, b: true, p: '1906.2', s: '0.0071', r: false, t: { limit: { tif: 'Ioc' } } }], grouping: 'na', builder: BUILDER }, + connectionId: '0x35f5e7cd138f400f406618d81c3d55043490c124d39e47e4c56c035c056a5882', + }, + { + name: 'order with TP/SL (normalTpsl grouping, banker-rounded SL 1700.25->1700.2)', + build: () => buildOrderAction({ coin: 'ETH', isBuy: true, size: 0.05, price: 1850.7, orderType: 'limit', tif: 'Gtc', takeProfit: 2100.5, stopLoss: 1700.25, reduceOnly: false, builder: BUILDER }, ETH), + nonce: 1784805872347, + action: { + type: 'order', + orders: [ + { a: 1, b: true, p: '1850.7', s: '0.05', r: false, t: { limit: { tif: 'Gtc' } } }, + { a: 1, b: false, p: '2100.5', s: '0.05', r: true, t: { trigger: { isMarket: true, triggerPx: '2100.5', tpsl: 'tp' } } }, + { a: 1, b: false, p: '1700.2', s: '0.05', r: true, t: { trigger: { isMarket: true, triggerPx: '1700.2', tpsl: 'sl' } } }, + ], + grouping: 'normalTpsl', + builder: BUILDER, + }, + connectionId: '0x5ea15b504a31fa6a0c29403fe7fae12db798f0ef24a5cad38a4e7c600de12470', + }, + { + name: 'BTC limit sell (5 sig-fig truncation 100123.456->100120, Alo)', + build: () => buildOrderAction({ coin: 'BTC', isBuy: false, size: 0.001234, price: 100123.456, orderType: 'limit', tif: 'Alo', reduceOnly: false, builder: BUILDER }, BTC), + nonce: 1784805873440, + action: { type: 'order', orders: [{ a: 0, b: false, p: '100120', s: '0.00123', r: false, t: { limit: { tif: 'Alo' } } }], grouping: 'na', builder: BUILDER }, + connectionId: '0xac56926d68c9f0dad07712bdd7a7e6eb82b67f36ca518522c63264a773def4b6', + }, + { + name: 'cancel', + build: () => buildCancelAction({ orderId: 123456789 }, ETH), + nonce: 1784805874771, + action: { type: 'cancel', cancels: [{ a: 1, o: 123456789 }] }, + connectionId: '0x21831e80ecfbfb90c14889317ab9b88626a52a82107527d924d1367683905086', + }, + { + name: 'close (market reduce-only, builder attached)', + build: () => buildCloseAction({ size: 0.0071111, price: 1850.7, isBuy: false, slippage: 0.03, builder: BUILDER }, ETH), + nonce: 1784805876384, + action: { type: 'order', orders: [{ a: 1, b: false, p: '1795.2', s: '0.0071', r: true, t: { limit: { tif: 'Ioc' } } }], grouping: 'na', builder: BUILDER }, + connectionId: '0x1ba887e6fb1612668898392bd956ae12b175ea96c3ffef965bef4ed6117cd7a2', + }, + { + name: 'updateLeverage (cross)', + build: () => buildLeverageAction({ leverage: 10, isCross: true }, ETH), + nonce: 1784805877448, + action: { type: 'updateLeverage', asset: 1, isCross: true, leverage: 10 }, + connectionId: '0x6ee8bba5f82c0cd32b2929ab67d0335c746926ec69d100ba2a15b748516f7924', + }, +]; + +describe('hl-action golden vectors (vs live API prepare)', () => { + for (const g of GOLDEN) { + it(g.name, () => { + const { action } = g.build(); + // Key order is load-bearing (msgpack encodes maps in insertion order), so + // compare the serialized form, not just deep structural equality. + expect(JSON.stringify(action)).toBe(JSON.stringify(g.action)); + const eip = l1Eip712(action, null, g.nonce); + expect(eip.message.connectionId).toBe(g.connectionId); + }); + } +}); + +describe('floatToWire', () => { + it('strips trailing zeros and the dot', () => { + expect(floatToWire(1850.7)).toBe('1850.7'); + expect(floatToWire(0.0123)).toBe('0.0123'); + expect(floatToWire(2000)).toBe('2000'); + expect(floatToWire(100120)).toBe('100120'); + }); + it('throws when a value cannot be represented in 8 decimals', () => { + expect(() => floatToWire(0.123456789)).toThrow(/rounding/); + }); +}); + +describe('pyRound (bankers rounding)', () => { + it('rounds ties to even, not away from zero', () => { + expect(pyRound(0.5, 0)).toBe(0); // 0 is even + expect(pyRound(1.5, 0)).toBe(2); // 2 is even + expect(pyRound(2.5, 0)).toBe(2); // 2 is even + expect(pyRound(1700.25, 1)).toBe(1700.2); // 2 is even + }); + it('handles negative ndigits without float dirt', () => { + expect(pyRound(100123.456, -1)).toBe(100120); + }); +}); + +describe('roundPrice / roundSize', () => { + it('applies 5 sig-figs then perp decimal cap', () => { + // ETH szDecimals=4 -> price capped at 2 decimals; 5 sig-figs first. + expect(roundPrice(1906.221, 4)).toBe(1906.2); + // BTC szDecimals=5 -> 1 decimal; 100123.456 -> 100120. + expect(roundPrice(100123.456, 5)).toBe(100120); + }); + it('rounds size to szDecimals', () => { + expect(roundSize(0.0071111, 4)).toBe(0.0071); + expect(roundSize(0.001234, 5)).toBe(0.00123); + }); +}); + +describe('encodeMsgpack primitives', () => { + it('positive fixint', () => { + expect(encodeMsgpack(0).equals(Buffer.from([0x00]))).toBe(true); + expect(encodeMsgpack(127).equals(Buffer.from([0x7f]))).toBe(true); + }); + it('uint8 / uint16 / uint32 width selection', () => { + expect(encodeMsgpack(128).equals(Buffer.from([0xcc, 0x80]))).toBe(true); + expect(encodeMsgpack(256).equals(Buffer.from([0xcd, 0x01, 0x00]))).toBe(true); + expect(encodeMsgpack(123456789).equals(Buffer.from([0xce, 0x07, 0x5b, 0xcd, 0x15]))).toBe(true); + }); + it('bool and fixstr', () => { + expect(encodeMsgpack(true).equals(Buffer.from([0xc3]))).toBe(true); + expect(encodeMsgpack(false).equals(Buffer.from([0xc2]))).toBe(true); + expect(encodeMsgpack('na').equals(Buffer.from([0xa2, 0x6e, 0x61]))).toBe(true); // fixstr len 2 + "na" + }); + it('fixmap preserves insertion order', () => { + // {"a":1,"b":true} -> 0x82 a1 61 01 a1 62 c3 + const got = encodeMsgpack({ a: 1, b: true }); + expect(got.equals(Buffer.from([0x82, 0xa1, 0x61, 0x01, 0xa1, 0x62, 0xc3]))).toBe(true); + }); +}); + +describe('actionHash', () => { + it('appends the null-vault byte and hashes to the golden connectionId', () => { + const action = { type: 'cancel', cancels: [{ a: 1, o: 123456789 }] }; + const hash = actionHash(action, null, 1784805874771); + expect('0x' + hash.toString('hex')).toBe('0x21831e80ecfbfb90c14889317ab9b88626a52a82107527d924d1367683905086'); + }); +}); diff --git a/src/__tests__/hl-client.test.js b/src/__tests__/hl-client.test.js new file mode 100644 index 00000000..1a72035a --- /dev/null +++ b/src/__tests__/hl-client.test.js @@ -0,0 +1,174 @@ +/** + * Tests for src/hl-client.js — the single direct-to-HL /exchange submit. + * + * Uses an injected fetch (the `fetchImpl` option) so no network is touched. + * Covers the two failure modes the backend proxy used to catch and now must be + * caught client-side: a top-level status "err", and a status "ok" that still + * carries a per-action error in response.data.statuses[].error. + */ + +import { describe, it, expect, vi } from "vitest"; +import { + submitExchange, + extractActionErrors, + hlApiUrl, + HL_MAINNET_API_URL, +} from "../hl-client.js"; + +// Build a fake fetch that returns one response. `ok` defaults to true. +function fakeFetch(bodyObj, { ok = true, status = 200, nonJson = false } = {}) { + const text = nonJson + ? "gateway timeout" + : JSON.stringify(bodyObj); + return vi.fn(async () => ({ ok, status, text: async () => text })); +} + +const OK_FILL = { + status: "ok", + response: { + type: "order", + data: { + statuses: [{ filled: { oid: 123, totalSz: "0.01", avgPx: "1850.7" } }], + }, + }, +}; + +const SIGNED = { + action: { type: "order", orders: [], grouping: "na" }, + nonce: 1784805814604, + signature: { r: "0x01", s: "0x02", v: 27 }, +}; + +describe("submitExchange", () => { + it("POSTs to /exchange with the signed body and returns the parsed response on ok", async () => { + const fetchImpl = fakeFetch(OK_FILL); + const result = await submitExchange(SIGNED, { + fetchImpl, + baseUrl: "https://hl.test", + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, opts] = fetchImpl.mock.calls[0]; + expect(url).toBe("https://hl.test/exchange"); + expect(opts.method).toBe("POST"); + expect(opts.headers["Content-Type"]).toBe("application/json"); + const sent = JSON.parse(opts.body); + expect(sent).toEqual({ + action: SIGNED.action, + nonce: SIGNED.nonce, + signature: SIGNED.signature, + }); + expect(result).toEqual(OK_FILL); + }); + + it("omits vaultAddress when null but includes it when set", async () => { + const f1 = fakeFetch(OK_FILL); + await submitExchange(SIGNED, { fetchImpl: f1, baseUrl: "https://hl.test" }); + expect("vaultAddress" in JSON.parse(f1.mock.calls[0][1].body)).toBe(false); + + const f2 = fakeFetch(OK_FILL); + await submitExchange( + { ...SIGNED, vaultAddress: "0xabc" }, + { fetchImpl: f2, baseUrl: "https://hl.test" } + ); + expect(JSON.parse(f2.mock.calls[0][1].body).vaultAddress).toBe("0xabc"); + }); + + it('throws on a top-level status "err" and surfaces the reason', async () => { + const fetchImpl = fakeFetch({ + status: "err", + response: "Insufficient margin", + }); + await expect( + submitExchange(SIGNED, { fetchImpl, baseUrl: "https://hl.test" }) + ).rejects.toMatchObject({ + code: "HL_ACTION_REJECTED", + message: expect.stringContaining("Insufficient margin"), + }); + }); + + it('throws on a per-action error even when top-level status is "ok"', async () => { + const fetchImpl = fakeFetch({ + status: "ok", + response: { + type: "order", + data: { + statuses: [ + { + error: + "Order price cannot be more than 95% away from the reference price", + }, + ], + }, + }, + }); + await expect( + submitExchange(SIGNED, { fetchImpl, baseUrl: "https://hl.test" }) + ).rejects.toMatchObject({ + code: "HL_ACTION_REJECTED", + message: expect.stringContaining("95% away"), + }); + }); + + it("throws HL_HTTP_ERROR on a non-2xx response", async () => { + const fetchImpl = fakeFetch( + { error: "rate limited" }, + { ok: false, status: 429 } + ); + await expect( + submitExchange(SIGNED, { fetchImpl, baseUrl: "https://hl.test" }) + ).rejects.toMatchObject({ + code: "HL_HTTP_ERROR", + message: expect.stringContaining("429"), + }); + }); + + it("throws HL_BAD_RESPONSE on a non-JSON body", async () => { + const fetchImpl = fakeFetch(null, { nonJson: true, status: 504 }); + await expect( + submitExchange(SIGNED, { fetchImpl, baseUrl: "https://hl.test" }) + ).rejects.toMatchObject({ + code: "HL_BAD_RESPONSE", + }); + }); + + it("wraps a network error as HL_NETWORK_ERROR and does not retry", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNRESET"); + }); + await expect( + submitExchange(SIGNED, { fetchImpl, baseUrl: "https://hl.test" }) + ).rejects.toMatchObject({ + code: "HL_NETWORK_ERROR", + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); + +describe("extractActionErrors", () => { + it("returns [] for shapes without statuses", () => { + expect(extractActionErrors(null)).toEqual([]); + expect(extractActionErrors("a string")).toEqual([]); + expect(extractActionErrors({ data: {} })).toEqual([]); + expect(extractActionErrors({ data: { statuses: "nope" } })).toEqual([]); + }); + it("collects every per-action error, ignoring filled entries", () => { + expect( + extractActionErrors({ + data: { statuses: [{ filled: {} }, { error: "e1" }, { error: "e2" }] }, + }) + ).toEqual(["e1", "e2"]); + }); +}); + +describe("hlApiUrl", () => { + it("defaults to mainnet and honours NANSEN_HL_API_URL", () => { + const prev = process.env.NANSEN_HL_API_URL; + delete process.env.NANSEN_HL_API_URL; + expect(hlApiUrl()).toBe(HL_MAINNET_API_URL); + process.env.NANSEN_HL_API_URL = "https://api.hyperliquid-testnet.xyz"; + expect(hlApiUrl()).toBe("https://api.hyperliquid-testnet.xyz"); + if (prev === undefined) delete process.env.NANSEN_HL_API_URL; + else process.env.NANSEN_HL_API_URL = prev; + }); +}); diff --git a/src/__tests__/hl-network.test.js b/src/__tests__/hl-network.test.js new file mode 100644 index 00000000..2720ab32 --- /dev/null +++ b/src/__tests__/hl-network.test.js @@ -0,0 +1,104 @@ +import { describe, it, expect, afterEach } from 'vitest'; + +import { hlNetwork, HL_TESTNET_API_URL } from '../hl-env.js'; +import { + buildApproveBuilderFeeAction, + buildUsdClassTransferAction, + l1Eip712, +} from '../hl-action.js'; + +// M8: source/hyperliquidChain used to be hardcoded to mainnet, so pointing +// NANSEN_HL_API_URL at the testnet signed actions the testnet rejects. These pin +// that both fields follow the resolved base URL. + +const prev = process.env.NANSEN_HL_API_URL; + +afterEach(() => { + if (prev === undefined) delete process.env.NANSEN_HL_API_URL; + else process.env.NANSEN_HL_API_URL = prev; +}); + +describe('hlNetwork', () => { + it('defaults to Mainnet', () => { + delete process.env.NANSEN_HL_API_URL; + expect(hlNetwork()).toBe('Mainnet'); + }); + + it('reports Testnet for the HL testnet host', () => { + process.env.NANSEN_HL_API_URL = HL_TESTNET_API_URL; + expect(hlNetwork()).toBe('Testnet'); + }); + + it('treats a local mock as Mainnet, so tests keep the mainnet vectors', () => { + process.env.NANSEN_HL_API_URL = 'http://127.0.0.1:8787'; + expect(hlNetwork()).toBe('Mainnet'); + }); + + it('falls back to Mainnet on an unparseable override rather than throwing', () => { + process.env.NANSEN_HL_API_URL = 'not a url'; + expect(hlNetwork()).toBe('Mainnet'); + }); +}); + +describe('action network derivation', () => { + const action = { type: 'cancel', cancels: [{ a: 1, o: 2 }] }; + + it('signs the L1 phantom agent with source "a" on mainnet', () => { + delete process.env.NANSEN_HL_API_URL; + expect(l1Eip712(action, null, 1).message.source).toBe('a'); + }); + + it('signs the L1 phantom agent with source "b" on testnet', () => { + process.env.NANSEN_HL_API_URL = HL_TESTNET_API_URL; + expect(l1Eip712(action, null, 1).message.source).toBe('b'); + }); + + it('carries hyperliquidChain Testnet into user-signed actions on testnet', () => { + process.env.NANSEN_HL_API_URL = HL_TESTNET_API_URL; + expect( + buildApproveBuilderFeeAction({ maxFeeRate: '0.08%', builder: '0xb', nonce: 1 }).action + .hyperliquidChain, + ).toBe('Testnet'); + expect( + buildUsdClassTransferAction({ amount: 5, toPerp: true, nonce: 1 }).action.hyperliquidChain, + ).toBe('Testnet'); + }); + + it('keeps hyperliquidChain Mainnet by default', () => { + delete process.env.NANSEN_HL_API_URL; + expect( + buildApproveBuilderFeeAction({ maxFeeRate: '0.08%', builder: '0xb', nonce: 1 }).action + .hyperliquidChain, + ).toBe('Mainnet'); + expect( + buildUsdClassTransferAction({ amount: 5, toPerp: true, nonce: 1 }).action.hyperliquidChain, + ).toBe('Mainnet'); + }); + + it('lets a caller pin the network explicitly', () => { + delete process.env.NANSEN_HL_API_URL; + expect(l1Eip712(action, null, 1, 'Testnet').message.source).toBe('b'); + }); +}); + +describe('usdClassTransfer amount rendering', () => { + it('renders a plain amount without trailing zeros', () => { + expect(buildUsdClassTransferAction({ amount: 25, toPerp: true, nonce: 1 }).action.amount).toBe('25'); + expect(buildUsdClassTransferAction({ amount: 0.5, toPerp: false, nonce: 1 }).action.amount).toBe('0.5'); + }); + + it('refuses an amount that would render in exponential notation', () => { + // toFixed() switches to "1e+21" from 1e21 up, which HL's parser rejects — + // previously signed as-is and rejected opaquely after the signature. + expect(() => buildUsdClassTransferAction({ amount: 1e21, toPerp: true, nonce: 1 })).toThrow( + /below 1e21/, + ); + }); + + it('refuses a non-positive or non-finite amount', () => { + expect(() => buildUsdClassTransferAction({ amount: 0, toPerp: true, nonce: 1 })).toThrow(/Invalid/); + expect(() => buildUsdClassTransferAction({ amount: -5, toPerp: true, nonce: 1 })).toThrow(/Invalid/); + expect(() => buildUsdClassTransferAction({ amount: NaN, toPerp: true, nonce: 1 })).toThrow(/Invalid/); + expect(() => buildUsdClassTransferAction({ amount: Infinity, toPerp: true, nonce: 1 })).toThrow(/Invalid/); + }); +}); diff --git a/src/__tests__/limit-order.test.js b/src/__tests__/limit-order.test.js index 653fef32..ed19e3a1 100644 --- a/src/__tests__/limit-order.test.js +++ b/src/__tests__/limit-order.test.js @@ -179,6 +179,16 @@ describe('parseExpiry', () => { expect(() => parseExpiry('invalid')).toThrow('Invalid expiry format'); expect(() => parseExpiry('abc123')).toThrow('Invalid expiry format'); }); + + it('rejects a zero-duration expiry (L5)', () => { + expect(() => parseExpiry('0h')).toThrow(/greater than 0/); + expect(() => parseExpiry('0d')).toThrow(/greater than 0/); + }); + + it('rejects a past epoch (L5)', () => { + const past = Date.now() - 3600000; + expect(() => parseExpiry(String(past))).toThrow(/in the past/); + }); }); // ============= API Client Functions ============= @@ -802,6 +812,39 @@ describe('buildLimitOrderCommands', () => { expect(logs.some(l => l.includes('$80.5'))).toBe(true); }); + it('renders the list even when an order has a non-integer amount (M8)', async () => { + createTestWallet('lo-list-bad-amount'); + const logs = []; + const cmds = buildLimitOrderCommands({ log: (m) => logs.push(m), exit: vi.fn() }); + + mockFetchSequence([ + { body: { challenge: 'sign' } }, + { body: { token: 'jwt' } }, + { + body: { + orders: [{ + id: 'order-bad', + status: 'open', + inputMint: 'So11111111111111111111111111111111111111112', + outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + inputAmount: '1.5e9', // float/scientific — BigInt() would throw + triggerPriceUsd: 80.5, + triggerMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + triggerCondition: 'below', + createdAt: '2026-03-20T00:00:00Z', + fills: [], + }], + pagination: { total: 1, limit: 20, offset: 0 }, + }, + }, + ]); + + await expect( + cmds.list([], null, {}, { wallet: 'lo-list-bad-amount' }), + ).resolves.not.toThrow(); + expect(logs.some(l => l.includes('order-bad'))).toBe(true); + }); + it('passes filter and pagination params', async () => { createTestWallet('lo-list-filter'); const cmds = buildLimitOrderCommands({ log: () => {}, exit: vi.fn() }); diff --git a/src/__tests__/perp.test.js b/src/__tests__/perp.test.js new file mode 100644 index 00000000..c4f24fa2 --- /dev/null +++ b/src/__tests__/perp.test.js @@ -0,0 +1,584 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: null, source: null })), +})); + +// Stub the ONE direct-to-HL network call so the flow tests can assert what +// gets submitted without hitting api.hyperliquid.xyz. No existing test reaches +// submit (they throw at validation/wallet/screening first), so this is inert +// for them. +vi.mock('../hl-client.js', () => ({ + submitExchange: vi.fn(async () => ({ status: 'ok', response: { data: { statuses: [{ resting: {} }] } } })), +})); + +import { showWallet, getWalletConfig, exportWallet } from '../wallet.js'; +import { submitExchange } from '../hl-client.js'; +import { buildPerpCommands } from '../perp.js'; + +// These tests exercise client-side input validation only. Validation runs +// before any wallet resolution or network call, so a rejected input throws +// without needing a configured wallet. +const cmds = buildPerpCommands({ log: () => {} }); + +const baseOrder = { + coin: 'ETH', + side: 'buy', + size: '0.01', + price: '2000', + type: 'limit', + wallet: 'does-not-matter', +}; + +describe('perp order validation', () => { + it('rejects a typo in --side instead of silently opening a short', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, side: 'xyz' }), + ).rejects.toThrow(/Invalid --side "xyz"/); + }); + + it('rejects a near-miss synonym in --side', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, side: 'lng' }), + ).rejects.toThrow(/Invalid --side/); + }); + + it('accepts long/short aliases', async () => { + // These pass validation and fail later (no real wallet) — assert the + // failure is NOT the side validation error. + await expect( + cmds.order([], null, {}, { ...baseOrder, side: 'long' }), + ).rejects.not.toThrow(/Invalid --side/); + await expect( + cmds.order([], null, {}, { ...baseOrder, side: 'short' }), + ).rejects.not.toThrow(/Invalid --side/); + }); + + it('rejects a negative --size', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, size: '-0.01' }), + ).rejects.toThrow(/Invalid --size "-0.01"/); + }); + + it('rejects a non-numeric --size with a specific message (not the usage banner)', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, size: 'abc' }), + ).rejects.toThrow(/Invalid --size "abc"/); + }); + + it('rejects --size with trailing garbage instead of parseFloat-ing it to a number', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, size: '100abc' }), + ).rejects.toThrow(/Invalid --size "100abc"/); + }); + + it('rejects an unknown --tif client-side', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, tif: 'InvalidTIF' }), + ).rejects.toThrow(/Invalid --tif "InvalidTIF"/); + }); + + it('rejects an unknown --type client-side', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, type: 'stop' }), + ).rejects.toThrow(/Invalid --type "stop"/); + }); + + it('accepts a case-insensitive --type (LIMIT) and valid --tif', async () => { + // Pass validation and fail later (no real wallet) — assert the failure is + // NOT a type/tif validation error. + await expect( + cmds.order([], null, {}, { ...baseOrder, type: 'LIMIT', tif: 'Ioc' }), + ).rejects.not.toThrow(/Invalid --(type|tif)/); + }); + + it('rejects --slippage with trailing garbage', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, slippage: '0.03abc' }), + ).rejects.toThrow(/Invalid --slippage "0.03abc"/); + }); + + it('rejects an out-of-range --slippage (percent-vs-decimal mix-up)', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, slippage: '3' }), + ).rejects.toThrow(/Invalid --slippage "3"/); + }); + + it('rejects a non-numeric --take-profit', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, 'take-profit': '1800x' }), + ).rejects.toThrow(/Invalid --take-profit "1800x"/); + }); + + it('rejects a negative --stop-loss', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, 'stop-loss': '-1' }), + ).rejects.toThrow(/Invalid --stop-loss "-1"/); + }); + + it('rejects a zero --price', async () => { + await expect( + cmds.order([], null, {}, { ...baseOrder, price: '0' }), + ).rejects.toThrow(/Invalid --price "0"/); + }); + + it('shows usage when a required arg is omitted', async () => { + const { side, ...noSide } = baseOrder; + void side; + await expect( + cmds.order([], null, {}, noSide), + ).rejects.toThrow(/Usage: nansen perp order/); + }); +}); + +describe('perp precision warnings (NEW — price/size precision)', () => { + // BTC with szDecimals=4 → max 4 size decimals, max (6-4)=2 price decimals. + const metaApi = { request: async () => ({ assets: [{ name: 'BTC', sz_decimals: 4, max_leverage: 50, asset_id: 1 }] }) }; + const base = { coin: 'BTC', side: 'buy', size: '0.001', price: '50000', type: 'limit' }; + + function runOrder(options) { + const warnings = []; + const cmds2 = buildPerpCommands({ log: () => {}, warn: (m) => warnings.push(m) }); + // Validation/warnings run before wallet resolution, which then rejects (no + // wallet) — swallow that so we can assert on the warnings collected first. + return cmds2.order([], metaApi, {}, options).catch(() => warnings); + } + + it('warns when --size is finer than the asset allows (M4 root cause)', async () => { + const warnings = await runOrder({ ...base, size: '0.0071111' }); + expect(warnings.some(w => /--size 0.0071111 is more precise than BTC/.test(w))).toBe(true); + }); + + it('warns when --price exceeds the asset price precision (NEW)', async () => { + const warnings = await runOrder({ ...base, price: '50000.123' }); + expect(warnings.some(w => /--price 50000.123 is more precise than BTC/.test(w))).toBe(true); + }); + + it('does not warn when size and price are within precision', async () => { + const warnings = await runOrder({ ...base, size: '0.0071', price: '50000.1' }); + expect(warnings.length).toBe(0); + }); + + it('falls open (no warning, no crash) when meta is unavailable', async () => { + const warnings = []; + const brokenApi = { request: async () => { throw new Error('meta down'); } }; + const cmds2 = buildPerpCommands({ log: () => {}, warn: (m) => warnings.push(m) }); + await cmds2.order([], brokenApi, {}, { ...base, size: '0.0071111' }).catch(() => {}); + expect(warnings.length).toBe(0); + }); +}); + +describe('perp close validation', () => { + const baseClose = { coin: 'ETH', side: 'sell', size: '0.01', price: '2000', wallet: 'x' }; + + it('only allows buy/sell for --side', async () => { + await expect( + cmds.close([], null, {}, { ...baseClose, side: 'long' }), + ).rejects.toThrow(/Invalid --side "long"/); + }); + + it('rejects a negative --size', async () => { + await expect( + cmds.close([], null, {}, { ...baseClose, size: '-1' }), + ).rejects.toThrow(/Invalid --size/); + }); + + it('rejects an out-of-range --slippage', async () => { + await expect( + cmds.close([], null, {}, { ...baseClose, slippage: '5' }), + ).rejects.toThrow(/Invalid --slippage "5"/); + }); +}); + +describe('perp leverage validation', () => { + const baseLev = { coin: 'ETH', leverage: '3', wallet: 'x' }; + + it('rejects a typo in --margin-type instead of silently switching to isolated', async () => { + await expect( + cmds.leverage([], null, {}, { ...baseLev, 'margin-type': 'xolated' }), + ).rejects.toThrow(/Invalid --margin-type "xolated"/); + }); + + it('accepts cross/isolated', async () => { + await expect( + cmds.leverage([], null, {}, { ...baseLev, 'margin-type': 'isolated' }), + ).rejects.not.toThrow(/Invalid --margin-type/); + }); + + it('rejects a fractional --leverage instead of silently flooring it', async () => { + await expect( + cmds.leverage([], null, {}, { ...baseLev, leverage: '2.5' }), + ).rejects.toThrow(/Invalid --leverage "2.5"/); + }); + + it('rejects a zero --leverage with a specific message', async () => { + await expect( + cmds.leverage([], null, {}, { ...baseLev, leverage: '0' }), + ).rejects.toThrow(/Invalid --leverage "0"/); + }); + + // meta exposes max_leverage per asset; the leverage command pre-checks against it. + const metaApi = { request: async () => ({ assets: [{ name: 'ETH', max_leverage: 25, sz_decimals: 4, asset_id: 1 }] }) }; + + it('rejects leverage above the asset maximum with a clear message', async () => { + await expect( + cmds.leverage([], metaApi, {}, { ...baseLev, leverage: '100' }), + ).rejects.toThrow(/exceeds the 25x maximum for ETH/); + }); + + it('allows leverage within the asset maximum (passes the pre-check)', async () => { + await expect( + cmds.leverage([], metaApi, {}, { ...baseLev, leverage: '10' }), + ).rejects.not.toThrow(/exceeds the/); + }); + + it('falls open when meta is unavailable (does not block on the pre-check)', async () => { + const brokenApi = { request: async () => { throw new Error('meta down'); } }; + await expect( + cmds.leverage([], brokenApi, {}, { ...baseLev, leverage: '100' }), + ).rejects.not.toThrow(/exceeds the|meta down/); + }); +}); + +describe('perp cancel validation', () => { + it('rejects --oid 0 with a specific message (not the usage banner)', async () => { + await expect( + cmds.cancel([], null, {}, { coin: 'ETH', oid: '0', wallet: 'x' }), + ).rejects.toThrow(/Invalid --oid "0"/); + }); +}); + +describe('perp meta listing (L1)', () => { + // 25 fake assets so the default-20 truncation is observable; HYPE is last. + const assets = Array.from({ length: 25 }, (_, i) => ({ + asset_id: i, + name: i === 24 ? 'HYPE' : `A${i}`, + sz_decimals: 2, + max_leverage: 50, + })); + const fakeApi = { request: async () => ({ assets }) }; + + function run(options) { + const logs = []; + const metaCmds = buildPerpCommands({ log: (m) => logs.push(m) }); + return metaCmds.meta([], fakeApi, options.flags || {}, options.options || {}).then(() => logs.join('\n')); + } + + it('truncates to 20 by default and hints at --all', async () => { + const out = await run({}); + expect(out).toContain('... and 5 more'); + expect(out).not.toContain('HYPE'); + }); + + it('shows everything with --all', async () => { + const out = await run({ flags: { all: true } }); + expect(out).toContain('HYPE'); + expect(out).not.toContain('more (use --all'); + }); + + it('filters by name with --filter', async () => { + const out = await run({ options: { filter: 'hype' } }); + expect(out).toContain('HYPE'); + expect(out).toContain('matching "hype"'); + }); +}); + +describe('perp close direction (NEW-4)', () => { + const baseClose = { coin: 'ETH', size: '0.1', price: '2000', wallet: 'x' }; + const apiWith = (positions) => ({ request: vi.fn(async () => ({ positions })) }); + + beforeEach(() => { + showWallet.mockReturnValue({ name: 'x', evm: '0x' + '1'.repeat(40), provider: 'local' }); + }); + + it('rejects closing a long with --side buy (wrong direction)', async () => { + const api = apiWith([{ coin: 'ETH', szi: '0.5' }]); + await expect( + cmds.close([], api, {}, { ...baseClose, side: 'buy' }), + ).rejects.toThrow(/Cannot close a long ETH position with --side buy\. Use --side sell/); + }); + + it('rejects closing a short with --side sell (wrong direction)', async () => { + const api = apiWith([{ coin: 'ETH', szi: '-0.5' }]); + await expect( + cmds.close([], api, {}, { ...baseClose, side: 'sell' }), + ).rejects.toThrow(/Cannot close a short ETH position with --side sell\. Use --side buy/); + }); + + it('allows the correct close direction (sell closes a long)', async () => { + const api = apiWith([{ coin: 'ETH', szi: '0.5' }]); + await expect( + cmds.close([], api, {}, { ...baseClose, side: 'sell' }), + ).rejects.not.toThrow(/Cannot close/); + }); + + it('falls open when no open position matches the coin', async () => { + const api = apiWith([{ coin: 'BTC', szi: '0.5' }]); + await expect( + cmds.close([], api, {}, { ...baseClose, side: 'buy' }), + ).rejects.not.toThrow(/Cannot close/); + }); + + it('falls open when the positions lookup fails', async () => { + const brokenApi = { request: vi.fn(async () => { throw new Error('positions down'); }) }; + await expect( + cmds.close([], brokenApi, {}, { ...baseClose, side: 'buy' }), + ).rejects.not.toThrow(/Cannot close|positions down/); + }); +}); + +describe('perp duplicate flags (6824)', () => { + it('rejects a duplicated --coin with a clean coded error instead of crashing', async () => { + const err = await cmds.order([], null, {}, { ...baseOrder, coin: ['ETH', 'BTC'] }).catch(e => e); + expect(err.code).toBe('INVALID_INPUT'); + expect(err.message).toMatch(/--coin was provided more than once/); + expect(err.message).not.toMatch(/is not a function/); + }); + + it('rejects a duplicated --side instead of crashing', async () => { + const err = await cmds.order([], null, {}, { ...baseOrder, side: ['buy', 'sell'] }).catch(e => e); + expect(err.message).toMatch(/--side was provided more than once/); + expect(err.message).not.toMatch(/is not a function/); + }); + + it('rejects a duplicated --size instead of silently using the first value', async () => { + const err = await cmds.order([], null, {}, { ...baseOrder, size: ['0.1', '0.2'] }).catch(e => e); + expect(err.message).toMatch(/--size was provided more than once/); + }); + + it('rejects a duplicated --oid', async () => { + const err = await cmds.cancel([], null, {}, { coin: 'ETH', oid: ['111', '222'], wallet: 'x' }).catch(e => e); + expect(err.message).toMatch(/--oid was provided more than once/); + }); +}); + +describe('perp coded errors (6826 N2)', () => { + it('throws a coded INVALID_INPUT error (not a bare Error) so agents can branch', async () => { + const err = await cmds.order([], null, {}, { ...baseOrder, side: 'xyz' }).catch(e => e); + expect(err.name).toBe('CommandError'); + expect(err.code).toBe('INVALID_INPUT'); + }); +}); + +describe('perp --symbol alias (6827)', () => { + it('accepts --symbol as an alias for --coin (no usage banner)', async () => { + const { coin, ...noCoin } = baseOrder; + void coin; + // passes coin resolution; fails later (no real wallet) — assert it is NOT + // the usage banner that a missing --coin would produce. + await expect( + cmds.order([], null, {}, { ...noCoin, symbol: 'ETH' }), + ).rejects.not.toThrow(/Usage: nansen perp order/); + }); +}); + +describe('perp password (6826 N4)', () => { + beforeEach(() => { + showWallet.mockReturnValue({ name: 'x', evm: '0x' + '1'.repeat(40), provider: 'local' }); + getWalletConfig.mockReturnValue({ passwordHash: 'hash', defaultWallet: 'x' }); + }); + afterEach(() => { + getWalletConfig.mockReturnValue({}); + }); + + it('reports PASSWORD_REQUIRED (not "Incorrect password") when none is configured', async () => { + const err = await cmds.order([], null, {}, { ...baseOrder, wallet: 'x' }).catch(e => e); + expect(err.code).toBe('PASSWORD_REQUIRED'); + expect(err.message).not.toMatch(/Incorrect password/); + expect(err.data?.resolution?.length).toBeGreaterThan(0); + }); +}); + +describe('perp account PnL (6828)', () => { + beforeEach(() => { + showWallet.mockReturnValue({ name: 'x', evm: '0x' + '1'.repeat(40), provider: 'local' }); + }); + + it('shows real unrealized PnL summed from positions, not the account value', async () => { + const logs = []; + const accountCmds = buildPerpCommands({ log: (m) => logs.push(m) }); + const api = { + request: vi.fn(async () => ({ + marginSummary: { accountValue: '14.98945', totalRawUsd: '14.98945', totalMarginUsed: '0.0' }, + withdrawable: '14.98945', + assetPositions: [{ position: { coin: 'ETH', unrealizedPnl: '-0.01' } }], + })), + }; + await accountCmds.account([], api, {}, { wallet: 'x' }); + const out = logs.join('\n'); + expect(out).toContain('Unrealized PnL: $-0.01'); + expect(out).not.toContain('Total PnL'); + }); + + it('surfaces the Spot USDC balance (API-14)', async () => { + const logs = []; + const accountCmds = buildPerpCommands({ log: (m) => logs.push(m) }); + const api = { + request: vi.fn(async () => ({ + marginSummary: { accountValue: '10', totalMarginUsed: '0' }, + withdrawable: '10', + assetPositions: [], + spotUsdc: '15.0', + })), + }; + await accountCmds.account([], api, {}, { wallet: 'x' }); + expect(logs.join('\n')).toContain('Spot USDC: $15.0'); + }); +}); + +describe('perp transfer (API-14)', () => { + const base = { direction: 'spot-to-perp', amount: '25', wallet: 'x' }; + + it('rejects an invalid --direction with a coded error', async () => { + const err = await cmds.transfer([], null, {}, { ...base, direction: 'sideways' }).catch(e => e); + expect(err.code).toBe('INVALID_INPUT'); + expect(err.message).toMatch(/Invalid --direction "sideways"/); + }); + + it('rejects a non-numeric --amount', async () => { + await expect( + cmds.transfer([], null, {}, { ...base, amount: '25abc' }), + ).rejects.toThrow(/Invalid --amount "25abc"/); + }); + + it('shows usage when --amount is missing', async () => { + const { amount, ...noAmount } = base; + void amount; + await expect( + cmds.transfer([], null, {}, noAmount), + ).rejects.toThrow(/Usage: nansen perp transfer/); + }); + + it('rejects a duplicated --direction instead of crashing', async () => { + const err = await cmds.transfer([], null, {}, { ...base, direction: ['spot-to-perp', 'perp-to-spot'] }).catch(e => e); + expect(err.message).toMatch(/--direction was provided more than once/); + }); + + it('accepts a valid direction + amount (passes validation, fails later without a wallet)', async () => { + await expect( + cmds.transfer([], null, {}, { ...base }), + ).rejects.not.toThrow(/Invalid --|Usage:/); + }); +}); + +describe('perp wallet resolution (M5)', () => { + beforeEach(() => { + showWallet.mockReset(); + }); + + it('rejects a wallet with no EVM address instead of querying for "undefined"', async () => { + showWallet.mockReturnValue({ name: 'sol-only', solana: 'So111...', provider: 'local' }); + await expect( + cmds.positions([], null, {}, { wallet: 'sol-only' }), + ).rejects.toThrow(/no valid EVM address/); + }); + + it('rejects a malformed EVM address', async () => { + showWallet.mockReturnValue({ name: 'bad', evm: '0xnothex', provider: 'local' }); + await expect( + cmds.positions([], null, {}, { wallet: 'bad' }), + ).rejects.toThrow(/no valid EVM address/); + }); +}); + +// ── Chunk 3/4/5: direct-to-HL build + screen + submit ──────────────── +describe('perp direct-to-HL flow (Chunk 3/4/5)', () => { + const WALLET = '0x' + '1'.repeat(40); + const BUILDER = '0x' + 'Ab'.repeat(20); // mixed case → asserts lowercasing + const KEY = '11'.repeat(32); // valid secp256k1 scalar; address is irrelevant here + const ETH = { name: 'ETH', asset_id: 1, sz_decimals: 4, max_leverage: 50 }; + const baseOrder = { coin: 'ETH', side: 'buy', size: '0.01', price: '2000', type: 'market', wallet: 'x' }; + + // Dispatch the proxy/screen calls by endpoint. `screen` is a function so a + // test can make it throw (unavailable) or flag an address (sanctioned). + function mockApi({ assets = [ETH], builder, screen }) { + const builderStatus = builder ?? { + approved: true, + max_fee_rate: 80, + required_fee: 80, + builder_address: BUILDER, + }; + return { + request: vi.fn(async (endpoint, body) => { + if (endpoint.startsWith('/api/v1/perp/meta')) return { assets }; + if (endpoint.startsWith('/api/v1/perp/builder-fee')) return builderStatus; + if (endpoint.startsWith('/api/v1/perp/positions')) return { positions: [] }; + if (endpoint.startsWith('/api/v1/sanctions/screen')) return screen(body); + throw new Error(`unexpected endpoint ${endpoint}`); + }), + }; + } + + const clean = () => ({ results: [{ address: WALLET, sanctioned: false }] }); + + beforeEach(() => { + submitExchange.mockClear(); + showWallet.mockReturnValue({ name: 'x', evm: WALLET, provider: 'local' }); + getWalletConfig.mockReturnValue({}); + exportWallet.mockReturnValue({ evm: { privateKey: KEY } }); + }); + + it('aborts a sanctioned wallet before signing/submitting', async () => { + const api = mockApi({ screen: () => ({ results: [{ address: WALLET, sanctioned: true }] }) }); + const err = await cmds.order([], api, {}, baseOrder).catch(e => e); + expect(err.code).toBe('SANCTIONED'); + expect(submitExchange).not.toHaveBeenCalled(); + }); + + it('fails closed when screening is unavailable', async () => { + const api = mockApi({ screen: () => { throw new Error('503 SDN snapshot unavailable'); } }); + const err = await cmds.order([], api, {}, baseOrder).catch(e => e); + expect(err.code).toBe('SCREENING_UNAVAILABLE'); + expect(submitExchange).not.toHaveBeenCalled(); + }); + + it('fails closed when a requested address is absent from the screening result', async () => { + const api = mockApi({ screen: () => ({ results: [] }) }); + const err = await cmds.order([], api, {}, baseOrder).catch(e => e); + expect(err.code).toBe('SCREENING_UNAVAILABLE'); + expect(submitExchange).not.toHaveBeenCalled(); + }); + + it('aborts with META_UNAVAILABLE when the asset is not listed', async () => { + const api = mockApi({ assets: [], screen: clean }); + const err = await cmds.order([], api, {}, baseOrder).catch(e => e); + expect(err.code).toBe('META_UNAVAILABLE'); + expect(submitExchange).not.toHaveBeenCalled(); + }); + + it('submits an order carrying the lowercased builder code {b,f}', async () => { + const api = mockApi({ screen: clean }); + await cmds.order([], api, {}, baseOrder); + expect(submitExchange).toHaveBeenCalledTimes(1); + const submitted = submitExchange.mock.calls[0][0]; + expect(submitted.action.type).toBe('order'); + expect(submitted.action.builder).toEqual({ b: BUILDER.toLowerCase(), f: 80 }); + expect(typeof submitted.nonce).toBe('number'); + expect(submitted.signature).toHaveProperty('r'); + }); + + it('auto-fires the one-time builder-fee approval before the first order', async () => { + const api = mockApi({ + builder: { approved: false, max_fee_rate: 0, required_fee: 80, builder_address: BUILDER }, + screen: clean, + }); + await cmds.order([], api, {}, baseOrder); + expect(submitExchange).toHaveBeenCalledTimes(2); + expect(submitExchange.mock.calls[0][0].action.type).toBe('approveBuilderFee'); + expect(submitExchange.mock.calls[0][0].action.maxFeeRate).toBe('0.08%'); + expect(submitExchange.mock.calls[1][0].action.type).toBe('order'); + }); + + it('screens and submits a transfer (user-signed usdClassTransfer)', async () => { + const api = mockApi({ screen: clean }); + await cmds.transfer([], api, {}, { direction: 'spot-to-perp', amount: '25', wallet: 'x' }); + expect(submitExchange).toHaveBeenCalledTimes(1); + expect(submitExchange.mock.calls[0][0].action.type).toBe('usdClassTransfer'); + }); +}); diff --git a/src/__tests__/schema-bridge-perp.test.js b/src/__tests__/schema-bridge-perp.test.js new file mode 100644 index 00000000..ba1a72a5 --- /dev/null +++ b/src/__tests__/schema-bridge-perp.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const schema = JSON.parse( + fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'schema.json'), + 'utf8', + ), +); + +// `bridge` is a top-level command group, so leaving it out of the schema made it +// undiscoverable to anything driving the CLI off `nansen schema`. +describe('bridge is described in the schema', () => { + it('exists with all three subcommands', () => { + expect(Object.keys(schema.commands)).toContain('bridge'); + expect(Object.keys(schema.commands.bridge.subcommands).sort()).toEqual([ + 'execute', + 'quote', + 'status', + ]); + }); + + it('documents the same routes the code enforces', () => { + // Kept in step with BRIDGE_ROUTES in bridge.js by hand; this pins the pair + // list so the two cannot drift silently. + expect(schema.commands.bridge.routes).toEqual([ + { origin: 'base', destination: 'hyperliquid', direction: 'deposit' }, + { origin: 'hyperliquid', destination: 'base', direction: 'withdrawal' }, + { origin: 'hyperliquid', destination: 'ethereum', direction: 'withdrawal' }, + { origin: 'hyperliquid', destination: 'arbitrum', direction: 'withdrawal' }, + ]); + }); + + it('constrains the chain enums to the supported routes', () => { + const { quote } = schema.commands.bridge.subcommands; + expect(quote.options['from-chain'].enum).toEqual(['base', 'hyperliquid']); + expect(quote.options['to-chain'].enum).toEqual([ + 'hyperliquid', 'base', 'ethereum', 'arbitrum', + ]); + }); + + it('points each subcommand at the route it actually calls', () => { + const subs = schema.commands.bridge.subcommands; + expect(subs.quote.endpoint).toBe('/api/v1/perp/bridge/quote'); + expect(subs.execute.endpoint).toBe('/api/v1/perp/bridge/execute'); + expect(subs.status.endpoint).toBe('/api/v1/perp/bridge/status'); + }); +}); + +// After the direct-submit refactor these commands sign locally and post to +// Hyperliquid. `endpoint` is also what the help renderer resolves a credit cost +// against, so naming a route the command never calls is wrong twice over. +describe('perp mutating subcommands describe direct Hyperliquid submission', () => { + const MUTATING = ['order', 'cancel', 'close', 'leverage', 'transfer', 'approve-builder-fee']; + + for (const name of MUTATING) { + it(`${name} declares submitsTo and no stale endpoint`, () => { + const sub = schema.commands.perp.subcommands[name]; + expect(sub.submitsTo).toBe('https://api.hyperliquid.xyz/exchange'); + expect(sub.endpoint).toBeUndefined(); + // Screening is fail-closed on every mutating command, so it is always + // among the API routes these commands consume. + expect(sub.apiEndpoints).toContain('/api/v1/sanctions/screen'); + }); + } + + it('leaves the read-only subcommands pointing at the API', () => { + for (const name of ['positions', 'orders', 'account', 'meta']) { + const sub = schema.commands.perp.subcommands[name]; + expect(sub.endpoint).toBe(`/api/v1/perp/${name}`); + expect(sub.submitsTo).toBeUndefined(); + } + }); + + it('never claims a mutating perp action posts to a Nansen route', () => { + for (const name of MUTATING) { + const sub = schema.commands.perp.subcommands[name]; + // The write path is Hyperliquid's; anything under apiEndpoints must be a + // read the command performs, not the submission target. + expect(sub.apiEndpoints ?? []).not.toContain(`/api/v1/perp/${name}`); + } + }); +}); diff --git a/src/__tests__/telemetry-tracking.test.js b/src/__tests__/telemetry-tracking.test.js index 2261ca96..a4e0fd6b 100644 --- a/src/__tests__/telemetry-tracking.test.js +++ b/src/__tests__/telemetry-tracking.test.js @@ -66,7 +66,6 @@ describe('telemetry tracking for all first-level commands', () => { { category: 'profiler', sub: 'labels', extraOpts: ['--address', '0x1234'] }, { category: 'token', sub: 'screener' }, { category: 'search', sub: 'search', extraOpts: ['--query', 'bitcoin'] }, - { category: 'perp', sub: 'screener' }, { category: 'portfolio', sub: 'current', extraOpts: ['--address', '0x1234'] }, { category: 'points', sub: 'leaderboard' }, { category: 'prediction-market', sub: 'market-screener' }, @@ -165,6 +164,20 @@ describe('telemetry tracking for all first-level commands', () => { expect(trackSucceeded.mock.calls[0][0].command).toBe('wallet'); }); + it('bridge (help subcommand)', async () => { + await runCLI(['bridge'], baseDeps()); + expect(wasTracked()).toBe(1); + expect(trackSucceeded).toHaveBeenCalledOnce(); + expect(trackSucceeded.mock.calls[0][0].command).toBe('bridge'); + }); + + it('perp (help subcommand)', async () => { + await runCLI(['perp'], baseDeps()); + expect(wasTracked()).toBe(1); + expect(trackSucceeded).toHaveBeenCalledOnce(); + expect(trackSucceeded.mock.calls[0][0].command).toBe('perp'); + }); + it('trade quote (missing args shows usage)', async () => { await runCLI(['trade', 'quote'], baseDeps()); expect(wasTracked()).toBe(1); @@ -233,8 +246,8 @@ describe('telemetry tracking for all first-level commands', () => { // operational 'account', 'login', 'logout', 'schema', 'cache', 'changelog', 'web', - // wallet & trading - 'wallet', 'trade', 'quote', 'execute', 'bridge-status', + // wallet, trading, bridge & perp + 'wallet', 'trade', 'quote', 'execute', 'bridge-status', 'bridge', 'perp', // help is a meta command, intentionally not tracked 'help', ]); diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index a8655254..85642290 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -250,6 +250,33 @@ describe('quote storage', () => { expect(loaded.chain).toBe('base'); expect(loaded.toChain).toBeUndefined(); }); + + it('tags saved quotes as swap and loads them', () => { + const quoteId = saveQuote(solanaQuoteResponse, 'solana'); + expect(loadQuote(quoteId).type).toBe('swap'); + }); + + it('rejects a bridge quote loaded through the swap path', () => { + const quotesDir = path.join(tempDir, '.nansen', 'quotes'); + fs.mkdirSync(quotesDir, { recursive: true }); + const quoteId = 'bridge-123-abc'; + fs.writeFileSync( + path.join(quotesDir, `${quoteId}.json`), + JSON.stringify({ quoteId, type: 'bridge', timestamp: Date.now(), response: {} }), + ); + expect(() => loadQuote(quoteId)).toThrow(/is a bridge quote/); + }); + + it('tolerates a legacy untyped swap quote', () => { + const quotesDir = path.join(tempDir, '.nansen', 'quotes'); + fs.mkdirSync(quotesDir, { recursive: true }); + const quoteId = 'legacy-123-abc'; + fs.writeFileSync( + path.join(quotesDir, `${quoteId}.json`), + JSON.stringify({ quoteId, chain: 'base', timestamp: Date.now(), response: {} }), + ); + expect(() => loadQuote(quoteId)).not.toThrow(); + }); }); // ============= Compact-u16 (Solana wire format) ============= @@ -3221,6 +3248,24 @@ describe('Relay aggregator: --aggregator filter on trade quote', () => { delete process.env.NANSEN_WALLET_PASSWORD; }); + + it('rejects out-of-range --slippage on quote (M7)', async () => { + createWallet('default', 'testpass'); + process.env.NANSEN_WALLET_PASSWORD = 'testpass'; + + const cmds = buildTradingCommands({ log: () => {}, exit: () => {} }); + // "3" is almost certainly meant as 3% but reads as 300% — reject it. + await expect(cmds.quote([], null, {}, { + chain: 'base', + 'to-chain': 'solana', + from: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + to: 'SOL', + amount: '1000', + slippage: '3', + })).rejects.toThrow(/Invalid --slippage/); + + delete process.env.NANSEN_WALLET_PASSWORD; + }); }); describe('Relay aggregator: bridge-status 502 handling', () => { diff --git a/src/__tests__/usage-banner-rendering.test.js b/src/__tests__/usage-banner-rendering.test.js new file mode 100644 index 00000000..c9c29e26 --- /dev/null +++ b/src/__tests__/usage-banner-rendering.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { runCLI, isUsageError } from '../cli.js'; + +// Usage banners are multi-line text meant to be read. Routing them through the +// error envelope serialises the newlines to literal \n, which turned every +// "you forgot an argument" response into an unreadable one-line JSON blob. +// Interactive terminals now get the banner as written; pipes and explicit +// output formats keep the envelope so agents still get one shape to parse. + +let outputs; +let exitCode; + +function deps(overrides = {}) { + return { + output: (msg) => outputs.push(msg), + errorOutput: () => {}, + exit: (code) => { exitCode = code; }, + ...overrides, + }; +} + +beforeEach(() => { + outputs = []; + exitCode = undefined; +}); + +describe('isUsageError', () => { + const usage = { code: 'MISSING_PARAM' }; + + it('is true for usage codes on a bare TTY', () => { + expect(isUsageError(usage, { isTTY: true })).toBe(true); + expect(isUsageError({ code: 'MISSING_ARGS' }, { isTTY: true })).toBe(true); + }); + + it('is false when not a TTY, so piped output stays machine-readable', () => { + expect(isUsageError(usage, { isTTY: false })).toBe(false); + expect(isUsageError(usage, {})).toBe(false); + }); + + // An explicitly requested format is a request for machine-readable output, + // even interactively. + for (const flag of ['pretty', 'table', 'csv', 'stream']) { + it(`is false when --${flag} was requested`, () => { + expect(isUsageError(usage, { isTTY: true, [flag]: true })).toBe(false); + }); + } + + it('is false for non-usage errors, which keep the envelope', () => { + expect(isUsageError({ code: 'UNKNOWN' }, { isTTY: true })).toBe(false); + expect(isUsageError({ code: 'SANCTIONED' }, { isTTY: true })).toBe(false); + // PASSWORD_REQUIRED carries structured resolution steps under details that + // agents branch on, so it must not degrade to plain text. + expect(isUsageError({ code: 'PASSWORD_REQUIRED' }, { isTTY: true })).toBe(false); + }); +}); + +describe('usage banner rendering through runCLI', () => { + it('prints the bridge quote banner as readable text on a TTY', async () => { + await runCLI(['bridge', 'quote'], deps({ isTTY: true })); + const text = outputs.join('\n'); + expect(text).not.toMatch(/^\{/); + expect(text).not.toContain('\\n'); + // Real newlines, so the sections actually render. + expect(text).toContain('\nSUPPORTED ROUTES:\n'); + expect(text).toContain('base -> hyperliquid'); + expect(text).toContain('Usage: nansen bridge quote'); + expect(exitCode).toBe(1); + }); + + it('keeps the JSON envelope for the same command when piped', async () => { + await runCLI(['bridge', 'quote'], deps({ isTTY: false })); + const parsed = JSON.parse(outputs.join('')); + expect(parsed.success).toBe(false); + expect(parsed.code).toBe('MISSING_PARAM'); + expect(parsed.error).toContain('Usage: nansen bridge quote'); + }); + + it('keeps the JSON envelope on a TTY when --pretty is requested', async () => { + await runCLI(['bridge', 'quote', '--pretty'], deps({ isTTY: true })); + const parsed = JSON.parse(outputs.join('')); + expect(parsed.code).toBe('MISSING_PARAM'); + }); + + // The regression reached well beyond the new commands — this is the case from + // the review, on a command that predates them. + it('prints the trade quote banner as readable text on a TTY', async () => { + await runCLI(['trade', 'quote'], deps({ isTTY: true })); + const text = outputs.join('\n'); + expect(text).not.toMatch(/^\{/); + expect(text).not.toContain('\\n'); + expect(text).toContain('Usage: nansen trade quote'); + }); + + it('leaves non-usage failures as an envelope on a TTY', async () => { + // An unsupported route is a validation failure, not a usage banner. + await runCLI( + ['bridge', 'quote', '--from-chain', 'polygon', '--to-chain', 'hyperliquid', + '--from-token', 'USDC', '--amount', '5'], + deps({ isTTY: true }), + ); + const parsed = JSON.parse(outputs.join('')); + expect(parsed.success).toBe(false); + expect(parsed.error).toContain('Unsupported bridge route'); + }); +}); diff --git a/src/__tests__/wallet-signing.test.js b/src/__tests__/wallet-signing.test.js new file mode 100644 index 00000000..bfe534ef --- /dev/null +++ b/src/__tests__/wallet-signing.test.js @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../wallet.js', () => ({ + showWallet: vi.fn(), + getWalletConfig: vi.fn(() => ({})), + exportWallet: vi.fn(), +})); + +vi.mock('../keychain.js', () => ({ + retrievePassword: vi.fn(() => ({ password: null, source: null })), +})); + +import { exportWallet, getWalletConfig, showWallet } from '../wallet.js'; +import { retrievePassword } from '../keychain.js'; +import { + resolveEvmWallet, + resolvePrivateKey, + resolveSigningCredentials, +} from '../wallet-signing.js'; + +const ADDR = '0x' + 'ab'.repeat(20); + +beforeEach(() => { + vi.clearAllMocks(); + getWalletConfig.mockReturnValue({}); + retrievePassword.mockReturnValue({ password: null, source: null }); +}); + +describe('resolveEvmWallet', () => { + it('resolves the named wallet', () => { + showWallet.mockReturnValue({ name: 'w', evm: ADDR, provider: 'local' }); + expect(resolveEvmWallet('w')).toEqual({ + name: 'w', + address: ADDR, + provider: 'local', + privyWalletIds: null, + }); + }); + + it('falls back to the configured default wallet', () => { + getWalletConfig.mockReturnValue({ defaultWallet: 'main' }); + showWallet.mockReturnValue({ name: 'main', evm: ADDR, provider: 'local' }); + expect(resolveEvmWallet(undefined).name).toBe('main'); + expect(showWallet).toHaveBeenCalledWith('main'); + }); + + it('reports NO_WALLET when there is nothing to resolve', () => { + getWalletConfig.mockReturnValue({}); + expect(() => resolveEvmWallet(undefined)).toThrow(/No wallet found/); + expect(showWallet).not.toHaveBeenCalled(); + }); + + it('rejects a wallet with no EVM address, naming the feature', () => { + // A Solana-only wallet used to reach the bridge API as wallet_address: null + // and come back a 422. + showWallet.mockReturnValue({ name: 'sol-only', evm: null, provider: 'local' }); + expect(() => resolveEvmWallet('sol-only', 'Bridging')).toThrow( + /Wallet "sol-only" has no valid EVM address\. Bridging requires an EVM wallet\./, + ); + }); + + it('rejects a malformed EVM address', () => { + showWallet.mockReturnValue({ name: 'bad', evm: '0xnothex', provider: 'local' }); + expect(() => resolveEvmWallet('bad')).toThrow(/no valid EVM address/); + }); +}); + +describe('resolvePrivateKey', () => { + const wallet = { name: 'w', address: ADDR, provider: 'local' }; + + it('exports with no password when the wallet is unencrypted', () => { + getWalletConfig.mockReturnValue({}); + exportWallet.mockReturnValue({ evm: { privateKey: 'aa'.repeat(32) } }); + expect(resolvePrivateKey(wallet)).toBe('aa'.repeat(32)); + expect(exportWallet).toHaveBeenCalledWith('w', null); + }); + + it('reports PASSWORD_REQUIRED rather than "Incorrect password" when none was entered', () => { + // The M6.1 regression: bridge.js called exportWallet(name, null), whose + // password check reports "Incorrect password" for a password that never + // existed — support noise pointing users at the wrong problem. + getWalletConfig.mockReturnValue({ passwordHash: 'h' }); + retrievePassword.mockReturnValue({ password: null, source: null }); + let err; + try { + resolvePrivateKey(wallet); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.code).toBe('PASSWORD_REQUIRED'); + expect(err.message).not.toMatch(/Incorrect password/); + expect(exportWallet).not.toHaveBeenCalled(); + }); + + it('passes a retrieved password through to exportWallet', () => { + getWalletConfig.mockReturnValue({ passwordHash: 'h' }); + retrievePassword.mockReturnValue({ password: 'pw', source: 'keychain' }); + exportWallet.mockReturnValue({ evm: { privateKey: 'bb'.repeat(32) } }); + expect(resolvePrivateKey(wallet)).toBe('bb'.repeat(32)); + expect(exportWallet).toHaveBeenCalledWith('w', 'pw'); + }); +}); + +describe('resolveSigningCredentials', () => { + it('returns a local key for a local wallet', () => { + exportWallet.mockReturnValue({ evm: { privateKey: 'cc'.repeat(32) } }); + expect(resolveSigningCredentials({ name: 'w', provider: 'local' })).toEqual({ + provider: 'local', + privateKey: 'cc'.repeat(32), + }); + }); + + it('does not try to export a Privy wallet', () => { + expect(resolveSigningCredentials({ name: 'p', provider: 'privy' })).toEqual({ + provider: 'privy', + privateKey: null, + }); + expect(exportWallet).not.toHaveBeenCalled(); + }); + + it('takes the resolved wallet, so it never re-reads the wallet file', () => { + exportWallet.mockReturnValue({ evm: { privateKey: 'dd'.repeat(32) } }); + resolveSigningCredentials({ name: 'w', provider: 'local' }); + // M6.3: bridge execute resolved the wallet twice, which could pick a + // different wallet than the one it had just screened. + expect(showWallet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api.js b/src/api.js index 78cc9013..7384bb0f 100644 --- a/src/api.js +++ b/src/api.js @@ -603,7 +603,9 @@ export class NansenAPI { || `API error: ${response.status}`; // nansen-api proxy stringifies nested error dicts via Python str(), producing // "{'message': 'actual error', ...}". Extract the inner message if present. - const nestedMatch = typeof message === 'string' && message.match(/['"]message['"]\s*:\s*['"](.*?)['"]/); + // Require the closing quote to be followed by a comma or closing brace so + // an apostrophe inside the message (e.g. "can't") doesn't truncate it. + const nestedMatch = typeof message === 'string' && message.match(/['"]message['"]\s*:\s*['"](.*?)['"]\s*[,}]/s); if (nestedMatch) message = nestedMatch[1]; const code = statusToErrorCode(response.status, data); const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')); diff --git a/src/bridge.js b/src/bridge.js new file mode 100644 index 00000000..666a6456 --- /dev/null +++ b/src/bridge.js @@ -0,0 +1,969 @@ +/** + * nansen bridge — Hyperliquid bridge commands (EVM <-> Hyperliquid via Relay). + * + * Calls nansen-api /api/v1/perp/bridge/* endpoints. Transaction signing and + * EVM broadcasting happen client-side; HL withdrawal signatures are + * proxied through the API's /perp/bridge/execute endpoint. + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { CommandError, validateAddress } from './api.js'; +import { signSecp256k1 } from './crypto.js'; +import { + convertToBaseUnits, + evmRpcCall, + getEvmNonce, + getQuotesDir, + resolveUsdPrice, + safeQuotesPath, + signEvmTransaction, + waitForReceipt, +} from './trading.js'; +import { screenOrThrow } from './perp.js'; +import { resolveEvmWallet, resolveSigningCredentials } from './wallet-signing.js'; +import { hashTypedData } from './x402-evm.js'; + +const QUOTE_TTL_MS = 3600000; // 1 hour + +const BRIDGE_TOKENS = { + ethereum: { USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }, + base: { USDC: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' }, + arbitrum: { USDC: '0xaf88d065e77c8cc2239327c5edb3a432268e5831' }, + hyperliquid: { USDC: '0x00000000000000000000000000000000' }, +}; + +// Routes this CLI can actually complete, as ordered [origin, destination] pairs. +// Deliberately narrower than the API accepts, and asymmetric, because the two +// directions need different things from us: +// +// Deposits (EVM → HL) broadcast an EVM transaction locally, so the origin +// chain must be signable by signEvmTransaction — which only knows Base. The +// API accepts ethereum/arbitrum/polygon/bnb origins as well, but nothing here +// can sign them, so offering them would just fail at execute time. Base → HL +// is also the only deposit route with a real-money round-trip behind it. +// +// Withdrawals (HL → EVM) sign a Hyperliquid EIP-712 action instead and never +// touch the destination chain, so no local per-chain support is needed; these +// mirror the API's supported pairs. +// +// Widening the deposit side means teaching signEvmTransaction the chain AND +// putting real funds through it first. +const BRIDGE_ROUTES = [ + ['base', 'hyperliquid'], + ['hyperliquid', 'base'], + ['hyperliquid', 'ethereum'], + ['hyperliquid', 'arbitrum'], +]; + +function isSupportedBridgeRoute(originChain, destinationChain) { + return BRIDGE_ROUTES.some(([o, d]) => o === originChain && d === destinationChain); +} + +function formatBridgeRoutes() { + return BRIDGE_ROUTES.map(([o, d]) => `${o} -> ${d}`).join(', '); +} + +function resolveBridgeToken(symbolOrAddress, chain) { + if (!symbolOrAddress || !chain) return symbolOrAddress; + const tokens = BRIDGE_TOKENS[chain.toLowerCase()]; + if (!tokens) return symbolOrAddress; + return tokens[symbolOrAddress.toUpperCase()] || symbolOrAddress; +} + +function isBridgeUsdc(tokenAddress, chain) { + const usdc = BRIDGE_TOKENS[chain.toLowerCase()]?.USDC; + return !!usdc && tokenAddress.toLowerCase() === usdc.toLowerCase(); +} + +// Resolve a token's on-chain decimals so a human --amount can be converted to +// base units. USDC is 6 on every supported EVM chain but 8 on Hyperliquid (the +// crux of the per-chain decimals trap); other EVM tokens fall back to decimals(). +export async function resolveBridgeTokenDecimals(tokenAddress, chain) { + const normChain = chain.toLowerCase(); + if (normChain === 'hyperliquid') { + if (isBridgeUsdc(tokenAddress, normChain)) return 8; + throw new Error( + `Cannot resolve decimals for ${tokenAddress} on hyperliquid. Pass --amount in base units (omit --amount-unit).`, + ); + } + if (isBridgeUsdc(tokenAddress, normChain)) return 6; + // EVM fallback: decimals() selector 0x313ce567 + const result = await evmRpcCall(normChain, 'eth_call', [{ to: tokenAddress, data: '0x313ce567' }, 'latest']); + const decimals = parseInt(result, 16); + if (isNaN(decimals) || decimals > 255) { + throw new Error(`Could not resolve decimals for ${tokenAddress} on ${normChain}.`); + } + return decimals; +} + +// USDC's canonical precision the Relay bridge formats Hyperliquid sendAsset to. +const HYPERLIQUID_USDC_BRIDGE_DECIMALS = 6; + +// Hyperliquid spot USDC is 8 decimals, but the Relay bridge rounds the sendAsset +// amount to USDC's 6 decimals (round-half-up). Submitting the full 8-decimal +// amount can round UP past the balance, which Hyperliquid rejects. Flooring to 6 +// keeps the bridge's rounding a no-op. (Mirrors Superapp SUPER-13582.) +export function floorHyperliquidUsdcBridgeAmount(amountBaseUnits, decimals, tokenAddress, chain) { + if (chain.toLowerCase() !== 'hyperliquid' || !isBridgeUsdc(tokenAddress, chain)) { + return amountBaseUnits; + } + const dropped = decimals - HYPERLIQUID_USDC_BRIDGE_DECIMALS; + if (dropped <= 0) return amountBaseUnits; + const factor = 10n ** BigInt(dropped); + return ((BigInt(amountBaseUnits) / factor) * factor).toString(); +} + +// Slippage is whole basis points in [0, 10000] (50 = 0.5%, 10000 = 100%). +// Validate client-side so "abc" or "-1" fail here with a clear message instead +// of an opaque backend 422, matching how `perp` validates --slippage. parseInt +// alone would silently accept "999abc" (-> 999) or "-1", so check the string +// shape before parsing. +export function parseSlippageBps(raw) { + const s = String(raw).trim(); + const bad = `Invalid --slippage "${raw}". Use whole basis points between 0 and 10000 (e.g. 50 = 0.5%).`; + if (!/^\d+$/.test(s)) throw new Error(bad); + const n = parseInt(s, 10); + if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error(bad); + return n; +} + +// ── API helpers ────────────────────────────────────────────────────── + +// cache: false — a quote carries live pricing, fees and per-step transaction +// data, and is cached locally as a quote file anyway. Replaying a stale one +// would mean signing against amounts the route no longer offers. +async function getBridgeQuote(apiInstance, params) { + return apiInstance.request('/api/v1/perp/bridge/quote', params, { cache: false }); +} + +// retry: false because this is not idempotent — it proxies to Relay's +// /authorize and to Hyperliquid's /exchange, so an automatic re-send on a 500 +// or 502 can submit the same signed action twice. hl-client.js's submitExchange +// documents the same reasoning for the direct path; this is the proxied one. +async function postBridgeExecute(apiInstance, targetUrl, body) { + return apiInstance.request( + '/api/v1/perp/bridge/execute', + { target_url: targetUrl, body }, + { cache: false, retry: false }, + ); +} + +// cache: false, and here it is what makes polling work at all. The cache key is +// endpoint + body, so every poll for a given request id is the same key — under +// --cache the loop would re-read one cached verdict for the whole TTL and stay +// blind to a bridge that had already completed or failed. +async function getBridgeStatus(apiInstance, { requestId, txHash }) { + const params = new URLSearchParams(); + if (requestId) params.set('request_id', requestId); + if (txHash) params.set('tx_hash', txHash); + return apiInstance.request(`/api/v1/perp/bridge/status?${params}`, {}, { method: 'GET', cache: false }); +} + +// ── Quote caching ──────────────────────────────────────────────────── + +function saveBridgeQuote(response, originChain, destinationChain, walletProvider, walletAddress) { + const dir = getQuotesDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const hash = crypto.randomBytes(4).toString('hex'); + const quoteId = `bridge-${Date.now()}-${hash}`; + const data = { + quoteId, + type: 'bridge', + originChain, + destinationChain, + walletProvider, + walletAddress, + timestamp: Date.now(), + response, + }; + const filePath = path.join(dir, `${quoteId}.json`); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 }); + return quoteId; +} + +export function loadBridgeQuote(quoteId) { + const filePath = safeQuotesPath(`${quoteId}.json`); + if (!filePath || !fs.existsSync(filePath)) { + throw new Error(`Bridge quote "${quoteId}" not found. Quotes expire after 1 hour.`); + } + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (Date.now() - data.timestamp > QUOTE_TTL_MS) { + fs.unlinkSync(filePath); + throw new Error('Bridge quote has expired. Please request a new quote.'); + } + if (data.type !== 'bridge') { + throw new Error(`Quote "${quoteId}" is not a bridge quote. Use "nansen trade execute" for a swap quote.`); + } + if (data.executedAt) { + // Quotes are single-use: re-signing and re-broadcasting would move funds + // a second time. Refuse a quote that has already been executed. + const when = new Date(data.executedAt).toISOString(); + const fullyDone = data.totalSteps && data.broadcastSteps >= data.totalSteps; + if (!fullyDone && data.broadcasts?.length) { + // Something is in flight but the run didn't finish — e.g. the tx was + // accepted and the receipt wait timed out. Re-running would re-send it, so + // refuse and name the hashes so the operator can check what landed. + const hashes = data.broadcasts.map(b => b.txHash).filter(Boolean); + const detail = hashes.length ? ` (${hashes.join(', ')})` : ''; + throw new Error( + `Bridge quote "${quoteId}" partially executed at ${when}: ${data.broadcasts.length} transaction(s) already broadcast${detail}. Funds may be in flight — check "nansen bridge status" before requesting a new quote.`, + ); + } + if (data.totalSteps && data.broadcastSteps && data.broadcastSteps < data.totalSteps) { + // Partial execution: a later step failed after an earlier one had already + // been broadcast. Re-running from step 0 would re-send what already went + // out, so refuse and say what landed — the funds may be in flight. + throw new Error( + `Bridge quote "${quoteId}" partially executed at ${when}: ${data.broadcastSteps} of ${data.totalSteps} steps were broadcast. Check "nansen bridge status" before requesting a new quote — the earlier step may already have moved funds.`, + ); + } + throw new Error( + `Bridge quote "${quoteId}" was already executed at ${when}. Request a new quote to bridge again.`, + ); + } + return data; +} + +// Records that a broadcast has happened. `executedAt` is set on the first call +// and never moved, so the quote is consumed the moment any step goes out — a +// later step throwing must not leave the quote reusable. The step counters make +// a partial failure legible to the operator (see loadBridgeQuote). +export function markBridgeQuoteExecuted(quoteId, progress = {}) { + const filePath = safeQuotesPath(`${quoteId}.json`); + if (!filePath || !fs.existsSync(filePath)) return; + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + data.executedAt = data.executedAt || Date.now(); + if (progress.broadcast) { + data.broadcasts = [...(data.broadcasts || []), { ...progress.broadcast, at: Date.now() }]; + } + if (progress.broadcastSteps !== undefined) { + data.broadcastSteps = Math.max(data.broadcastSteps || 0, progress.broadcastSteps); + } + if (progress.totalSteps !== undefined) data.totalSteps = progress.totalSteps; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 }); + } catch { + // Best-effort: if the marker can't be written, the next execute attempt + // will still proceed, but that's preferable to crashing after a successful + // broadcast. + } +} + +// ── EIP-712 signing (for HL withdrawals) ───────────────────────────── + +// `types[primaryType] || []` used to swallow a missing type definition: with no +// fields, hashStruct hashes typeHash("PrimaryType()") over none of the message's +// contents, so we would hand back a well-formed signature that commits to +// nothing about the action being authorised. Refuse instead — an omitted or +// misspelled type list is a bug or a tampered response, never something to sign +// through. +function signEip712Local(typedData, privateKeyHex, context = 'EIP-712 payload') { + const { domain, types, primaryType, message } = typedData; + const fields = (types?.[primaryType] || []).map(f => ({ name: f.name, type: f.type })); + if (fields.length === 0) { + throw new Error( + `${context} is missing its EIP-712 type definition for "${primaryType}", so the signature would not cover the action. Refusing to sign.`, + ); + } + const msgHash = hashTypedData(domain, primaryType, fields, message); + const { r, s, v } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex')); + return '0x' + r.toString('hex') + s.toString('hex') + (27 + v).toString(16).padStart(2, '0'); +} + +// ── Step processors ────────────────────────────────────────────────── + +// Headroom multiplier applied to the current base fee when setting maxFeePerGas. +// A type-2 transaction only ever pays baseFee + priority, so a generous cap +// costs nothing extra — it just buys tolerance for the base fee moving between +// signing and inclusion. Base's fee moved ~2x within minutes while this was +// being tested, so the tolerance is not theoretical. +const BASE_FEE_HEADROOM = 3n; + +// Floor for maxPriorityFeePerGas, in wei (0.01 gwei). +// +// The priority fee is what orders a transaction for inclusion, and it is where a +// real deposit got stuck: Relay quotes ~0.0011 gwei, while Base was including at +// ~0.008 gwei and up, so the transaction sat in the mempool and burned the nonce. +// A cap alone does not fix that — the cap is only what you are *willing* to pay. +// At 21k-75k gas this floor is a small fraction of a cent per step. +const MIN_PRIORITY_FEE_WEI = 10000000n; + +// Parse a --priority-fee / --max-fee override. Given in gwei, because that is +// the unit every fee tracker and block explorer quotes; returned in wei. +// +// Converted digit-wise rather than by multiplying a float, so 0.05 gwei is +// exactly 50000000 wei and not whatever the binary representation rounds to. +export function parseGweiToWei(raw, name) { + const s = String(raw).trim(); + if (!/^\d*\.?\d+$/.test(s)) { + throw new CommandError( + `Invalid --${name} "${raw}". Give a fee in gwei (e.g. 0.05).`, + 'INVALID_INPUT', + ); + } + const [int, frac = ''] = s.split('.'); + const wei = BigInt(int || '0') * 1000000000n + BigInt((frac + '000000000').slice(0, 9)); + if (wei <= 0n) { + throw new CommandError(`Invalid --${name} "${raw}". Must be greater than zero.`, 'INVALID_INPUT'); + } + return wei; +} + +// Decide the fee fields for an EVM bridge step. +// +// Relay's quote already carries maxFeePerGas/maxPriorityFeePerGas, which this +// used to discard in favour of a bare eth_gasPrice reading — producing a legacy +// transaction priced at roughly the current base fee with almost no priority +// fee. Keep Relay's intent, but raise the priority fee to something Base will +// actually schedule and lift the cap to cover it plus base-fee movement. +// +// `overrides` are the operator's explicit --priority-fee/--max-fee, in wei. They +// win outright, including over MIN_PRIORITY_FEE_WEI: their whole purpose is +// outbidding a transaction that is already stuck, which the computed values +// cannot do — they reproduce the same numbers that got stuck in the first place, +// and a replacement needs roughly +10% to be accepted at all. +export async function resolveEvmStepFees(chain, txData, overrides = {}) { + const { priorityFeeWei = null, maxFeeWei = null } = overrides; + + if (txData.maxFeePerGas || priorityFeeWei || maxFeeWei) { + let maxPriorityFeePerGas = priorityFeeWei ?? BigInt(txData.maxPriorityFeePerGas ?? 0); + if (!priorityFeeWei && maxPriorityFeePerGas < MIN_PRIORITY_FEE_WEI) { + maxPriorityFeePerGas = MIN_PRIORITY_FEE_WEI; + } + + // The cap must cover the raised priority fee, or the transaction is + // self-contradictory: maxFeePerGas < maxPriorityFeePerGas is rejected + // outright by every node. + if (maxFeeWei) { + if (maxFeeWei < maxPriorityFeePerGas) { + throw new CommandError( + `--max-fee is below --priority-fee (${maxFeeWei} wei < ${maxPriorityFeePerGas} wei); no node accepts that. Raise --max-fee.`, + 'INVALID_INPUT', + ); + } + return { + maxFeePerGas: maxFeeWei.toString(), + maxPriorityFeePerGas: maxPriorityFeePerGas.toString(), + }; + } + + let maxFeePerGas = BigInt(txData.maxFeePerGas ?? 0); + try { + const block = await evmRpcCall(chain, 'eth_getBlockByNumber', ['latest', false]); + const baseFee = BigInt(block?.baseFeePerGas ?? 0); + const floor = baseFee * BASE_FEE_HEADROOM + maxPriorityFeePerGas; + if (floor > maxFeePerGas) maxFeePerGas = floor; + } catch { + // Base-fee lookup is best-effort, but the cap still has to clear the + // priority fee even without it. + if (maxFeePerGas < maxPriorityFeePerGas) maxFeePerGas = maxPriorityFeePerGas; + } + + return { + maxFeePerGas: maxFeePerGas.toString(), + maxPriorityFeePerGas: maxPriorityFeePerGas.toString(), + }; + } + + // Pre-1559 shape (or a quote that only gave a flat price): fall back to the + // node's reading, which is what the legacy signer needs. + return { gasPrice: await evmRpcCall(chain, 'eth_gasPrice') }; +} + +async function processEvmStep(step, { chain, privateKeyHex, log, onBroadcast, feeOverrides, nonceSequence }) { + for (const item of step.items || []) { + if (item.status === 'complete') continue; + const txData = item.data; + + const fees = await resolveEvmStepFees(chain, txData, feeOverrides); + // getEvmNonce returns a decimal number and reconciles pending against the + // mined count. An explicit --nonce skips both: it is how an operator + // deliberately re-signs at the nonce of a stuck transaction to replace it, + // which is exactly the case the reconciliation refuses. + const nonce = nonceSequence + ? nonceSequence.next++ + : await getEvmNonce(chain, txData.from); + if (nonceSequence) log(` Nonce: ${nonce} (from --nonce)`); + + const signedTx = signEvmTransaction( + { ...txData, ...fees }, + privateKeyHex, + chain, + nonce, + ); + + log(` Broadcasting ${step.id} on ${chain}...`); + const txHash = await evmRpcCall(chain, 'eth_sendRawTransaction', [signedTx]); + // In flight now: record it before waiting on the receipt, because a receipt + // timeout must not leave the quote reusable. + onBroadcast?.(step.id, txHash); + log(` Tx: ${txHash}`); + + const receipt = await waitForReceipt(chain, txHash); + const status = parseInt(receipt.status, 16); + if (status !== 1) { + throw new Error(`Transaction reverted: ${txHash}`); + } + log(` Confirmed in block ${parseInt(receipt.blockNumber, 16)}`); + } +} + +async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance, onBroadcast }) { + for (const item of step.items || []) { + if (item.status === 'complete') continue; + const { data: signData } = item; + + if (signData.sign) { + const typedData = { + domain: signData.sign.domain, + types: signData.sign.types, + primaryType: signData.sign.primaryType, + message: signData.sign.value, + }; + const signature = signEip712Local(typedData, privateKeyHex, `Bridge step "${step.id}"`); + + let targetUrl = signData.post.endpoint; + if (!targetUrl.startsWith('http')) { + targetUrl = `https://api.relay.link${targetUrl}`; + } + const postBody = { ...signData.post.body }; + + if (targetUrl.includes('/authorize')) { + const sep = targetUrl.includes('?') ? '&' : '?'; + targetUrl = `${targetUrl}${sep}signature=${signature}`; + } else { + postBody.signature = signature; + } + + log(` Signing ${step.id} (EIP-712)...`); + await postBridgeExecute(apiInstance, targetUrl, postBody); + onBroadcast?.(step.id, null); + log(` Submitted to ${new URL(targetUrl).hostname}`); + } else if (signData.action) { + const domain = { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 1, + verifyingContract: '0x0000000000000000000000000000000000000000', + }; + const types = signData.eip712Types || {}; + const primaryType = signData.eip712PrimaryType || 'HyperliquidTransaction'; + const message = { + ...(signData.action.parameters || signData.action), + type: signData.action.type, + signatureChainId: '0x1', + }; + + const typedData = { domain, types, primaryType, message }; + const signature = signEip712Local(typedData, privateKeyHex, `Bridge step "${step.id}"`); + const [rHex, sHex, vHex] = [signature.slice(2, 66), signature.slice(66, 130), signature.slice(130, 132)]; + + const flatAction = { type: signData.action.type, ...signData.action.parameters, signatureChainId: '0x1' }; + const hlBody = { + action: flatAction, + nonce: signData.nonce, + signature: { r: '0x' + rHex, s: '0x' + sHex, v: parseInt(vHex, 16) }, + vaultAddress: null, + }; + + log(` Signing ${step.id} (Hyperliquid deposit)...`); + await postBridgeExecute(apiInstance, 'https://api.hyperliquid.xyz/exchange', hlBody); + onBroadcast?.(step.id, null); + log(` Submitted to api.hyperliquid.xyz`); + } + } +} + +async function processSignatureStepPrivy(step, { privyClient, walletId, log, apiInstance, onBroadcast }) { + for (const item of step.items || []) { + if (item.status === 'complete') continue; + const { data: signData } = item; + + let typedData; + if (signData.sign) { + typedData = { + domain: signData.sign.domain, + types: signData.sign.types, + primaryType: signData.sign.primaryType, + message: signData.sign.value, + }; + } else if (signData.action) { + typedData = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 1, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: signData.eip712Types || {}, + primaryType: signData.eip712PrimaryType || 'HyperliquidTransaction', + message: { + ...(signData.action.parameters || signData.action), + type: signData.action.type, + signatureChainId: '0x1', + }, + }; + } else { + throw new Error(`Unexpected signature step format for ${step.id}`); + } + + log(` Signing ${step.id} via Privy...`); + const result = await privyClient.ethSignTypedDataV4(walletId, typedData); + const signature = result.data?.signature || result.signature || result; + + if (signData.sign) { + let targetUrl = signData.post.endpoint; + if (!targetUrl.startsWith('http')) targetUrl = `https://api.relay.link${targetUrl}`; + const postBody = { ...signData.post.body }; + if (targetUrl.includes('/authorize')) { + const sep = targetUrl.includes('?') ? '&' : '?'; + targetUrl = `${targetUrl}${sep}signature=${signature}`; + } else { + postBody.signature = signature; + } + await postBridgeExecute(apiInstance, targetUrl, postBody); + onBroadcast?.(step.id, null); + } else { + const [rHex, sHex, vHex] = [signature.slice(2, 66), signature.slice(66, 130), signature.slice(130, 132)]; + const flatAction = { type: signData.action.type, ...signData.action.parameters, signatureChainId: '0x1' }; + const hlBody = { + action: flatAction, + nonce: signData.nonce, + signature: { r: '0x' + rHex, s: '0x' + sHex, v: parseInt(vHex, 16) }, + vaultAddress: null, + }; + await postBridgeExecute(apiInstance, 'https://api.hyperliquid.xyz/exchange', hlBody); + onBroadcast?.(step.id, null); + } + log(` Submitted`); + } +} + +// ── Status polling ─────────────────────────────────────────────────── + +async function pollBridgeCompletion(apiInstance, { requestId, txHash, timeoutMs = 600000, pollMs = 10000, log = console.log }) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const status = await getBridgeStatus(apiInstance, { requestId, txHash }); + log(` Bridge: ${status.status} (${status.raw_status || ''})`); + if (status.status === 'success') return status; + if (status.status === 'failure') { + throw Object.assign(new Error('Bridge failed'), { code: 'BRIDGE_FAILED', details: status }); + } + if (status.status === 'refund') { + log(' Bridge: REFUNDED — funds returned on source chain'); + return status; + } + } catch (err) { + if (err.code === 'BRIDGE_FAILED') throw err; + // Say what went wrong. A silent "poll error" hides the difference between + // a transient 502 (worth waiting out) and a 401 or a bad request id, which + // will still be failing when the timeout arrives ten minutes later. + log(` Bridge: poll error — ${err.message} (retrying...)`); + } + await new Promise(r => setTimeout(r, pollMs)); + } + // Name whichever handle the caller actually gave us. Interpolating a missing + // request_id produced "--request-id undefined", a command that cannot work. + const followUp = requestId + ? `nansen bridge status --request-id ${requestId}` + : txHash + ? `nansen bridge status --tx-hash ${txHash}` + : 'nansen bridge status --tx-hash '; + throw Object.assign( + new Error(`Bridge polling timed out after ${timeoutMs / 1000}s. Check manually: ${followUp}`), + { code: 'BRIDGE_TIMEOUT' }, + ); +} + +// ── Wallet helpers ─────────────────────────────────────────────────── + +// Every route here has an EVM address on at least one side (Hyperliquid uses EVM +// addresses too), and both legs sign with the EVM key — so a wallet without a +// valid EVM address can't bridge at all. This used to pass `wallet.evm` +// through unchecked, so a Solana-only wallet reached the API as +// `wallet_address: null` and came back a 422. +function resolveWalletAddress(walletName) { + return resolveEvmWallet(walletName, 'Bridging'); +} + +// Destination address for --recipient. Validated against the EVM pattern rather +// than the destination chain's own rules: every supported destination +// (base/ethereum/arbitrum/hyperliquid) takes an EVM address, and passing +// 'hyperliquid' to validateAddress would fall through its unknown-chain branch +// and accept anything. +function assertRecipient(recipient) { + const { valid, error } = validateAddress(recipient, 'ethereum'); + if (!valid) { + throw new CommandError(`Invalid --recipient "${recipient}". ${error}`, 'INVALID_ADDRESS'); + } +} + +// --amount is a base-unit integer by default, or a positive decimal with +// --amount-unit. Checked client-side because the two failure modes are both +// quiet: a decimal in base units is a units mix-up (5.5 meaning 5.5 USDC would +// bridge 5 base units, i.e. 0.000005 USDC), and trailing garbage would reach +// convertToBaseUnits rather than being rejected. +function parseBridgeAmount(raw, amountUnit) { + const s = String(raw).trim(); + if (amountUnit === undefined) { + if (!/^\d+$/.test(s) || BigInt(s) <= 0n) { + throw new CommandError( + `Invalid --amount "${raw}". Base units must be a positive whole number (USDC is 6 decimals on EVM chains, 8 on Hyperliquid). Pass --amount-unit token to use a human amount instead.`, + 'INVALID_INPUT', + ); + } + return s; + } + if (!/^\d*\.?\d+$/.test(s) || !(parseFloat(s) > 0)) { + throw new CommandError( + `Invalid --amount "${raw}". Must be a positive number when --amount-unit is ${amountUnit}.`, + 'INVALID_INPUT', + ); + } + return s; +} + +// ── Command builder ────────────────────────────────────────────────── + +export function buildBridgeCommands(deps = {}) { + const { log = console.log } = deps; + + return { + 'quote': async (args, apiInstance, flags, options) => { + const originChain = (options['from-chain'] || options.from || '').toLowerCase(); + const destinationChain = (options['to-chain'] || options.to || '').toLowerCase(); + const fromTokenRaw = options['from-token'] || options.token || ''; + const toTokenRaw = options['to-token'] || ''; + const amount = options.amount; + // Normalize so "Token"/"USD" etc. are accepted; reject anything unknown + // rather than silently falling back to base units (which would re-open the + // per-chain magnitude trap --amount-unit exists to prevent). + const amountUnit = options['amount-unit'] != null + ? String(options['amount-unit']).toLowerCase() + : undefined; + const slippageBps = options.slippage !== undefined ? parseSlippageBps(options.slippage) : 50; + const walletName = options.wallet; + const recipient = options.recipient; + + if (amountUnit !== undefined && amountUnit !== 'token' && amountUnit !== 'usd') { + throw new Error( + `Invalid --amount-unit "${options['amount-unit']}". Must be "token" or "usd" (omit for base units).`, + ); + } + + if (!originChain || !destinationChain || !fromTokenRaw || !amount) { + throw new CommandError( + `Usage: nansen bridge quote --from-chain --to-chain --from-token --amount [--wallet ] + +SUPPORTED ROUTES: + ${BRIDGE_ROUTES.map(([o, d]) => `${o} -> ${d}`).join('\n ')} + +OPTIONS: + --from-chain Source chain (see supported routes above) + --to-chain Destination chain (see supported routes above) + --from-token Source token (symbol like USDC, or address) + --to-token Destination token (defaults to USDC) + --amount Amount. Base units by default (decimals differ per chain: + USDC is 6 on EVM chains, 8 on Hyperliquid). Use --amount-unit + to pass human amounts instead. + --amount-unit token (human token amount) or usd. Omit for base units. + --slippage Slippage in bps (default 50 = 0.5%) + --wallet Wallet name + --recipient Destination wallet (defaults to same address)`, + 'MISSING_PARAM', + ); + } + + if (!isSupportedBridgeRoute(originChain, destinationChain)) { + throw new Error( + `Unsupported bridge route: ${originChain} -> ${destinationChain}. Supported routes: ${formatBridgeRoutes()}`, + ); + } + + if (recipient !== undefined) assertRecipient(recipient); + const amountInput = parseBridgeAmount(amount, amountUnit); + + const originToken = resolveBridgeToken(fromTokenRaw, originChain); + const destinationToken = toTokenRaw + ? resolveBridgeToken(toTokenRaw, destinationChain) + : resolveBridgeToken('USDC', destinationChain); + + const wallet = resolveWalletAddress(walletName); + + // Default: --amount is base units. With --amount-unit, accept a human token + // or USD amount and convert client-side using the source token's decimals. + let resolvedAmount = amountInput; + if (amountUnit === 'token' || amountUnit === 'usd') { + try { + const decimals = await resolveBridgeTokenDecimals(originToken, originChain); + let humanAmount = amountInput; + if (amountUnit === 'usd') { + // USDC is USD-pegged ($1), so skip the price lookup — and Hyperliquid's + // USDC uses a sentinel address the price API can't resolve, which would + // otherwise make `--amount-unit usd` unusable from HL. Non-stable tokens + // still fetch a live price. + const price = isBridgeUsdc(originToken, originChain) + ? 1 + : await resolveUsdPrice(apiInstance, originToken, originChain); + humanAmount = (parseFloat(amountInput) / price).toFixed(decimals); + } + resolvedAmount = convertToBaseUnits(humanAmount, decimals); + resolvedAmount = floorHyperliquidUsdcBridgeAmount(resolvedAmount, decimals, originToken, originChain); + } catch (err) { + throw new Error(`Error converting --amount: ${err.message}`, { cause: err }); + } + } + + log(`\n Fetching bridge quote: ${originChain} → ${destinationChain}...`); + + const result = await getBridgeQuote(apiInstance, { + wallet_address: wallet.address, + origin_chain: originChain, + destination_chain: destinationChain, + origin_token: originToken, + destination_token: destinationToken, + amount: resolvedAmount, + slippage_bps: slippageBps, + ...(recipient && { recipient }), + }); + + const details = result.details || {}; + const currIn = details.currencyIn || {}; + const currOut = details.currencyOut || {}; + const fees = result.fees || {}; + const relayerFee = fees.relayer || {}; + + log(`\n Bridge Quote: ${originChain} → ${destinationChain}`); + log(` Type: ${result.execution_type}`); + log(` Send: ${currIn.amountFormatted || amount} ${currIn.currency?.symbol || originToken}`); + log(` Receive: ${currOut.amountFormatted || '?'} ${currOut.currency?.symbol || destinationToken}`); + if (relayerFee.amountUsd) { + log(` Fee: $${relayerFee.amountUsd}`); + } + log(` Steps: ${(result.steps || []).length}`); + for (const s of result.steps || []) { + log(` - ${s.id} (${s.kind})`); + } + + const quoteId = saveBridgeQuote(result, originChain, destinationChain, wallet.provider, wallet.address); + log(`\n Quote ID: ${quoteId}`); + log(` Execute: nansen bridge execute --quote ${quoteId}`); + log(''); + return undefined; + }, + + 'execute': async (args, apiInstance, flags, options) => { + const quoteId = options.quote || args[0]; + const walletName = options.wallet; + + if (!quoteId) { + throw new CommandError( + `Usage: nansen bridge execute --quote [--wallet ] + +Execute a cached bridge quote. Signs transactions and broadcasts them. + +RECOVERY OPTIONS (EVM deposit legs only): + --priority-fee Priority fee in gwei, overriding the quoted one + --max-fee Fee cap in gwei, overriding the computed one + --nonce Sign at this nonce instead of the next one + +Use these to replace a transaction that is stuck in the mempool: a replacement +must reuse the stuck nonce and outbid it (roughly +10%), and the fees computed +from a quote are the same ones that got stuck. Check the stuck nonce with +"nansen wallet balance" or an explorer, then: + + nansen bridge execute --quote --nonce --priority-fee 0.05`, + 'MISSING_PARAM', + ); + } + + // Fee/nonce overrides. Parsed before the quote is touched so a typo can't + // consume it, and only applied to EVM legs — an HL withdrawal signs an + // action with no fee fields at all. + const feeOverrides = { + priorityFeeWei: options['priority-fee'] !== undefined + ? parseGweiToWei(options['priority-fee'], 'priority-fee') + : null, + maxFeeWei: options['max-fee'] !== undefined + ? parseGweiToWei(options['max-fee'], 'max-fee') + : null, + }; + let nonceSequence = null; + if (options.nonce !== undefined) { + const s = String(options.nonce).trim(); + if (!/^\d+$/.test(s)) { + throw new CommandError( + `Invalid --nonce "${options.nonce}". Must be a non-negative whole number.`, + 'INVALID_INPUT', + ); + } + // A multi-step quote (approve then deposit) signs consecutive nonces, so + // this is a starting point rather than a single value. + nonceSequence = { next: parseInt(s, 10) }; + } + + const quoteData = loadBridgeQuote(quoteId); + const { execution_type, steps, request_id } = quoteData.response; + + // The overrides only mean something for an EVM broadcast. A withdrawal + // signs a Hyperliquid action with no fee or nonce fields, so there is + // nothing to apply them to — refuse rather than ignore them, since the + // operator passed them expecting a different outcome. + if ( + execution_type !== 'evm_transaction' + && (feeOverrides.priorityFeeWei || feeOverrides.maxFeeWei || nonceSequence) + ) { + throw new CommandError( + `--priority-fee/--max-fee/--nonce apply only to EVM deposit legs, but quote "${quoteId}" is a ${execution_type} leg with no on-chain transaction to price.`, + 'INVALID_INPUT', + ); + } + + log(`\n Executing bridge: ${quoteData.originChain} → ${quoteData.destinationChain}`); + log(` Type: ${execution_type}`); + log(` Steps: ${steps.length}`); + + // The quote was issued for one wallet, but the signing wallet is resolved + // separately from --wallet / the current default — which can have changed + // since. Signing with a different wallet than the quote was built for would + // screen one address and move funds from another, and the cached tx data + // (nonce, from) belongs to the quote's wallet regardless. Refuse instead. + const signer = resolveWalletAddress(walletName); + if ( + quoteData.walletAddress && + String(signer.address).toLowerCase() !== String(quoteData.walletAddress).toLowerCase() + ) { + throw new Error( + `Bridge quote "${quoteId}" was created for ${quoteData.walletAddress} but the signing wallet is ${signer.address}. Pass --wallet for the quote's wallet, or request a new quote.`, + ); + } + + // Re-screen immediately before signing, fail-closed, mirroring the perp + // path — and screen the address that actually signs, now known to match the + // quote. The quote was screened server-side when issued, but quotes live up + // to an hour and the EVM deposit leg broadcasts straight to a public RPC, so + // without this nothing re-checks the wallet between the quote and the + // transaction that moves funds. + await screenOrThrow(apiInstance, [signer.address]); + + // Signing material for the wallet resolved above — not a second lookup. + // Resolving twice re-read the wallet file and, worse, could pick a + // different wallet than the one just screened if the default changed in + // between. + const creds = resolveSigningCredentials(signer); + + // Consume the quote at each INDIVIDUAL broadcast, before any receipt wait. + // A tx can be accepted by the network and then have waitForReceipt time + // out; if the quote were still unspent, a retry would re-sign and re-send + // it with a fresh nonce. markBridgeQuoteExecuted pins executedAt on the + // first call, so the quote is spent the instant anything is in flight. + const onBroadcast = (stepId, txHash) => + markBridgeQuoteExecuted(quoteId, { + broadcast: { step: stepId, txHash: txHash || null }, + totalSteps: steps.length, + }); + + // Step-level counter, recorded only once a step fully completes. + const markBroadcast = (index) => + markBridgeQuoteExecuted(quoteId, { + broadcastSteps: index + 1, + totalSteps: steps.length, + }); + + if (execution_type === 'evm_transaction') { + // Overrides move real money differently from what was quoted, so say so + // rather than letting them apply silently. + if (feeOverrides.priorityFeeWei || feeOverrides.maxFeeWei || nonceSequence) { + const parts = []; + if (feeOverrides.priorityFeeWei) parts.push(`priority fee ${feeOverrides.priorityFeeWei} wei`); + if (feeOverrides.maxFeeWei) parts.push(`fee cap ${feeOverrides.maxFeeWei} wei`); + if (nonceSequence) parts.push(`starting nonce ${nonceSequence.next}`); + log(` Overrides: ${parts.join(', ')}`); + } + for (const [index, step] of steps.entries()) { + await processEvmStep(step, { + chain: quoteData.originChain, + privateKeyHex: creds.privateKey, + log, + onBroadcast, + feeOverrides, + nonceSequence, + }); + markBroadcast(index); + } + } else if (execution_type === 'hyperliquid_signature') { + if (creds.provider === 'privy') { + const { PrivyClient } = await import('./privy.js'); + const privyClient = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET); + for (const [index, step] of steps.entries()) { + await processSignatureStepPrivy(step, { + privyClient, + // `signer` above — the same resolution that was screened and + // matched against the quote, rather than resolving a second time. + walletId: signer.privyWalletIds?.evm, + log, + apiInstance, + onBroadcast, + }); + markBroadcast(index); + } + } else { + for (const [index, step] of steps.entries()) { + await processSignatureStepLocal(step, { + privateKeyHex: creds.privateKey, + log, + apiInstance, + onBroadcast, + }); + markBroadcast(index); + } + } + } else { + throw new Error(`Unknown execution type: ${execution_type}`); + } + + // Every step is out; the marker above already consumed the quote. + markBridgeQuoteExecuted(quoteId, { broadcastSteps: steps.length, totalSteps: steps.length }); + + log(`\n Bridge submitted. Polling for completion...`); + const status = await pollBridgeCompletion(apiInstance, { requestId: request_id, log }); + + if (status.status === 'success') { + log(`\n Bridge completed!`); + if (status.destination_tx_hashes?.length) { + log(` Destination tx: ${status.destination_tx_hashes[0]}`); + } + } + log(''); + return undefined; + }, + + 'status': async (args, apiInstance, flags, options) => { + const requestId = options['request-id'] || args[0]; + const txHash = options['tx-hash']; + + if (!requestId && !txHash) { + throw new CommandError( + `Usage: nansen bridge status --request-id or --tx-hash + +Check the status of a Hyperliquid bridge transaction.`, + 'MISSING_PARAM', + ); + } + + const status = await getBridgeStatus(apiInstance, { requestId, txHash }); + + log(`\n Bridge Status: ${status.status}`); + if (status.raw_status) log(` Raw: ${status.raw_status}`); + if (status.source_tx_hashes?.length) log(` Source: ${status.source_tx_hashes.join(', ')}`); + if (status.destination_tx_hashes?.length) log(` Dest: ${status.destination_tx_hashes.join(', ')}`); + log(''); + return undefined; + }, + }; +} diff --git a/src/cli.js b/src/cli.js index 2180354b..d47dc8f7 100644 --- a/src/cli.js +++ b/src/cli.js @@ -5,6 +5,8 @@ import { NansenAPI, NansenError, CommandError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, normalizeAddress, sleep } from './api.js'; import { buildWalletCommands } from './wallet.js'; +import { buildBridgeCommands } from './bridge.js'; +import { buildPerpCommands } from './perp.js'; import { buildTradingCommands } from './trading.js'; import { buildLimitOrderCommands } from './limit-order.js'; import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js'; @@ -365,6 +367,19 @@ export function formatOutput(data, { pretty = false, table = false, csv = false } } +// Codes whose message is a usage banner written for a human to read: multi-line, +// indented, with a blank line between sections. Serialising one into the error +// envelope turns every newline into a literal \n and makes it unreadable, so an +// interactive terminal gets the message as written instead. Piped or explicitly +// formatted output still gets the envelope, so agents keep one shape to branch on. +export const USAGE_ERROR_CODES = new Set(['MISSING_PARAM', 'MISSING_ARGS']); + +export function isUsageError(errorData, { pretty, table, csv, stream, isTTY }) { + if (!USAGE_ERROR_CODES.has(errorData.code)) return false; + if (pretty || table || csv || stream) return false; + return !!isTTY; +} + // Format error data (returns object, does not exit) export function formatError(error) { const details = error.details ?? error.data ?? null; @@ -701,6 +716,8 @@ USAGE: nansen [subcommand] [options] COMMANDS: trade DEX swaps/bridges: quote, execute, bridge-status, limit-order + bridge Hyperliquid bridge: quote, execute, status (EVM <-> HL) + perp Hyperliquid perps: order, cancel, close, leverage, positions research analytics: smart-money, profiler, token, search, perp, portfolio, points wallet create, list, show, export, default, delete, forget-password agent Ask the Nansen AI research agent (fast/expert modes) @@ -724,6 +741,12 @@ TRADING: nansen trade limit-order create --from SOL --to USDC --amount 1.5 --trigger-mint SOL --trigger-condition below --trigger-price 80 Supports Solana/Base DEX swaps, cross-chain bridges, and Solana limit orders. +BRIDGE (Hyperliquid): + nansen bridge quote --from-chain base --to-chain hyperliquid --from-token USDC --amount 1000000 + nansen bridge execute --quote + nansen bridge status --request-id + Supports EVM chains (ethereum, base, arbitrum, polygon, bnb) <-> Hyperliquid. + EXAMPLES: nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000 nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000 @@ -737,6 +760,7 @@ DEPRECATED ALIASES (still work, will be removed in a future version): Research chains: ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, mantle, ronin, sei, plasma, sonic, monad, hyperevm, iotaevm Trade chains: solana, base +Bridge chains: ethereum, base, arbitrum, polygon, bnb, hyperliquid Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader Docs: https://docs.nansen.ai @@ -1488,6 +1512,11 @@ export function buildCommands(deps = {}) { // 'research' delegates to the category handlers defined above const RESEARCH_CATEGORIES = new Set(['smart-money', 'profiler', 'token', 'search', 'perp', 'portfolio', 'points', 'prediction-market']); + // The analytics-only perp handler, captured before the trading wrapper below + // replaces cmds['perp']. Both the wrapper and the research dispatch route to + // it, so it has to be taken exactly once, here. + const perpAnalytics = cmds['perp']; + const researchHistorical = buildResearchCommands(deps).research; cmds['research'] = async (args, apiInstance, flags, options) => { @@ -1508,6 +1537,13 @@ export function buildCommands(deps = {}) { if (!RESEARCH_CATEGORIES.has(category)) { throw new NansenError(`Unknown research category: ${rawCategory}. Available: ${[...RESEARCH_CATEGORIES, ...RESEARCH_HISTORICAL_SUBCOMMANDS].join(', ')}`, ErrorCode.UNKNOWN); } + // `research perp` reaches only the analytics half (screener/leaderboard) — + // the trading subcommands live at the top level. Routing its help through + // cmds['perp'] printed the trading help, advertising order/close/leverage + // from a command that can't run them. + if (category === 'perp' && (!args[1] || args[1] === 'help')) { + return perpAnalytics(['help'], apiInstance, flags, options); + } return cmds[category](args.slice(1), apiInstance, flags, options); }; @@ -1589,11 +1625,84 @@ USAGE: return tradingCmds[sub](args.slice(1), apiInstance, flags, options); }; + // 'bridge' delegates to quote/execute/status from buildBridgeCommands + const bridgeCmds = buildBridgeCommands(deps); + cmds['bridge'] = async (args, apiInstance, flags, options) => { + const sub = args[0]; + if (!sub || sub === 'help') { + log(`nansen bridge — Hyperliquid bridge commands (EVM <-> Hyperliquid via Relay) + +SUBCOMMANDS: + quote Get a bridge quote + execute Execute a bridge quote (sign + broadcast) + status Check bridge transaction status + +USAGE: + nansen bridge quote --from-chain base --to-chain hyperliquid --from-token USDC --amount 1000000 + nansen bridge execute --quote + nansen bridge status --request-id + +SUPPORTED CHAINS: + EVM -> Hyperliquid: ethereum, base, arbitrum, polygon, bnb + Hyperliquid -> EVM: ethereum, base, arbitrum`); + return; + } + if (!bridgeCmds[sub]) { + throw new NansenError(`Unknown bridge subcommand: ${sub}. Available: quote, execute, status`, ErrorCode.UNKNOWN); + } + return bridgeCmds[sub](args.slice(1), apiInstance, flags, options); + }; + + // 'perp' delegates to buildPerpCommands. The trading subcommands are added on + // top of the pre-existing perp analytics command, so capture that handler and + // keep screener/leaderboard reachable instead of shadowing them — both + // `nansen perp screener` and `nansen research perp screener` route through here. + const perpCmds = buildPerpCommands(deps); + const PERP_ANALYTICS_SUBCOMMANDS = new Set(['screener', 'leaderboard']); + cmds['perp'] = async (args, apiInstance, flags, options) => { + const sub = args[0]; + if (!sub || sub === 'help') { + log(`nansen perp — Hyperliquid perpetual trading + +SUBCOMMANDS: + order Place a perp order (market/limit with optional TP/SL) + cancel Cancel an open order + close Close a position (reduce-only market order) + leverage Set leverage and margin mode + transfer Move USDC between Spot and Perps balances + approve-builder-fee Authorize the Nansen builder fee (one-time; auto-fired on first trade) + positions View open positions + orders View open orders + account View account state (balance, equity, margin, spot) + meta View available assets + screener Perp market screener (analytics) + leaderboard Perp trader leaderboard (analytics) + +USAGE: + nansen perp order --coin BTC --side buy --size 0.001 --price 50000 --type limit + nansen perp cancel --coin BTC --oid 12345 + nansen perp close --coin BTC --size 0.001 --price 100000 --side sell + nansen perp leverage --coin BTC --leverage 10 --margin-type cross + nansen perp transfer --direction spot-to-perp --amount 25 + nansen perp approve-builder-fee + nansen perp positions + nansen perp account`); + return; + } + if (!perpCmds[sub]) { + if (PERP_ANALYTICS_SUBCOMMANDS.has(sub)) { + return perpAnalytics(args, apiInstance, flags, options); + } + throw new NansenError(`Unknown perp subcommand: ${sub}. Available: order, cancel, close, leverage, transfer, approve-builder-fee, positions, orders, account, meta, screener, leaderboard`, ErrorCode.UNKNOWN); + } + return perpCmds[sub](args.slice(1), apiInstance, flags, options); + }; + return cmds; } // Categories that moved under 'research' -export const DEPRECATED_TO_RESEARCH = new Set(['smart-money', 'profiler', 'token', 'search', 'perp', 'portfolio', 'points']); +export const DEPRECATED_TO_RESEARCH = new Set(['smart-money', 'profiler', 'token', 'search', 'portfolio', 'points']); // Subcommands that moved under 'trade' export const DEPRECATED_TO_TRADE = new Set(['quote', 'execute']); @@ -1670,7 +1779,10 @@ export async function runCLI(rawArgs, deps = {}) { errorOutput = console.error, exit = process.exit, NansenAPIClass = NansenAPI, - commandOverrides = {} + commandOverrides = {}, + // Injectable so tests can exercise both renderings; defaults to the real + // terminal, which is false under a pipe or in CI. + isTTY = process.stdout.isTTY, } = deps; const { _: positional, flags, options } = parseArgs(rawArgs); @@ -1852,6 +1964,15 @@ export async function runCLI(rawArgs, deps = {}) { defaultHeaders['Payment-Signature'] = options['x402-payment-signature']; } const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions, cache: cacheOptions, defaultHeaders }); + + // Deprecated top-level aliases otherwise run silently (the notice was only + // shown in --help). Warn on stderr so it doesn't pollute parsed stdout. + if (DEPRECATED_TO_TRADE.has(command)) { + process.stderr.write(`Note: "nansen ${command}" is deprecated. Use "nansen trade ${command}" instead.\n`); + } else if (DEPRECATED_TO_RESEARCH.has(command)) { + process.stderr.write(`Note: "nansen ${command}" is deprecated. Use "nansen research ${command}" instead.\n`); + } + let result = await commands[command](subArgs, api, flags, options); // Commands that handle their own output return undefined @@ -1898,12 +2019,15 @@ export async function runCLI(rawArgs, deps = {}) { await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain }); return { type: csv ? 'csv' : 'success', data: result }; } catch (error) { - let errorData; - if (error instanceof CommandError) { - output(error.data ? JSON.stringify(error.data) : error.message); - errorData = { error: error.message, code: error.code }; + // Unified error envelope across all command families (perp/bridge/trade): + // every failure serializes through formatError as + // {success:false, error, code, status, details}. A CommandError's structured + // data (e.g. PASSWORD_REQUIRED resolution steps) is preserved under `details`, + // so agents get one consistent shape to branch on regardless of command. + const errorData = formatError(error); + if (isUsageError(errorData, { pretty, table, csv, stream, isTTY })) { + output(errorData.error); } else { - errorData = formatError(error); const formatted = formatOutput(errorData, { pretty, table, csv }); output(formatted.text); } diff --git a/src/hl-action.js b/src/hl-action.js new file mode 100644 index 00000000..e29811e6 --- /dev/null +++ b/src/hl-action.js @@ -0,0 +1,494 @@ +/** + * Nansen CLI — Hyperliquid action builder (client-side, direct-to-HL). + * + * Ports the deterministic signing path of the hyperliquid-python-sdk + * (`utils/signing.py`) and nansen-api's `hyperliquid_exchange.py::prepare_*` + * into JS, so the CLI can build the exact L1 action + EIP-712 payload locally + * and submit straight to api.hyperliquid.xyz — no server round-trip to build it. + * + * The one primitive the CLI lacked is a msgpack encoder: an L1 action's hash is + * keccak256( msgpack(action) ‖ nonce(8B BE) ‖ vault-byte ) + * which becomes the EIP-712 `connectionId`. Everything else (keccak, secp256k1, + * EIP-712 hashing) already exists in crypto.js / x402-evm.js. + * + * Correctness is pinned byte-for-byte against the live API `/perp/*` prepare + * endpoints via golden-vector tests — the msgpack + rounding + wire assembly + * either reproduces the known-good Python output exactly or the test fails. + * + * Note: the L1 actions hashed here carry NO floats — prices and sizes are + * stringified by floatToWire before msgpack, so the encoder only handles + * str/int/bool/map/array. Float64 support is included for completeness only. + */ + +import { keccak256 } from './crypto.js'; +import { hlNetwork } from './hl-env.js'; + +// Phantom-agent `source` for an L1 action: "a" on mainnet, "b" on testnet +// (signing.py). Every builder below takes the network as an argument defaulting +// to hlNetwork(), so a caller can pin it in a test while the CLI stays in step +// with whatever base URL it will actually submit to. +function phantomAgentSource(network) { + return network === 'Testnet' ? 'b' : 'a'; +} + +// ── msgpack encoder ────────────────────────────────────────────────── +// +// Matches Python `msgpack.packb(action)` byte-for-byte for the value types we +// emit: map (JS object, insertion order preserved), array, str (utf-8), bool, +// and int. Widths are chosen smallest-first, unsigned preferred for +// non-negative ints — exactly what the reference implementation does. + +function encodeInt(nRaw) { + const n = BigInt(nRaw); + if (n >= 0n) { + if (n <= 0x7fn) return Buffer.from([Number(n)]); // positive fixint + if (n <= 0xffn) return Buffer.from([0xcc, Number(n)]); // uint8 + if (n <= 0xffffn) { + const b = Buffer.alloc(3); + b[0] = 0xcd; + b.writeUInt16BE(Number(n), 1); + return b; + } + if (n <= 0xffffffffn) { + const b = Buffer.alloc(5); + b[0] = 0xce; + b.writeUInt32BE(Number(n), 1); + return b; + } + const b = Buffer.alloc(9); + b[0] = 0xcf; + b.writeBigUInt64BE(n, 1); + return b; + } + if (n >= -0x20n) { + // negative fixint (0xe0..0xff) — single two's-complement byte + const b = Buffer.alloc(1); + b.writeInt8(Number(n), 0); + return b; + } + if (n >= -0x80n) return Buffer.from([0xd0, Number(n) & 0xff]); // int8 + if (n >= -0x8000n) { + const b = Buffer.alloc(3); + b[0] = 0xd1; + b.writeInt16BE(Number(n), 1); + return b; + } + if (n >= -0x80000000n) { + const b = Buffer.alloc(5); + b[0] = 0xd2; + b.writeInt32BE(Number(n), 1); + return b; + } + const b = Buffer.alloc(9); + b[0] = 0xd3; + b.writeBigInt64BE(n, 1); + return b; +} + +function encodeFloat(x) { + const b = Buffer.alloc(9); + b[0] = 0xcb; // float64 + b.writeDoubleBE(x, 1); + return b; +} + +function encodeStr(s) { + const utf8 = Buffer.from(s, 'utf8'); + const len = utf8.length; + let head; + if (len <= 31) head = Buffer.from([0xa0 | len]); // fixstr + else if (len <= 0xff) head = Buffer.from([0xd9, len]); // str8 + else if (len <= 0xffff) { + head = Buffer.alloc(3); + head[0] = 0xda; // str16 + head.writeUInt16BE(len, 1); + } else { + head = Buffer.alloc(5); + head[0] = 0xdb; // str32 + head.writeUInt32BE(len, 1); + } + return Buffer.concat([head, utf8]); +} + +function encodeArray(arr) { + const len = arr.length; + let head; + if (len <= 15) head = Buffer.from([0x90 | len]); // fixarray + else if (len <= 0xffff) { + head = Buffer.alloc(3); + head[0] = 0xdc; // array16 + head.writeUInt16BE(len, 1); + } else { + head = Buffer.alloc(5); + head[0] = 0xdd; // array32 + head.writeUInt32BE(len, 1); + } + return Buffer.concat([head, ...arr.map(encodeMsgpack)]); +} + +function encodeMap(obj) { + const keys = Object.keys(obj); + const len = keys.length; + let head; + if (len <= 15) head = Buffer.from([0x80 | len]); // fixmap + else if (len <= 0xffff) { + head = Buffer.alloc(3); + head[0] = 0xde; // map16 + head.writeUInt16BE(len, 1); + } else { + head = Buffer.alloc(5); + head[0] = 0xdf; // map32 + head.writeUInt32BE(len, 1); + } + const parts = [head]; + for (const k of keys) { + parts.push(encodeStr(k)); + parts.push(encodeMsgpack(obj[k])); + } + return Buffer.concat(parts); +} + +// Encode a JS value as msgpack. Integers are detected via Number.isInteger +// (all numeric action fields we emit — asset id, fee, order id, leverage — are +// integers; prices/sizes are already strings), so a bare number never takes the +// float64 path unless it genuinely has a fractional part. +export function encodeMsgpack(v) { + if (v === null || v === undefined) return Buffer.from([0xc0]); // nil + if (typeof v === 'boolean') return Buffer.from([v ? 0xc3 : 0xc2]); + if (typeof v === 'bigint') return encodeInt(v); + if (typeof v === 'number') return Number.isInteger(v) ? encodeInt(v) : encodeFloat(v); + if (typeof v === 'string') return encodeStr(v); + if (Array.isArray(v)) return encodeArray(v); + if (typeof v === 'object') return encodeMap(v); + throw new Error(`msgpack: unsupported type ${typeof v}`); +} + +// ── Number formatting (ports of signing.py) ────────────────────────── + +// Port of `float_to_wire`: fixed 8-decimal render, verify it doesn't lose +// precision, then strip trailing zeros (Decimal.normalize + `:f`). Produces the +// canonical decimal string HL expects in order wires (e.g. 1924.7 -> "1924.7", +// 0.006 -> "0.006", 2000 -> "2000"). No scientific notation. +export function floatToWire(x) { + const rounded = x.toFixed(8); + if (Math.abs(parseFloat(rounded) - x) >= 1e-12) { + throw new Error(`floatToWire causes rounding: ${x}`); + } + let s = rounded; + if (s.indexOf('.') !== -1) { + s = s.replace(/0+$/, '').replace(/\.$/, ''); + } + // Normalize a signed zero ("-0") to "0", matching the SDK. + if (s === '-0') s = '0'; + return s; +} + +// Round a positive-ish value to the nearest integer, ties-to-even (banker's). +// Math.round alone rounds ties up, so an exact .5 (e.g. HL's 1700.25 -> 1700.2) +// would differ from Python. The EPS band catches near-exact halves that binary +// float represents a hair off 0.5. +function roundHalfEven(y) { + const floor = Math.floor(y); + const diff = y - floor; + const EPS = 1e-9; + if (Math.abs(diff - 0.5) < EPS) return floor % 2 === 0 ? floor : floor + 1; + return Math.round(y); +} + +// Port of Python's round(x, ndigits): round-half-to-even. For negative ndigits +// we scale by an integer factor and multiply back (never divide by 0.1, which +// injects float dirt that later trips floatToWire's precision guard). +export function pyRound(x, ndigits) { + if (!Number.isFinite(x)) return x; + if (ndigits >= 0) { + const m = 10 ** ndigits; + return roundHalfEven(x * m) / m; + } + const m = 10 ** -ndigits; // integer + return roundHalfEven(x / m) * m; +} + +// 5-significant-figure round, ties-to-even — matches Python's f"{x:.5g}" (the +// first stage of HL's price rounding). toPrecision() rounds ties away from zero, +// so it can't be used here. +function roundSigFigs(x, sig) { + if (x === 0) return 0; + const digits = Math.floor(Math.log10(Math.abs(x))) + 1; // integer-part digits + return pyRound(x, sig - digits); +} + +// Port of `_round_size`: round size to the asset's szDecimals. +export function roundSize(size, szDecimals) { + return pyRound(size, szDecimals); +} + +// Port of `_round_price`: 5 significant figures, then round to (6 - szDecimals) +// decimal places (perps). f"{price:.5g}" == Number.toPrecision(5) reparsed. +export function roundPrice(price, szDecimals) { + const px = roundSigFigs(price, 5); + return pyRound(px, Math.max(0, 6 - szDecimals)); +} + +// ── Order wire assembly (ports of signing.py) ──────────────────────── + +function orderTypeToWire(orderType) { + if ('limit' in orderType) return { limit: orderType.limit }; + if ('trigger' in orderType) { + // Key order matches the SDK: isMarket, triggerPx, tpsl. + return { + trigger: { + isMarket: orderType.trigger.isMarket, + triggerPx: floatToWire(orderType.trigger.triggerPx), + tpsl: orderType.trigger.tpsl, + }, + }; + } + throw new Error('Invalid order type'); +} + +// Port of `order_request_to_order_wire`. Key order (a,b,p,s,r,t) is load-bearing +// — it's the msgpack map insertion order the hash depends on. No cloid ("c"): +// the CLI never sets one. +function orderRequestToOrderWire(order, asset) { + return { + a: asset, + b: order.isBuy, + p: floatToWire(order.limitPx), + s: floatToWire(order.sz), + r: order.reduceOnly, + t: orderTypeToWire(order.orderType), + }; +} + +// Port of `order_wires_to_order_action`. builder ({b,f}) is appended last, only +// when present — matching the SDK, so a builderless action hashes identically. +function orderWiresToOrderAction(orderWires, builder, grouping = 'na') { + const action = { type: 'order', orders: orderWires, grouping }; + if (builder) action.builder = builder; + return action; +} + +// Port of `_validate_tpsl`: a stop/take on the wrong side of entry would trigger +// immediately and self-close the position the moment it opens. +function validateTpsl({ isBuy, price, takeProfit, stopLoss }) { + if (isBuy) { + if (stopLoss != null && stopLoss >= price) { + throw new Error(`Stop-loss for a long must be below the entry price (${price}). Got: ${stopLoss}`); + } + if (takeProfit != null && takeProfit <= price) { + throw new Error(`Take-profit for a long must be above the entry price (${price}). Got: ${takeProfit}`); + } + } else { + if (stopLoss != null && stopLoss <= price) { + throw new Error(`Stop-loss for a short must be above the entry price (${price}). Got: ${stopLoss}`); + } + if (takeProfit != null && takeProfit >= price) { + throw new Error(`Take-profit for a short must be below the entry price (${price}). Got: ${takeProfit}`); + } + } +} + +// ── Action builders (ports of hyperliquid_exchange.py::prepare_*) ───── +// +// Each takes the asset metadata (assetId + szDecimals) the caller sourced from +// the proxy `GET /perp/meta` endpoint (Decision D4: reads stay on the proxy). +// `builder` is the {b: , f: } code, attached to +// order/close fills. Returns { action, size, price } where size/price are the +// rounded values actually encoded (so callers can report the real fill). + +export function buildOrderAction( + // coin is resolved to assetId/szDecimals by the caller (proxy /perp/meta), so + // it isn't needed here — the asset metadata is passed in the second argument. + { isBuy, size, price, orderType = 'limit', reduceOnly = false, tif = 'Gtc', slippage = 0.03, takeProfit = null, stopLoss = null, builder = null }, + { assetId, szDecimals }, +) { + validateTpsl({ isBuy, price, takeProfit, stopLoss }); + const roundedSize = roundSize(size, szDecimals); + + let effectivePrice; + let ot; + if (orderType === 'market') { + const raw = isBuy ? price * (1 + slippage) : price * (1 - slippage); + effectivePrice = roundPrice(raw, szDecimals); + ot = { limit: { tif: 'Ioc' } }; + } else { + effectivePrice = roundPrice(price, szDecimals); + ot = { limit: { tif } }; + } + + const wires = [ + orderRequestToOrderWire({ isBuy, sz: roundedSize, limitPx: effectivePrice, orderType: ot, reduceOnly }, assetId), + ]; + + let grouping = 'na'; + if (takeProfit != null || stopLoss != null) { + grouping = 'normalTpsl'; + if (takeProfit != null) { + const rtp = roundPrice(takeProfit, szDecimals); + wires.push( + orderRequestToOrderWire( + { isBuy: !isBuy, sz: roundedSize, limitPx: rtp, orderType: { trigger: { triggerPx: rtp, isMarket: true, tpsl: 'tp' } }, reduceOnly: true }, + assetId, + ), + ); + } + if (stopLoss != null) { + const rsl = roundPrice(stopLoss, szDecimals); + wires.push( + orderRequestToOrderWire( + { isBuy: !isBuy, sz: roundedSize, limitPx: rsl, orderType: { trigger: { triggerPx: rsl, isMarket: true, tpsl: 'sl' } }, reduceOnly: true }, + assetId, + ), + ); + } + } + + const action = orderWiresToOrderAction(wires, builder, grouping); + return { action, size: roundedSize, price: effectivePrice }; +} + +export function buildCancelAction({ orderId }, { assetId }) { + return { action: { type: 'cancel', cancels: [{ a: assetId, o: orderId }] } }; +} + +export function buildCloseAction({ size, price, isBuy, slippage = 0.03, builder = null }, { assetId, szDecimals }) { + const raw = isBuy ? price * (1 + slippage) : price * (1 - slippage); + const effectivePrice = roundPrice(raw, szDecimals); + const roundedSize = roundSize(size, szDecimals); + const wire = orderRequestToOrderWire( + { isBuy, sz: roundedSize, limitPx: effectivePrice, orderType: { limit: { tif: 'Ioc' } }, reduceOnly: true }, + assetId, + ); + const action = orderWiresToOrderAction([wire], builder); + return { action, size: roundedSize, price: effectivePrice }; +} + +export function buildLeverageAction({ leverage, isCross = true }, { assetId }) { + return { action: { type: 'updateLeverage', asset: assetId, isCross, leverage } }; +} + +// ── L1 hashing + phantom-agent EIP-712 (ports of signing.py) ───────── + +// Port of `action_hash(action, vault_address, nonce, None)`. We never set +// expiresAfter, so the trailing expires-block is omitted (matches the API, +// which passes expires_after=None). +export function actionHash(action, vaultAddress, nonce) { + const packed = encodeMsgpack(action); + const nonceBuf = Buffer.alloc(8); + nonceBuf.writeBigUInt64BE(BigInt(nonce), 0); + const parts = [packed, nonceBuf]; + if (vaultAddress == null) { + parts.push(Buffer.from([0x00])); + } else { + parts.push(Buffer.from([0x01])); + parts.push(Buffer.from(vaultAddress.replace(/^0x/, ''), 'hex')); + } + return keccak256(Buffer.concat(parts)); +} + +// Port of construct_phantom_agent + l1_payload (source "a"/"b" by network, +// chainId 1337, Exchange domain). Returns the EIP-712 payload the CLI's +// signAgent() already knows how to sign. +export function l1Eip712(action, vaultAddress, nonce, network = hlNetwork()) { + const hash = actionHash(action, vaultAddress, nonce); + const connectionId = '0x' + hash.toString('hex'); + return { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: phantomAgentSource(network), connectionId }, + }; +} + +// ── User-signed actions (approveBuilderFee / usdClassTransfer) ─────── +// +// These skip msgpack/action_hash entirely: the struct is EIP-712-hashed +// directly under the HyperliquidSignTransaction domain (signatureChainId +// 0x66eee). Ports of `user_signed_payload` + the two SIGN_TYPES tables. + +export const APPROVE_BUILDER_FEE_SIGN_TYPES = [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'maxFeeRate', type: 'string' }, + { name: 'builder', type: 'address' }, + { name: 'nonce', type: 'uint64' }, +]; + +export const USD_CLASS_TRANSFER_SIGN_TYPES = [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'amount', type: 'string' }, + { name: 'toPerp', type: 'bool' }, + { name: 'nonce', type: 'uint64' }, +]; + +// Port of `user_signed_payload`. The message carries extra keys (type, +// signatureChainId) that aren't in signTypes; EIP-712 hashing pulls fields by +// name from the type list, so they're ignored in the hash but preserved for the +// HL submit body. +export function userSignedEip712(primaryType, signTypes, action) { + const chainId = parseInt(action.signatureChainId, 16); + return { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { [primaryType]: signTypes }, + primaryType, + message: action, + }; +} + +// Build an approveBuilderFee action (user-signed, master key). maxFeeRate is the +// HL percentage string (e.g. "0.008%"); builder is the lowercased address. +export function buildApproveBuilderFeeAction({ maxFeeRate, builder, nonce, network = hlNetwork() }) { + return { + action: { + type: 'approveBuilderFee', + hyperliquidChain: network, + signatureChainId: '0x66eee', + maxFeeRate, + builder, + nonce, + }, + primaryType: 'HyperliquidTransaction:ApproveBuilderFee', + signTypes: APPROVE_BUILDER_FEE_SIGN_TYPES, + }; +} + +// Build a usdClassTransfer action (Spot<->Perps, user-signed). amount is +// rendered like the SDK: up to 8 decimals, no trailing zeros / sci-notation. +export function buildUsdClassTransferAction({ amount, toPerp, nonce, network = hlNetwork() }) { + // toFixed() switches to exponential notation from 1e21 up ("1e+21"), which + // HL's amount parser rejects — and the failure would surface as an opaque + // rejection after signing. An amount that large is a mistake either way, so + // refuse here. + if (!Number.isFinite(amount) || amount <= 0 || amount >= 1e21) { + throw new Error( + `Invalid usdClassTransfer amount: ${amount}. Must be a positive number below 1e21.`, + ); + } + const strAmount = amount.toFixed(8).replace(/0+$/, '').replace(/\.$/, ''); + return { + action: { + type: 'usdClassTransfer', + hyperliquidChain: network, + signatureChainId: '0x66eee', + amount: strAmount, + toPerp, + nonce, + }, + primaryType: 'HyperliquidTransaction:UsdClassTransfer', + signTypes: USD_CLASS_TRANSFER_SIGN_TYPES, + }; +} diff --git a/src/hl-client.js b/src/hl-client.js new file mode 100644 index 00000000..da94ce37 --- /dev/null +++ b/src/hl-client.js @@ -0,0 +1,146 @@ +/** + * Nansen CLI — direct Hyperliquid exchange submission (client egress). + * + * This is the ONE direct-to-HL network call (Decision D4): a signed L1 or + * user-signed action goes straight from the user's machine to + * api.hyperliquid.xyz/exchange. Reads and market-data stay on the Nansen proxy + * (/api/v1/perp/*), so nothing else here talks to HL directly. + * + * Mirrors the contract of the backend proxy (perp_execute.py) that this + * replaces. HL replies with an envelope: + * { status: "ok" | "err", response: } + * and signals failure in TWO ways, both of which must throw: + * 1. a top-level status of "err" (response carries the reason string), and + * 2. a status of "ok" that still carries per-action errors in + * response.data.statuses[].error — a rejected order that would otherwise + * masquerade as a fill. The proxy caught this via extract_action_errors; + * going direct, the CLI has to catch it itself. + */ + +import { CommandError } from "./api.js"; +// The base URL and network live in hl-env.js so hl-action.js can resolve the +// network without importing this module (it builds actions; this one submits +// them). Re-exported here because this is where callers expect to find them. +export { + HL_MAINNET_API_URL, + HL_TESTNET_API_URL, + hlApiUrl, + hlNetwork, +} from "./hl-env.js"; + +import { hlApiUrl } from "./hl-env.js"; + +// Port of perp_execute.py::extract_action_errors. HL returns top-level +// status "ok" even when individual actions are rejected: +// {"status":"ok","response":{"data":{"statuses":[{"error":"..."}]}}} +// Pull out every per-action error so a rejected order/cancel/close isn't masked +// as success. +export function extractActionErrors(responseBody) { + if (!responseBody || typeof responseBody !== "object") return []; + const data = responseBody.data; + if (!data || typeof data !== "object") return []; + const statuses = data.statuses; + if (!Array.isArray(statuses)) return []; + const errors = []; + for (const entry of statuses) { + if (entry && typeof entry === "object" && "error" in entry) { + errors.push(String(entry.error)); + } + } + return errors; +} + +// POST a signed action to HL's /exchange endpoint. +// +// `signature` is the {r, s, v} object signAgent() already produces; `nonce` is +// the same nonce the action was hashed with; `vaultAddress` is null for a normal +// wallet (omitted from the body when null, matching the SDK). +// +// Returns the parsed HL response object on success. Throws CommandError on a +// network failure, a non-JSON / HTTP-error response, a top-level "err", or a +// per-action error. +// +// Deliberately NOT retried: each submit carries a unique nonce and is not +// idempotent, so a retry after a request that may have reached HL risks a +// double-submit. A network error surfaces to the caller as-is. +export async function submitExchange( + { action, nonce, signature, vaultAddress = null }, + { fetchImpl = fetch, baseUrl = hlApiUrl(), timeoutMs = 30000 } = {} +) { + const body = { action, nonce, signature }; + // HL only expects vaultAddress when trading on behalf of a vault; omit the + // null so a normal-wallet action hashes/serializes like the SDK's. + if (vaultAddress != null) body.vaultAddress = vaultAddress; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let response; + try { + response = await fetchImpl(`${baseUrl}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + const reason = + err.name === "AbortError" + ? `timed out after ${timeoutMs}ms` + : err.message; + throw new CommandError( + `Could not reach Hyperliquid: ${reason}`, + "HL_NETWORK_ERROR" + ); + } finally { + clearTimeout(timer); + } + + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch { + throw new CommandError( + `Hyperliquid returned a non-JSON response (HTTP ${ + response.status + }): ${text.slice(0, 200)}`, + "HL_BAD_RESPONSE" + ); + } + + if (!response.ok) { + const detail = + typeof data === "string" + ? data + : data.response || data.error || JSON.stringify(data); + throw new CommandError( + `Hyperliquid error (HTTP ${response.status}): ${detail}`, + "HL_HTTP_ERROR" + ); + } + + const status = data.status ?? "ok"; + const responseBody = data.response; + + if (status === "err") { + const reason = + typeof responseBody === "string" + ? responseBody + : "Hyperliquid rejected the action"; + throw new CommandError( + `Hyperliquid rejected the action: ${reason}`, + "HL_ACTION_REJECTED" + ); + } + + const actionErrors = extractActionErrors(responseBody); + if (actionErrors.length > 0) { + throw new CommandError( + `Hyperliquid rejected the action: ${actionErrors.join("; ")}`, + "HL_ACTION_REJECTED" + ); + } + + return data; +} diff --git a/src/hl-env.js b/src/hl-env.js new file mode 100644 index 00000000..8e54dd34 --- /dev/null +++ b/src/hl-env.js @@ -0,0 +1,37 @@ +/** + * Nansen CLI — which Hyperliquid network we are talking to. + * + * Its own module because both halves of the HL path need it and neither should + * have to import the other: hl-action.js builds actions (pure, golden-vector + * pinned) and hl-client.js submits them. Deriving the network from one place + * keeps a signed action in step with the URL it is submitted to. + */ + +export const HL_MAINNET_API_URL = 'https://api.hyperliquid.xyz'; +export const HL_TESTNET_API_URL = 'https://api.hyperliquid-testnet.xyz'; + +// Resolve the HL API base. NANSEN_HL_API_URL overrides it (tests, or pointing at +// the testnet); defaults to mainnet. +export function hlApiUrl() { + return process.env.NANSEN_HL_API_URL || HL_MAINNET_API_URL; +} + +// Which HL network the resolved base URL points at. +// +// Actions are network-specific in two places: the L1 phantom agent's `source` +// ("a" mainnet, "b" testnet) and the `hyperliquidChain` field of a user-signed +// action ("Mainnet"/"Testnet"). Both used to be hardcoded to mainnet, so +// pointing NANSEN_HL_API_URL at the testnet signed mainnet-shaped actions that +// the testnet rejects. +// +// Anything not recognisably the testnet host is treated as mainnet, which keeps +// a local mock (tests) on the mainnet vectors. +export function hlNetwork() { + let host; + try { + host = new URL(hlApiUrl()).hostname.toLowerCase(); + } catch { + return 'Mainnet'; + } + return host.includes('hyperliquid-testnet') ? 'Testnet' : 'Mainnet'; +} diff --git a/src/keychain.js b/src/keychain.js index 6b0b7bc5..54da1522 100644 --- a/src/keychain.js +++ b/src/keychain.js @@ -73,7 +73,9 @@ function keychainRetrieve() { '-a', ACCOUNT, '-w', ], { timeout: TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'] }); - const pw = result.toString().trim(); + // Strip only the trailing newline the OS tool appends — trimming all + // whitespace would corrupt a password with leading/trailing spaces. + const pw = result.toString().replace(/\r?\n$/, ''); return pw || null; } @@ -83,7 +85,9 @@ function keychainRetrieve() { 'service', SERVICE, 'account', ACCOUNT, ], { timeout: TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'] }); - const pw = result.toString().trim(); + // Strip only the trailing newline the OS tool appends — trimming all + // whitespace would corrupt a password with leading/trailing spaces. + const pw = result.toString().replace(/\r?\n$/, ''); return pw || null; } diff --git a/src/limit-order.js b/src/limit-order.js index 4afff109..000a842d 100644 --- a/src/limit-order.js +++ b/src/limit-order.js @@ -381,14 +381,20 @@ export function parseExpiry(expiryStr) { const match = expiryStr.match(/^(\d+)(h|d)$/i); if (match) { const value = parseInt(match[1], 10); + if (value <= 0) { + throw new Error(`Invalid expiry "${expiryStr}". Duration must be greater than 0.`); + } const unit = match[2].toLowerCase(); const ms = unit === 'h' ? value * 3600 * 1000 : value * 24 * 3600 * 1000; return Date.now() + ms; } - // Try as raw epoch ms + // Try as raw epoch ms — must be in the future, or the order expires on arrival. const num = Number(expiryStr); - if (!isNaN(num) && num > Date.now() - 86400000) { + if (!isNaN(num)) { + if (num <= Date.now()) { + throw new Error(`Expiry "${expiryStr}" is in the past. Provide a future time (e.g. "24h", "7d", or a future epoch in ms).`); + } return num; } @@ -432,8 +438,16 @@ function formatAmount(amount, mintAddress) { if (!amount) return '?'; const info = KNOWN_SOLANA_TOKENS[mintAddress]; if (!info) return `${amount} ${mintAddress || '?'}`; - const raw = BigInt(amount); - const divisor = BigInt(10 ** info.decimals); + // amount is backend-controlled; a float or scientific-notation string makes + // BigInt() throw. Fall back to the raw amount rather than aborting the whole + // list render. + let raw; + try { + raw = BigInt(amount); + } catch { + return `${amount} ${info.symbol}`; + } + const divisor = 10n ** BigInt(info.decimals); const whole = raw / divisor; const frac = raw % divisor; const fracStr = frac.toString().padStart(info.decimals, '0').replace(/0+$/, ''); diff --git a/src/perp.js b/src/perp.js new file mode 100644 index 00000000..d767dd84 --- /dev/null +++ b/src/perp.js @@ -0,0 +1,828 @@ +/** + * Nansen CLI — Hyperliquid perpetual trading commands. + * + * Mutating commands (order/cancel/close/leverage/transfer) build the HL action + * locally (hl-action.js), screen the signing wallet against the live SDN list, + * sign with existing EIP-712 infrastructure, and submit straight to + * api.hyperliquid.xyz (hl-client.js) — the Nansen API is out of the order path. + * Reads (positions/orders/account/meta) and the builder-fee status still go + * through the proxy /api/v1/perp/* endpoints (Decision D4). + */ + +import { CommandError } from './api.js'; +import { signSecp256k1 } from './crypto.js'; +import { + buildApproveBuilderFeeAction, + buildCancelAction, + buildCloseAction, + buildLeverageAction, + buildOrderAction, + buildUsdClassTransferAction, + l1Eip712, + userSignedEip712, +} from './hl-action.js'; +import { submitExchange } from './hl-client.js'; +import { resolveEvmWallet, resolvePrivateKey } from './wallet-signing.js'; +import { hashTypedData } from './x402-evm.js'; + +// ── EIP-712 signing ────────────────────────────────────────────────── + +function signAgent(eip712, privateKeyHex) { + const { domain, types, primaryType, message } = eip712; + const fields = (types[primaryType] || []).map(f => ({ name: f.name, type: f.type })); + const msgHash = hashTypedData(domain, primaryType, fields, message); + const { r, s, v } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex')); + return { + r: '0x' + r.toString('hex'), + s: '0x' + s.toString('hex'), + v: 27 + v, + }; +} + +// ── Proxy read helpers (Decision D4: reads stay on the API) ─────────── + +// cache: false on every read. --cache is meant for research endpoints; here a +// stale response either misreports live money to the user (positions, orders, +// account) or feeds a signing decision (close sizes its order from positions, +// and asset ids/szDecimals come from meta), so a hit inside the 5-minute TTL +// would sign against data that has moved. +async function perpRead(apiInstance, endpoint, params) { + const qs = new URLSearchParams(params).toString(); + return apiInstance.request(`/api/v1/perp/${endpoint}?${qs}`, {}, { method: 'GET', cache: false }); +} + +// Resolve an asset's id + szDecimals (+ maxLeverage) from the proxy /perp/meta. +// Fail OPEN to null: a meta outage must not preempt a clearer earlier error +// (missing wallet, wrong password) — callers that need it to build an action +// re-check for null and abort at that point (see requireAsset). +async function fetchAssetMeta(apiInstance, coin) { + try { + const meta = await perpRead(apiInstance, 'meta', {}); + const asset = (meta.assets || []).find(a => String(a.name).toUpperCase() === coin); + if (asset && Number.isInteger(asset.asset_id) && Number.isInteger(asset.sz_decimals)) { + return { assetId: asset.asset_id, szDecimals: asset.sz_decimals, maxLeverage: asset.max_leverage }; + } + } catch { + // meta lookup failed — treat as unavailable; caller decides whether to abort. + } + return null; +} + +// An action builder needs the asset metadata; without it we cannot construct a +// correct wire, so abort (fail closed) rather than guessing. The message +// deliberately omits any upstream error text so it can't be confused with the +// advisory pre-checks that fall open on the same outage. +function requireAsset(assetMeta, coin) { + if (!assetMeta) { + throw new CommandError( + `Could not load Hyperliquid asset metadata for ${coin}; not trading.`, + 'META_UNAVAILABLE', + ); + } + return assetMeta; +} + +// Hard ceiling on the builder fee this CLI will attach to an order or sign an +// approval for, in tenths of a basis point. Nansen's published rate is 80 +// (0.08%). +// +// The rate arrives from the API and approveBuilderFee authorises a *maximum* on +// HL, so an unbounded value would be signed as given — the only threat model is +// a compromised or misconfigured API, which makes this defence in depth rather +// than a live risk. It also catches a units slip: a rate mistakenly expressed in +// basis points or percent reads as wildly out of range here. +// +// Deliberately equal to the published rate, not a loose multiple: if Nansen's +// builder fee changes, that should ship as a CLI release rather than take effect +// silently on every installed client. +const MAX_BUILDER_FEE_TENTHS_BP = 80; + +// Fetch the builder-fee status + code from the proxy (single source of truth, +// Decision D1): { approved, max_fee_rate, required_fee, builder_address }. One +// call yields both the {b,f} attached to every order/close and the approval +// gate. Fail closed — this endpoint shares availability with screening, so if +// it's down we abort rather than trade without the builder code. +async function fetchBuilderFee(apiInstance, walletAddress) { + const qs = new URLSearchParams({ wallet_address: walletAddress }).toString(); + let status; + try { + // cache: false — this gates whether we sign a builder-fee approval, so a + // stale verdict either skips a needed approval (HL then rejects the order) + // or re-signs one that already exists. + status = await apiInstance.request(`/api/v1/perp/builder-fee?${qs}`, {}, { method: 'GET', cache: false }); + } catch (err) { + throw new CommandError( + `Could not resolve the Hyperliquid builder fee, so the trade was not submitted: ${err.message}`, + 'BUILDER_FEE_UNAVAILABLE', + ); + } + if (!status || !status.builder_address || !Number.isInteger(status.required_fee)) { + throw new CommandError( + 'Builder-fee status response was malformed, so the trade was not submitted.', + 'BUILDER_FEE_UNAVAILABLE', + ); + } + // Bound the fee at its single entry point: every order/close attaches + // required_fee as its builder code, and every approval signs a maxFeeRate + // derived from the same number, so checking here covers both. + if (status.required_fee < 0 || status.required_fee > MAX_BUILDER_FEE_TENTHS_BP) { + throw new CommandError( + `Refusing to trade: the builder fee returned was ${status.required_fee} tenths of a basis point (${builderMaxFeeRate(status.required_fee)}), above the ${MAX_BUILDER_FEE_TENTHS_BP} (${builderMaxFeeRate(MAX_BUILDER_FEE_TENTHS_BP)}) this CLI accepts. Upgrade the CLI if Nansen's builder fee has changed.`, + 'BUILDER_FEE_TOO_HIGH', + ); + } + return status; +} + +// The {b,f} builder code attached to order/close actions. `b` is lowercased (HL +// requirement); `f` is the fee in tenths of a basis point. +function builderCode(status) { + return { b: String(status.builder_address).toLowerCase(), f: status.required_fee }; +} + +// maxFeeRate string signed in approveBuilderFee, derived from the per-order fee +// so the two can't drift — mirrors the API's config.hl_builder_max_fee_rate +// (80 tenths-of-a-bp -> "0.08%"). +function builderMaxFeeRate(requiredFee) { + const percent = requiredFee / 1000; + return percent.toFixed(4).replace(/0+$/, '').replace(/\.$/, '') + '%'; +} + +// HL nonce: current time in milliseconds. Must be recent and strictly +// increasing per account; Date.now() satisfies both for a single command. +function hlNonce() { + return Date.now(); +} + +// ── Wallet helpers ─────────────────────────────────────────────────── + +// Resolution and key handling are shared with bridge.js (wallet-signing.js), so +// a fix to either can't land on one money path and miss the other. +function resolveWalletAddress(walletName) { + return resolveEvmWallet(walletName, 'Hyperliquid perp trading'); +} + +// ── Screening (Chunk 4) ────────────────────────────────────────────── +// +// Per-trade OFAC screening against the live SDN list. This is the compliance +// checkpoint that lets the CLI submit directly to HL: every mutating action +// re-screens the signing wallet before it is signed. Fail CLOSED — a sanctioned +// hit, a non-200 (503 = SDN snapshot unavailable), a network error, or a +// response that doesn't cover every requested address all abort the trade +// before signing, never trade through. + +// Exported for bridge.js, which needs the same fail-closed check before it signs +// (its EVM deposit leg broadcasts straight to a public RPC, so no server-side +// screen sits in that path). Worth lifting into its own module if a third caller +// appears. +export async function screenOrThrow(apiInstance, addresses) { + let result; + try { + // cache: false is load-bearing, not hygiene. Every mutating command + // re-screens the signing wallet immediately before signing; serving that + // verdict from a cache written up to 5 minutes ago would let a + // newly-listed address through on exactly the guarantee this check exists + // to provide. + result = await apiInstance.request('/api/v1/sanctions/screen', { addresses }, { cache: false }); + } catch (err) { + throw new CommandError( + `Compliance screening is unavailable, so the trade was not submitted: ${err.message}`, + 'SCREENING_UNAVAILABLE', + ); + } + + const results = Array.isArray(result?.results) ? result.results : []; + const sanctioned = results.filter(r => r && r.sanctioned).map(r => r.address); + if (sanctioned.length > 0) { + throw new CommandError( + `Wallet address is on the compliance blocklist and cannot trade: ${sanctioned.join(', ')}`, + 'SANCTIONED', + ); + } + + // A 200 that omitted a requested address is unverifiable — fail closed rather + // than assume the missing address is clean. + const screened = new Set(results.map(r => String(r.address).toLowerCase())); + const missing = addresses.filter(a => !screened.has(String(a).toLowerCase())); + if (missing.length > 0) { + throw new CommandError( + `Compliance screening did not cover all addresses, so the trade was not submitted: ${missing.join(', ')}`, + 'SCREENING_UNAVAILABLE', + ); + } +} + +// ── Sign + direct submit ───────────────────────────────────────────── + +// Sign an EIP-712 payload with the local wallet key or via Privy. Returns the +// {r,s,v} object submitExchange expects. Works for both L1 (phantom-agent) and +// user-signed payloads — the field list comes from the payload's own types. +async function signHlAction(eip712, { privateKeyHex, privyClient, privyWalletId, log }) { + if (privyClient && privyWalletId) { + log(' Signing via Privy...'); + const result = await privyClient.ethSignTypedDataV4(privyWalletId, eip712); + const sig = result.data?.signature || result.signature || result; + return { + r: '0x' + sig.slice(2, 66), + s: '0x' + sig.slice(66, 130), + v: parseInt(sig.slice(130, 132), 16), + }; + } + log(' Signing...'); + return signAgent(eip712, privateKeyHex); +} + +// The direct-to-HL flow that replaces prepareSignExecute: screen the signing +// wallet against the live SDN list (Chunk 4), sign the locally-built action, +// and submit straight to api.hyperliquid.xyz (Chunk 2). `prepared` is +// { action, nonce, eip712, size?, price? } from an hl-action.js builder; the +// vault is always null for a normal wallet (the CLI signs L1 actions with the +// wallet key directly). submitExchange throws on any HL rejection. +async function buildScreenSignSubmit(apiInstance, prepared, ctx) { + const { action, nonce, eip712, size, price } = prepared; + const { walletAddress, log } = ctx; + + log(' Screening...'); + await screenOrThrow(apiInstance, [walletAddress]); + + // Report the values actually encoded in the signed action (rounded size, + // slippage-adjusted market price), not the raw input, so we don't misreport + // the fill. + if (size !== undefined && price !== undefined) { + log(` Submitting: ${size} @ ${price}`); + } + + const signature = await signHlAction(eip712, ctx); + + log(' Submitting to Hyperliquid...'); + const result = await submitExchange({ action, nonce, signature, vaultAddress: null }); + + const status = result.status ?? 'ok'; + log(` Status: ${status}`); + if (result.response) { + const resp = typeof result.response === 'string' ? result.response : JSON.stringify(result.response); + log(` Response: ${resp}`); + } + return result; +} + +// ── Builder-fee onboarding (Chunk 5) ───────────────────────────────── +// +// HL silently rejects orders carrying our builder code until the master wallet +// has approved a matching maxFeeRate. Auto-fire the one-time approval before the +// first order/close; skip when already approved. The approval is a user-signed +// action signed by the same wallet key, and is screened like any other. +async function ensureBuilderApproved(apiInstance, status, ctx) { + if (status.approved) return; + const maxFeeRate = builderMaxFeeRate(status.required_fee); + const builder = String(status.builder_address).toLowerCase(); + // Name the rate and the beneficiary before signing: this authorises a maximum + // fee on Hyperliquid, so what was approved should be visible in the transcript + // rather than implied by "(one-time)". fetchBuilderFee has already bounded the + // rate at MAX_BUILDER_FEE_TENTHS_BP. + ctx.log(` Approving Nansen builder fee (one-time): max ${maxFeeRate} to ${builder}`); + const nonce = hlNonce(); + const { action, primaryType, signTypes } = buildApproveBuilderFeeAction({ + maxFeeRate, + builder, + nonce, + }); + const eip712 = userSignedEip712(primaryType, signTypes, action); + await buildScreenSignSubmit(apiInstance, { action, nonce, eip712 }, ctx); +} + +// ── Input validation ───────────────────────────────────────────────── +// +// The perp path coerces strings to booleans (side -> is_buy, margin-type -> +// is_cross) before anything reaches the backend, so a typo can't be caught +// server-side — it silently flips to the false branch (short / isolated). +// Validate against explicit allowlists, and reject non-positive/non-finite +// numerics, before signing anything. +// +// All guards throw a coded CommandError ('INVALID_INPUT') rather than a bare +// Error, so agents can branch on the error code instead of string-matching. + +const ORDER_SIDES = new Set(['buy', 'long', 'sell', 'short']); +const CLOSE_SIDES = new Set(['buy', 'sell']); +const MARGIN_TYPES = new Set(['cross', 'isolated']); +// Case-insensitive input -> canonical value the backend expects. Hyperliquid +// is case-sensitive (Gtc not gtc, limit not LIMIT), so normalise here rather +// than forwarding the raw string and letting the backend reject it. +const TIF_VALUES = new Map([['gtc', 'Gtc'], ['ioc', 'Ioc'], ['alo', 'Alo']]); +const ORDER_TYPES = new Map([['limit', 'limit'], ['market', 'market']]); + +function invalid(message) { + return new CommandError(message, 'INVALID_INPUT'); +} + +// The arg parser collects a repeated flag into an array (to support genuinely +// repeatable flags elsewhere). Perp flags are never repeatable, so reject the +// array with a clear message instead of crashing in a string guard or silently +// using the first element. +function scalar(raw, name) { + if (Array.isArray(raw)) { + throw invalid(`--${name} was provided more than once. Pass --${name} exactly once.`); + } + return raw; +} + +function assertSide(raw, allowed) { + const side = String(scalar(raw, 'side') ?? '').toLowerCase(); + if (!allowed.has(side)) { + throw invalid(`Invalid --side "${raw}". Must be one of: ${[...allowed].join(', ')}.`); + } + return side; +} + +function assertMarginType(raw) { + // --margin-type is optional and defaults to cross when omitted. + if (raw === undefined) return 'cross'; + const marginType = String(scalar(raw, 'margin-type') ?? '').toLowerCase(); + if (!MARGIN_TYPES.has(marginType)) { + throw invalid(`Invalid --margin-type "${raw}". Must be one of: ${[...MARGIN_TYPES].join(', ')}.`); + } + return marginType; +} + +function parsePositiveNumber(raw, name) { + // Strict numeric check before parseFloat — parseFloat("100abc") returns 100, + // so trailing garbage would otherwise slip through and only fail at the backend. + const s = String(scalar(raw, name) ?? '').trim(); + if (!/^\d*\.?\d+$/.test(s)) { + throw invalid(`Invalid --${name} "${raw}". Must be a positive number.`); + } + const n = parseFloat(s); + if (!Number.isFinite(n) || n <= 0) { + throw invalid(`Invalid --${name} "${raw}". Must be a positive number.`); + } + return n; +} + +function parsePositiveInt(raw, name) { + // Digits-only check before parseInt — parseInt("2.5") floors to 2 and + // parseInt("123abc") yields 123, so a fractional or garbage value would + // otherwise be silently accepted. + const s = String(scalar(raw, name) ?? '').trim(); + if (!/^\d+$/.test(s)) { + throw invalid(`Invalid --${name} "${raw}". Must be a positive integer.`); + } + const n = parseInt(s, 10); + if (!Number.isInteger(n) || n <= 0) { + throw invalid(`Invalid --${name} "${raw}". Must be a positive integer.`); + } + return n; +} + +function parseSlippage(raw) { + // Slippage is a decimal fraction in [0, 1] (0.03 = 3%). Reject trailing + // garbage (parseFloat would accept "0.03abc") and percent-vs-decimal + // mix-ups (e.g. "3" meaning 3% would otherwise be a 300% tolerance). + const s = String(scalar(raw, 'slippage') ?? '').trim(); + const n = /^\d*\.?\d+$/.test(s) ? parseFloat(s) : NaN; + if (!Number.isFinite(n) || n < 0 || n > 1) { + throw invalid(`Invalid --slippage "${raw}". Use a decimal between 0 and 1 (e.g. 0.03 for 3%).`); + } + return n; +} + +// Count the decimal places in a validated numeric string. The numeric guards +// above reject scientific notation and trailing garbage, so a plain split on +// "." is exact (no float-repr drift from parseFloat). +function countDecimals(numStr) { + const s = String(numStr).trim(); + const dot = s.indexOf('.'); + return dot === -1 ? 0 : s.length - dot - 1; +} + +// Hyperliquid rounds an over-precise size/price to the asset's precision rather +// than rejecting it (size -> szDecimals; price -> 6 - szDecimals decimals for +// perps), so the order still fills — but silently at a different value than the +// user typed. Warn up front (the post-prepare "Submitting" line then shows the +// exact rounded value). Fail open: with no szDecimals (meta unavailable) skip. +function warnImpreciseValue(coin, szDecimals, { sizeRaw, priceRaw }, warn) { + if (!Number.isInteger(szDecimals)) return; + if (sizeRaw !== undefined && countDecimals(sizeRaw) > szDecimals) { + warn(`⚠️ --size ${sizeRaw} is more precise than ${coin} allows (max ${szDecimals} decimals); Hyperliquid will round it.`); + } + const maxPriceDecimals = Math.max(0, 6 - szDecimals); + if (priceRaw !== undefined && countDecimals(priceRaw) > maxPriceDecimals) { + warn(`⚠️ --price ${priceRaw} is more precise than ${coin} allows (max ${maxPriceDecimals} decimals); Hyperliquid will round it.`); + } +} + +// Resolve the signing half of a mutating command's context for an +// already-resolved wallet: a local private key, or a Privy client + wallet id. +// Returns the ctx object buildScreenSignSubmit consumes (walletAddress + one of +// the two signing paths + log). Kept separate from resolveWalletAddress so a +// command that needs the address earlier (e.g. close's direction pre-check) can +// resolve the key afterwards, matching the previous ordering. Takes the resolved +// wallet, not its name, so the wallet is read once per command. +async function resolveSigningCtx(wallet, log) { + const ctx = { + walletAddress: wallet.address, + privateKeyHex: null, + privyClient: null, + privyWalletId: null, + log, + }; + if (wallet.provider === 'privy') { + const { PrivyClient } = await import('./privy.js'); + ctx.privyClient = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET); + ctx.privyWalletId = wallet.privyWalletIds?.evm; + } else { + ctx.privateKeyHex = resolvePrivateKey(wallet); + } + return ctx; +} + +function assertTif(raw) { + // --tif is optional and defaults to Gtc when omitted. + if (raw === undefined) return 'Gtc'; + const tif = TIF_VALUES.get(String(scalar(raw, 'tif') ?? '').toLowerCase()); + if (!tif) { + throw invalid(`Invalid --tif "${raw}". Must be one of: Gtc, Ioc, Alo.`); + } + return tif; +} + +function assertOrderType(raw) { + // --type is optional and defaults to limit when omitted. + if (raw === undefined) return 'limit'; + const type = ORDER_TYPES.get(String(scalar(raw, 'type') ?? '').toLowerCase()); + if (!type) { + throw invalid(`Invalid --type "${raw}". Must be one of: limit, market.`); + } + return type; +} + +// Resolve the asset symbol from --coin (or its --symbol alias), rejecting a +// duplicated flag. Returns the upper-cased symbol, or '' when neither is set. +function resolveCoin(options) { + const raw = scalar(options.coin, 'coin') ?? scalar(options.symbol, 'symbol'); + return String(raw ?? '').toUpperCase(); +} + +// ── Command builder ────────────────────────────────────────────────── + +export function buildPerpCommands(deps = {}) { + const { log = console.log, warn = (m) => process.stderr.write(`${m}\n`) } = deps; + + return { + 'order': async (args, apiInstance, flags, options) => { + const coin = resolveCoin(options); + const walletName = scalar(options.wallet, 'wallet'); + + if (!coin || !options.side || options.size === undefined || options.price === undefined) { + throw new CommandError( +`Usage: nansen perp order --coin --side --size --price [options] + +OPTIONS: + --coin Asset symbol (BTC, ETH, etc.) + --side buy (long) or sell (short) + --size Position size in base asset units + --price Limit price (or mark price for market orders) + --type Order type: limit (default) or market + --tif Time-in-force: Gtc (default), Ioc, Alo + --slippage Slippage for market orders (default 0.03 = 3%) + --take-profit Take-profit trigger price + --stop-loss Stop-loss trigger price + --wallet Wallet name`, 'MISSING_PARAM'); + } + + const side = assertSide(options.side, ORDER_SIDES); + const orderType = assertOrderType(options.type); + const tif = assertTif(options.tif); + const size = parsePositiveNumber(options.size, 'size'); + const price = parsePositiveNumber(options.price, 'price'); + const slippage = options.slippage !== undefined ? parseSlippage(options.slippage) : 0.03; + const tp = options['take-profit'] !== undefined ? parsePositiveNumber(options['take-profit'], 'take-profit') : undefined; + const sl = options['stop-loss'] !== undefined ? parsePositiveNumber(options['stop-loss'], 'stop-loss') : undefined; + const isBuy = side === 'buy' || side === 'long'; + + // One meta read serves both the advisory precision warning and the + // required build metadata. Fetched fail-open so a meta outage doesn't + // preempt a clearer error below (missing wallet, wrong password); we + // re-require it just before building the action. + const assetMeta = await fetchAssetMeta(apiInstance, coin); + warnImpreciseValue(coin, assetMeta?.szDecimals, { sizeRaw: options.size, priceRaw: options.price }, warn); + + const wallet = resolveWalletAddress(walletName); + const ctx = await resolveSigningCtx(wallet, log); + + const { assetId, szDecimals } = requireAsset(assetMeta, coin); + + log(`\n Perp Order: ${coin} ${isBuy ? 'LONG' : 'SHORT'} ${size} @ ${price} (${orderType})`); + + // Single source of truth for the builder code + approval gate (D1). + const builderStatus = await fetchBuilderFee(apiInstance, wallet.address); + await ensureBuilderApproved(apiInstance, builderStatus, ctx); + + const { action, size: effSize, price: effPrice } = buildOrderAction( + { + isBuy, + size, + price, + orderType, + reduceOnly: false, + tif, + slippage, + takeProfit: tp ?? null, + stopLoss: sl ?? null, + builder: builderCode(builderStatus), + }, + { assetId, szDecimals }, + ); + const nonce = hlNonce(); + const eip712 = l1Eip712(action, null, nonce); + + await buildScreenSignSubmit(apiInstance, { action, nonce, eip712, size: effSize, price: effPrice }, ctx); + log(''); + return undefined; + }, + + 'cancel': async (args, apiInstance, flags, options) => { + const coin = resolveCoin(options); + const walletName = scalar(options.wallet, 'wallet'); + + if (!coin || options.oid === undefined) { + throw new CommandError('Usage: nansen perp cancel --coin --oid [--wallet ]', 'MISSING_PARAM'); + } + + const oid = parsePositiveInt(options.oid, 'oid'); + + const assetMeta = await fetchAssetMeta(apiInstance, coin); + const wallet = resolveWalletAddress(walletName); + const ctx = await resolveSigningCtx(wallet, log); + const { assetId } = requireAsset(assetMeta, coin); + + log(`\n Cancel: ${coin} order #${oid}`); + + const { action } = buildCancelAction({ orderId: oid }, { assetId }); + const nonce = hlNonce(); + const eip712 = l1Eip712(action, null, nonce); + + await buildScreenSignSubmit(apiInstance, { action, nonce, eip712 }, ctx); + log(''); + return undefined; + }, + + 'close': async (args, apiInstance, flags, options) => { + const coin = resolveCoin(options); + const walletName = scalar(options.wallet, 'wallet'); + + if (!coin || options.size === undefined || options.price === undefined || !options.side) { + throw new CommandError( +`Usage: nansen perp close --coin --size --price --side [options] + + --side buy (closing a short) or sell (closing a long) + --slippage Slippage tolerance (default 0.03 = 3%)`, 'MISSING_PARAM'); + } + + const side = assertSide(options.side, CLOSE_SIDES); + const size = parsePositiveNumber(options.size, 'size'); + const price = parsePositiveNumber(options.price, 'price'); + const slippage = options.slippage !== undefined ? parseSlippage(options.slippage) : 0.03; + const isBuy = side === 'buy'; + + // Advisory: warn before signing if the close size is finer than the asset + // allows (Hyperliquid rounds rather than rejects). --price here is only a + // reference mark for the slippage calc, so its precision is not flagged. + const assetMeta = await fetchAssetMeta(apiInstance, coin); + warnImpreciseValue(coin, assetMeta?.szDecimals, { sizeRaw: options.size }, warn); + + const wallet = resolveWalletAddress(walletName); + + // Validate the close direction against the open position so a wrong --side + // fails fast with a clear message instead of the backend's opaque "reduce + // only order would increase position". sell closes a long, buy closes a + // short. Fall open if positions can't be fetched — HL still checks. + let openPositions = null; + try { + const result = await perpRead(apiInstance, 'positions', { wallet_address: wallet.address }); + openPositions = result.positions || []; + } catch { + // positions lookup failed — skip the direction pre-check. + } + if (openPositions) { + const pos = openPositions.find(p => String(p.coin).toUpperCase() === coin); + const szi = pos ? parseFloat(pos.szi) : NaN; + if (Number.isFinite(szi) && szi !== 0) { + const requiredSide = szi > 0 ? 'sell' : 'buy'; + if (side !== requiredSide) { + const posSide = szi > 0 ? 'long' : 'short'; + throw invalid( + `Cannot close a ${posSide} ${coin} position with --side ${side}. Use --side ${requiredSide} (sell closes a long, buy closes a short).`, + ); + } + } + } + + const ctx = await resolveSigningCtx(wallet, log); + const { assetId, szDecimals } = requireAsset(assetMeta, coin); + + log(`\n Close: ${coin} ${isBuy ? 'buy-to-close' : 'sell-to-close'} ${size} @ ${price}`); + + const builderStatus = await fetchBuilderFee(apiInstance, wallet.address); + await ensureBuilderApproved(apiInstance, builderStatus, ctx); + + const { action, size: effSize, price: effPrice } = buildCloseAction( + { size, price, isBuy, slippage, builder: builderCode(builderStatus) }, + { assetId, szDecimals }, + ); + const nonce = hlNonce(); + const eip712 = l1Eip712(action, null, nonce); + + await buildScreenSignSubmit(apiInstance, { action, nonce, eip712, size: effSize, price: effPrice }, ctx); + log(''); + return undefined; + }, + + 'leverage': async (args, apiInstance, flags, options) => { + const coin = resolveCoin(options); + const walletName = scalar(options.wallet, 'wallet'); + + if (!coin || options.leverage === undefined) { + throw new CommandError('Usage: nansen perp leverage --coin --leverage [--margin-type cross|isolated] [--wallet ]', 'MISSING_PARAM'); + } + + const marginType = assertMarginType(options['margin-type']); + const leverage = parsePositiveInt(options.leverage, 'leverage'); + + // Pre-validate against the asset's max leverage so an over-max value fails + // fast with a clear message instead of an opaque HL rejection. Falls open + // if meta is unavailable or the coin isn't listed (HL still checks); the + // build below re-requires meta since it needs the asset id. + const assetMeta = await fetchAssetMeta(apiInstance, coin); + if (assetMeta && Number.isFinite(assetMeta.maxLeverage) && leverage > assetMeta.maxLeverage) { + throw invalid(`Leverage ${leverage}x exceeds the ${assetMeta.maxLeverage}x maximum for ${coin}.`); + } + + const isCross = marginType === 'cross'; + const wallet = resolveWalletAddress(walletName); + const ctx = await resolveSigningCtx(wallet, log); + const { assetId } = requireAsset(assetMeta, coin); + + log(`\n Leverage: ${coin} ${leverage}x ${isCross ? 'cross' : 'isolated'}`); + + const { action } = buildLeverageAction({ leverage, isCross }, { assetId }); + const nonce = hlNonce(); + const eip712 = l1Eip712(action, null, nonce); + + await buildScreenSignSubmit(apiInstance, { action, nonce, eip712 }, ctx); + log(''); + return undefined; + }, + + 'transfer': async (args, apiInstance, flags, options) => { + const direction = scalar(options.direction, 'direction'); + const walletName = scalar(options.wallet, 'wallet'); + + if (!direction || options.amount === undefined) { + throw new CommandError( + 'Usage: nansen perp transfer --direction --amount [--wallet ]', + 'MISSING_PARAM', + ); + } + + // Move USDC between the wallet's Spot and Perps balances (usdClassTransfer). + const DIRECTIONS = new Map([['spot-to-perp', true], ['perp-to-spot', false]]); + const toPerp = DIRECTIONS.get(String(direction).toLowerCase()); + if (toPerp === undefined) { + throw invalid(`Invalid --direction "${direction}". Must be one of: spot-to-perp, perp-to-spot.`); + } + const amount = parsePositiveNumber(options.amount, 'amount'); + + const wallet = resolveWalletAddress(walletName); + const ctx = await resolveSigningCtx(wallet, log); + + log(`\n Transfer: ${amount} USDC ${toPerp ? 'Spot → Perps' : 'Perps → Spot'}`); + + // usdClassTransfer is user-signed: the nonce is embedded in the action. + const nonce = hlNonce(); + const { action, primaryType, signTypes } = buildUsdClassTransferAction({ amount, toPerp, nonce }); + const eip712 = userSignedEip712(primaryType, signTypes, action); + + await buildScreenSignSubmit(apiInstance, { action, nonce, eip712 }, ctx); + log(''); + return undefined; + }, + + 'approve-builder-fee': async (args, apiInstance, flags, options) => { + // One-time onboarding: authorize Nansen's builder fee so orders route with + // the builder code. order/close auto-fire this on the first trade; this + // command lets a client approve up front. No-op when already approved. + const walletName = scalar(options.wallet, 'wallet'); + const wallet = resolveWalletAddress(walletName); + const ctx = await resolveSigningCtx(wallet, log); + + const builderStatus = await fetchBuilderFee(apiInstance, wallet.address); + if (builderStatus.approved) { + log(`\n Builder fee already approved for ${wallet.address}\n`); + return undefined; + } + + log(`\n Approve builder fee: ${wallet.address}`); + await ensureBuilderApproved(apiInstance, builderStatus, ctx); + log(''); + return undefined; + }, + + 'positions': async (args, apiInstance, flags, options) => { + const walletName = scalar(options.wallet, 'wallet'); + const wallet = resolveWalletAddress(walletName); + + const result = await perpRead(apiInstance, 'positions', { wallet_address: wallet.address }); + const positions = result.positions || []; + + if (!positions.length) { + log('\n No open positions\n'); + return undefined; + } + + log(`\n Open Positions (${positions.length}):`); + for (const p of positions) { + const side = parseFloat(p.szi) >= 0 ? 'LONG' : 'SHORT'; + log(` ${p.coin} ${side} size=${p.szi} entry=${p.entryPx} pnl=${p.unrealizedPnl} liq=${p.liquidationPx || 'n/a'}`); + } + log(''); + return undefined; + }, + + 'orders': async (args, apiInstance, flags, options) => { + const walletName = scalar(options.wallet, 'wallet'); + const wallet = resolveWalletAddress(walletName); + + const result = await perpRead(apiInstance, 'orders', { wallet_address: wallet.address }); + const orders = result.orders || []; + + if (!orders.length) { + log('\n No open orders\n'); + return undefined; + } + + log(`\n Open Orders (${orders.length}):`); + for (const o of orders) { + log(` ${o.coin} ${o.side} size=${o.sz} price=${o.limitPx} oid=${o.oid}`); + } + log(''); + return undefined; + }, + + 'account': async (args, apiInstance, flags, options) => { + const walletName = scalar(options.wallet, 'wallet'); + const wallet = resolveWalletAddress(walletName); + + const result = await perpRead(apiInstance, 'account', { wallet_address: wallet.address }); + const ms = result.marginSummary || {}; + + // Sum per-position unrealized PnL. marginSummary.totalRawUsd is the account's + // total raw USD (≈ collateral / account value), NOT profit-and-loss — labeling + // it "Total PnL" made it read identical to account value (ECINT-6828). + const unrealizedPnl = (result.assetPositions || []).reduce( + (sum, p) => sum + (parseFloat(p.position?.unrealizedPnl) || 0), + 0, + ); + + log(`\n Hyperliquid Account: ${wallet.address}`); + log(` Account Value: $${ms.accountValue || '0'}`); + log(` Unrealized PnL: $${unrealizedPnl.toFixed(2)}`); + log(` Margin Used: $${ms.totalMarginUsed || '0'}`); + log(` Withdrawable: $${result.withdrawable || '0'}`); + // Spot balance is separate from Perps: USDC sent via Hyperliquid "Send" + // lands here and can't be traded until moved with `perp transfer`. + log(` Spot USDC: $${result.spotUsdc ?? 'n/a'}`); + log(''); + return undefined; + }, + + 'meta': async (args, apiInstance, flags, options) => { + const result = await perpRead(apiInstance, 'meta', {}); + let assets = result.assets || []; + + const filter = String(scalar(options.filter, 'filter') ?? '').toUpperCase(); + if (filter) { + assets = assets.filter(a => String(a.name).toUpperCase().includes(filter)); + } + // Default to a preview; --all or --filter shows the full (matching) set so + // assets past the first 20 (e.g. HYPE) are reachable from the CLI. + const showAll = flags.all || !!filter; + const shown = showAll ? assets : assets.slice(0, 20); + + const heading = filter ? ` matching "${options.filter}"` : ''; + log(`\n Hyperliquid Perp Assets (${assets.length}${heading}):`); + log(' ID Name Size Dec Max Lev'); + for (const a of shown) { + const id = String(a.asset_id).padStart(4); + const name = a.name.padEnd(12); + const szDec = String(a.sz_decimals).padStart(8); + const maxLev = String(a.max_leverage).padStart(9); + log(` ${id} ${name} ${szDec} ${maxLev}`); + } + if (!showAll && assets.length > 20) { + log(` ... and ${assets.length - 20} more (use --all, or --filter )`); + } + log(''); + return undefined; + }, + }; +} diff --git a/src/rpc-urls.js b/src/rpc-urls.js index b91f1c40..1ce161b0 100644 --- a/src/rpc-urls.js +++ b/src/rpc-urls.js @@ -19,17 +19,24 @@ * working while new code uses the standardised NANSEN_BASE_RPC name. */ -const DEFAULT_EVM_RPC = 'https://eth.public-rpc.com'; -const DEFAULT_BASE_RPC = 'https://mainnet.base.org'; -const DEFAULT_BSC_RPC = 'https://bsc-dataseed.binance.org'; -const DEFAULT_XLAYER_RPC = 'https://rpc.xlayer.tech'; -const DEFAULT_SOLANA_RPC = 'https://api.mainnet-beta.solana.com'; +const DEFAULT_EVM_RPC = 'https://eth.public-rpc.com'; +const DEFAULT_BASE_RPC = 'https://mainnet.base.org'; +const DEFAULT_BSC_RPC = 'https://bsc-dataseed.binance.org'; +const DEFAULT_XLAYER_RPC = 'https://rpc.xlayer.tech'; +const DEFAULT_SOLANA_RPC = 'https://api.mainnet-beta.solana.com'; +const DEFAULT_ARBITRUM_RPC = 'https://arb1.arbitrum.io/rpc'; +const DEFAULT_POLYGON_RPC = 'https://polygon-rpc.com'; +const DEFAULT_BNB_RPC = 'https://bsc-dataseed.bnbchain.org'; +// `bsc` (x402.js) and `bnb` (bridge/perp) are both chain 56 — both keys are read. export const CHAIN_RPCS = { - ethereum: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC, - evm: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC, // generic EVM fallback - base: process.env.NANSEN_BASE_RPC || process.env.NANSEN_RPC_BASE || DEFAULT_BASE_RPC, - bsc: process.env.NANSEN_BSC_RPC || DEFAULT_BSC_RPC, - xlayer: process.env.NANSEN_XLAYER_RPC || DEFAULT_XLAYER_RPC, - solana: process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC, + ethereum: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC, + evm: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC, // generic EVM fallback + base: process.env.NANSEN_BASE_RPC || process.env.NANSEN_RPC_BASE || DEFAULT_BASE_RPC, + bsc: process.env.NANSEN_BSC_RPC || DEFAULT_BSC_RPC, + xlayer: process.env.NANSEN_XLAYER_RPC || DEFAULT_XLAYER_RPC, + solana: process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC, + arbitrum: process.env.NANSEN_ARBITRUM_RPC || DEFAULT_ARBITRUM_RPC, + polygon: process.env.NANSEN_POLYGON_RPC || DEFAULT_POLYGON_RPC, + bnb: process.env.NANSEN_BNB_RPC || DEFAULT_BNB_RPC, }; diff --git a/src/schema.json b/src/schema.json index 1431b5ef..d95c8c14 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1,5 +1,414 @@ { "commands": { + "perp": { + "description": "Hyperliquid perpetual trading commands", + "subcommands": { + "order": { + "submitsTo": "https://api.hyperliquid.xyz/exchange", + "apiEndpoints": [ + "/api/v1/perp/meta", + "/api/v1/perp/builder-fee", + "/api/v1/sanctions/screen" + ], + "description": "Place a perp order (limit or market, with optional take-profit/stop-loss)", + "options": { + "coin": { + "type": "string", + "required": true, + "description": "Asset symbol, e.g. BTC, ETH (alias: --symbol)" + }, + "side": { + "type": "string", + "required": true, + "enum": [ + "buy", + "long", + "sell", + "short" + ], + "description": "buy/long opens a long, sell/short opens a short" + }, + "size": { + "type": "string", + "required": true, + "description": "Position size in base asset units" + }, + "price": { + "type": "string", + "required": true, + "description": "Limit price (or mark price for market orders)" + }, + "type": { + "type": "string", + "default": "limit", + "enum": [ + "limit", + "market" + ], + "description": "Order type" + }, + "tif": { + "type": "string", + "default": "Gtc", + "enum": [ + "Gtc", + "Ioc", + "Alo" + ], + "description": "Time-in-force" + }, + "slippage": { + "type": "string", + "default": "0.03", + "description": "Slippage tolerance for market orders as a decimal in [0,1] (0.03 = 3%)" + }, + "take-profit": { + "type": "string", + "description": "Take-profit trigger price" + }, + "stop-loss": { + "type": "string", + "description": "Stop-loss trigger price" + }, + "wallet": { + "type": "string", + "description": "Wallet name (defaults to the configured default wallet)" + } + } + }, + "cancel": { + "submitsTo": "https://api.hyperliquid.xyz/exchange", + "apiEndpoints": [ + "/api/v1/perp/meta", + "/api/v1/sanctions/screen" + ], + "description": "Cancel an open order by order id", + "options": { + "coin": { + "type": "string", + "required": true, + "description": "Asset symbol (alias: --symbol)" + }, + "oid": { + "type": "string", + "required": true, + "description": "Order id to cancel" + }, + "wallet": { + "type": "string", + "description": "Wallet name" + } + } + }, + "close": { + "submitsTo": "https://api.hyperliquid.xyz/exchange", + "apiEndpoints": [ + "/api/v1/perp/positions", + "/api/v1/perp/meta", + "/api/v1/perp/builder-fee", + "/api/v1/sanctions/screen" + ], + "description": "Close a position (reduce-only market order)", + "options": { + "coin": { + "type": "string", + "required": true, + "description": "Asset symbol (alias: --symbol)" + }, + "size": { + "type": "string", + "required": true, + "description": "Size to close in base asset units" + }, + "price": { + "type": "string", + "required": true, + "description": "Mark price" + }, + "side": { + "type": "string", + "required": true, + "enum": [ + "buy", + "sell" + ], + "description": "sell closes a long, buy closes a short" + }, + "slippage": { + "type": "string", + "default": "0.03", + "description": "Slippage tolerance as a decimal in [0,1] (0.03 = 3%)" + }, + "wallet": { + "type": "string", + "description": "Wallet name" + } + } + }, + "leverage": { + "submitsTo": "https://api.hyperliquid.xyz/exchange", + "apiEndpoints": [ + "/api/v1/perp/meta", + "/api/v1/sanctions/screen" + ], + "description": "Set leverage and margin mode for an asset", + "options": { + "coin": { + "type": "string", + "required": true, + "description": "Asset symbol (alias: --symbol)" + }, + "leverage": { + "type": "string", + "required": true, + "description": "Leverage multiplier (positive integer, capped at the asset maximum)" + }, + "margin-type": { + "type": "string", + "default": "cross", + "enum": [ + "cross", + "isolated" + ], + "description": "Margin mode" + }, + "wallet": { + "type": "string", + "description": "Wallet name" + } + } + }, + "transfer": { + "submitsTo": "https://api.hyperliquid.xyz/exchange", + "apiEndpoints": [ + "/api/v1/sanctions/screen" + ], + "description": "Move USDC between a wallet's Spot and Perps balances", + "options": { + "direction": { + "type": "string", + "required": true, + "enum": [ + "spot-to-perp", + "perp-to-spot" + ], + "description": "Transfer direction" + }, + "amount": { + "type": "string", + "required": true, + "description": "USDC amount to transfer" + }, + "wallet": { + "type": "string", + "description": "Wallet name (defaults to the configured default wallet)" + } + } + }, + "approve-builder-fee": { + "submitsTo": "https://api.hyperliquid.xyz/exchange", + "apiEndpoints": [ + "/api/v1/perp/builder-fee", + "/api/v1/sanctions/screen" + ], + "description": "Authorize the Nansen builder fee (one-time; auto-fired on the first order/close)", + "options": { + "wallet": { + "type": "string", + "description": "Wallet name (defaults to the configured default wallet)" + } + } + }, + "positions": { + "endpoint": "/api/v1/perp/positions", + "description": "View open positions", + "options": { + "wallet": { + "type": "string", + "description": "Wallet name" + } + } + }, + "orders": { + "endpoint": "/api/v1/perp/orders", + "description": "View open/resting orders", + "options": { + "wallet": { + "type": "string", + "description": "Wallet name" + } + } + }, + "account": { + "endpoint": "/api/v1/perp/account", + "description": "View account state (account value, unrealized PnL, margin, withdrawable)", + "options": { + "wallet": { + "type": "string", + "description": "Wallet name" + } + } + }, + "meta": { + "endpoint": "/api/v1/perp/meta", + "description": "View available perp assets (id, size decimals, max leverage)", + "options": { + "filter": { + "type": "string", + "description": "Filter assets by name substring (also shows the full matching set)" + }, + "all": { + "type": "boolean", + "description": "Show all assets instead of the first 20" + } + } + } + }, + "notes": "Signed locally and submitted directly to Hyperliquid. apiEndpoints lists the Nansen API routes each command reads (compliance screening, market metadata, builder-fee status)." + }, + "bridge": { + "description": "Move funds between Base and Hyperliquid", + "notes": "Supported routes: base -> hyperliquid (deposit); hyperliquid -> base, hyperliquid -> ethereum, hyperliquid -> arbitrum (withdrawal). Any other combination is rejected. Deposits broadcast an EVM transaction from the local wallet, so they require a locally signable origin chain; withdrawals sign a Hyperliquid action and never transact on the destination chain.", + "routes": [ + { + "origin": "base", + "destination": "hyperliquid", + "direction": "deposit" + }, + { + "origin": "hyperliquid", + "destination": "base", + "direction": "withdrawal" + }, + { + "origin": "hyperliquid", + "destination": "ethereum", + "direction": "withdrawal" + }, + { + "origin": "hyperliquid", + "destination": "arbitrum", + "direction": "withdrawal" + } + ], + "subcommands": { + "quote": { + "endpoint": "/api/v1/perp/bridge/quote", + "description": "Get a bridge quote and cache it locally for execute (quotes expire after 1 hour)", + "options": { + "from-chain": { + "type": "string", + "required": true, + "enum": [ + "base", + "hyperliquid" + ], + "description": "Source chain (alias: --from)" + }, + "to-chain": { + "type": "string", + "required": true, + "enum": [ + "hyperliquid", + "base", + "ethereum", + "arbitrum" + ], + "description": "Destination chain (alias: --to)" + }, + "from-token": { + "type": "string", + "required": true, + "description": "Source token symbol (USDC) or address (alias: --token)" + }, + "to-token": { + "type": "string", + "default": "USDC", + "description": "Destination token symbol or address" + }, + "amount": { + "type": "string", + "required": true, + "description": "Amount in base units by default. USDC is 6 decimals on EVM chains but 8 on Hyperliquid, so prefer --amount-unit to avoid the per-chain magnitude trap." + }, + "amount-unit": { + "type": "string", + "enum": [ + "token", + "usd" + ], + "description": "Interpret --amount as a human token amount or a USD amount. Omit for base units." + }, + "slippage": { + "type": "number", + "default": 50, + "description": "Slippage in whole basis points, 0-10000 (50 = 0.5%)" + }, + "recipient": { + "type": "string", + "description": "Destination wallet address (defaults to the signing wallet)" + }, + "wallet": { + "type": "string", + "description": "Wallet name (defaults to the configured default wallet)" + } + }, + "prerequisites": [ + "A wallet must be configured. Run: nansen wallet create" + ] + }, + "execute": { + "endpoint": "/api/v1/perp/bridge/execute", + "apiEndpoints": [ + "/api/v1/perp/bridge/execute", + "/api/v1/perp/bridge/status", + "/api/v1/sanctions/screen" + ], + "description": "Execute a cached bridge quote. Screens the signing wallet, signs, submits, then polls to completion.", + "notes": "The wallet must match the one the quote was created for. A deposit broadcasts its EVM transaction straight to a public RPC rather than through the Nansen API; only the Hyperliquid signature legs are proxied. Quotes are single-use. If a deposit transaction is stuck in the mempool, replace it by requesting a fresh quote and executing it with --nonce set to the stuck nonce and a higher --priority-fee; a replacement must reuse the nonce and outbid the original by roughly 10%.", + "options": { + "quote": { + "type": "string", + "required": true, + "description": "Quote ID returned by bridge quote" + }, + "wallet": { + "type": "string", + "description": "Wallet name (defaults to the configured default wallet)" + }, + "priority-fee": { + "type": "string", + "description": "Priority fee in gwei, overriding the quoted one (EVM deposit legs only)" + }, + "max-fee": { + "type": "string", + "description": "Fee cap in gwei, overriding the computed one (EVM deposit legs only)" + }, + "nonce": { + "type": "string", + "description": "Sign at this nonce instead of the next one, to replace a stuck transaction (EVM deposit legs only)" + } + }, + "prerequisites": [ + "A wallet must be configured. Run: nansen wallet create" + ] + }, + "status": { + "endpoint": "/api/v1/perp/bridge/status", + "description": "Check the status of a bridge transfer", + "options": { + "request-id": { + "type": "string", + "description": "Bridge request ID (required unless --tx-hash is given)" + }, + "tx-hash": { + "type": "string", + "description": "Source chain transaction hash (required unless --request-id is given)" + } + } + } + } + }, "research": { "description": "Research and analytics commands", "subcommands": { @@ -936,7 +1345,7 @@ "description": "Reference date for label and pricing resolution (YYYY-MM-DD)" }, "block-timestamp": { - "description": "Block timestamp (YYYY-MM-DD HH:MM:SS) \u2014 skips slow hash-resolution step if provided" + "description": "Block timestamp (YYYY-MM-DD HH:MM:SS) — skips slow hash-resolution step if provided" }, "chain": { "default": "ethereum", @@ -965,7 +1374,7 @@ } }, "alerts": { - "description": "Smart alert management \u2014 create, update, toggle, delete alerts", + "description": "Smart alert management — create, update, toggle, delete alerts", "subcommands": { "list": { "description": "List all alerts", @@ -1116,7 +1525,7 @@ }, "to-chain": { "type": "string", - "description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain. At least one side must be USDC or a native token (ETH, SOL). Non-native to non-native is not supported \u2014 swap to USDC first, then bridge. Bridge providers (Li.Fi or Relay) are selected automatically based on best price. Sub-dollar swaps are supported via Relay." + "description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain. At least one side must be USDC or a native token (ETH, SOL). Non-native to non-native is not supported — swap to USDC first, then bridge. Bridge providers (Li.Fi or Relay) are selected automatically based on best price. Sub-dollar swaps are supported via Relay." }, "from": { "type": "string", @@ -1139,7 +1548,7 @@ }, "wallet": { "type": "string", - "description": "Wallet name (or \"walletconnect\"/\"wc\" for WalletConnect, EVM only). A configured wallet is required \u2014 run `nansen wallet create` if you haven't set one up yet." + "description": "Wallet name (or \"walletconnect\"/\"wc\" for WalletConnect, EVM only). A configured wallet is required — run `nansen wallet create` if you haven't set one up yet." }, "to-wallet": { "type": "string", @@ -1192,7 +1601,7 @@ }, "aggregator": { "type": "string", - "description": "lifi or relay. Overrides auto-detection \u2014 use when polling from a different machine or after the 30-day local record TTL has expired." + "description": "lifi or relay. Overrides auto-detection — use when polling from a different machine or after the 30-day local record TTL has expired." } } }, @@ -1424,7 +1833,7 @@ } }, "agent": { - "description": "Nansen AI research agent \u2014 ask questions about wallets, tokens, and on-chain activity", + "description": "Nansen AI research agent — ask questions about wallets, tokens, and on-chain activity", "options": { "expert": { "type": "boolean", diff --git a/src/trading.js b/src/trading.js index 7c09acef..1b7694ff 100644 --- a/src/trading.js +++ b/src/trading.js @@ -80,7 +80,7 @@ export function resolveTokenAddress(symbolOrAddress, chainName) { * @returns {Promise<*>} Parsed result value * @throws {Error} If chain has no configured RPC or the RPC returns an error */ -async function evmRpcCall(chain, method, params = []) { +export async function evmRpcCall(chain, method, params = []) { const rpcUrl = CHAIN_RPCS[chain]; if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`); const res = await fetch(rpcUrl, { @@ -99,13 +99,13 @@ async function evmRpcCall(chain, method, params = []) { return body.result; } -function getQuotesDir() { +export function getQuotesDir() { const configDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen'); return path.join(configDir, 'quotes'); } // Resolve a filename inside the quotes dir, rejecting path traversal. -function safeQuotesPath(filename) { +export function safeQuotesPath(filename) { const base = path.resolve(getQuotesDir()); const target = path.resolve(base, filename); if (path.relative(base, target).startsWith('..')) return null; @@ -390,7 +390,7 @@ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalle const hash = crypto.randomBytes(4).toString('hex'); const quoteId = `${timestamp}-${hash}`; - const data = { quoteId, chain, timestamp, signerType, response: quoteResponse }; + const data = { quoteId, type: 'swap', chain, timestamp, signerType, response: quoteResponse }; if (toChain) data.toChain = toChain; if (privyWalletIds) data.privyWalletIds = privyWalletIds; @@ -412,6 +412,11 @@ export function loadQuote(quoteId) { fs.unlinkSync(filePath); throw new Error('Quote has expired. Please request a new quote.'); } + // Guard against running a bridge quote through the swap path. Older swap + // quotes predate the `type` field, so only reject a known-mismatched type. + if (data.type && data.type !== 'swap') { + throw new Error(`Quote "${quoteId}" is a ${data.type} quote. Use the matching command (e.g. "nansen bridge execute" for a bridge quote).`); + } return data; } @@ -498,25 +503,29 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) { * { to, data, value?, gas?, gasPrice? } * * The nonce must be fetched from the chain RPC. - * Signs as a legacy (type 0) transaction with gasPrice (matching the e2e tests). * - * @param {object} txData - Transaction fields from quote.transaction { to, data, value, gas, gasPrice } + * Emits an EIP-1559 (type 2) transaction when the quote supplies fee-cap + * fields, and a legacy (type 0) one otherwise. Quotes from the trading API and + * from Relay both carry maxFeePerGas/maxPriorityFeePerGas, so type 2 is the + * normal path; flattening those into a single legacy gasPrice — as this used to + * do — discards the fee cap the aggregator computed and leaves the transaction + * unincludable the moment the base fee rises past it. + * + * @param {object} txData - Transaction fields from a quote { to, data, value, gas, gasPrice | maxFeePerGas + maxPriorityFeePerGas } * @param {string} privateKeyHex - 64-char hex (32-byte secp256k1 private key) * @param {string} chain - Chain name * @param {number} nonce - Account nonce * @returns {string} 0x-prefixed signed transaction hex */ // ⚠️ SECURITY: EVM transaction signing - requires thorough review before production use -// TODO: Always signs as legacy (type 0) transactions. Do we need EIP-1559 (type 2) support? export function signEvmTransaction(txData, privateKeyHex, chain, nonce) { const chainConfig = CHAIN_MAP[chain]; if (!chainConfig || chainConfig.type !== 'evm') { throw new Error(`Unsupported EVM chain: ${chain}`); } - const tx = { + const common = { nonce, - gasPrice: toHex(txData.gasPrice || txData.maxFeePerGas || '1'), gasLimit: toHex(txData.gas || txData.gasLimit || '210000'), to: txData.to, value: toHex(txData.value || '0'), @@ -524,18 +533,82 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) { chainId: chainConfig.chainId, }; - return signLegacyTransaction(tx, privateKeyHex); + if (txData.maxFeePerGas) { + return signEip1559Transaction({ + ...common, + maxFeePerGas: toHex(txData.maxFeePerGas), + // A zero priority fee is a valid choice but not a sane default, so fall + // back to the fee cap rather than to nothing when the quote omits it. + maxPriorityFeePerGas: toHex(txData.maxPriorityFeePerGas || txData.maxFeePerGas), + }, privateKeyHex); + } + + // Previously this fell back to a gasPrice of 1 wei, which signs a transaction + // that can never be mined and burns the nonce. Refuse instead: a quote with no + // fee information at all is a bug upstream, not something to sign through. + if (!txData.gasPrice) { + throw new Error( + 'Quote supplied no gas price (expected gasPrice or maxFeePerGas), so any signed transaction would be unmineable. Refusing to sign.', + ); + } + + return signLegacyTransaction({ ...common, gasPrice: toHex(txData.gasPrice) }, privateKeyHex); } +// How many queued-but-unmined transactions we are willing to sign past. +// +// `pending` counts mempool-queued transactions as well as mined ones, and that is +// what callers want: the bridge signs its approve and deposit steps back to back, +// so the second has to be numbered after the first while the first is still +// pending. But a transaction that *cannot* be mined — priced below what the chain +// is currently including — keeps the count elevated for as long as it sits there, +// and every later signature is numbered behind it, unexecutable until it clears. +// +// One or two in flight is normal for a multi-step run. Beyond that, something is +// wedged, and adding another transaction to the queue cannot help. +const MAX_PENDING_NONCE_GAP = 2; + /** - * Fetch the pending nonce for an EVM address. + * Fetch the next nonce for an EVM address, reconciled against the mined count. + * + * Returns a DECIMAL number, not a hex string — callers must not decode it again. + * (bridge.js did, and `parseInt(20, 16)` is 32: a wallet at nonce 20 signed at + * 32, which no node can execute. It only showed up past nonce 9, where decimal + * and hex digits diverge.) + * * @param {string} chain - Chain name * @param {string} address - 0x address - * @returns {Promise} Nonce + * @returns {Promise} Next nonce, decimal */ export async function getEvmNonce(chain, address) { - const result = await evmRpcCall(chain, 'eth_getTransactionCount', [address, 'pending']); - return parseInt(result, 16); + const [pendingHex, latestHex] = await Promise.all([ + evmRpcCall(chain, 'eth_getTransactionCount', [address, 'pending']), + evmRpcCall(chain, 'eth_getTransactionCount', [address, 'latest']), + ]); + const pending = parseInt(pendingHex, 16); + const latest = parseInt(latestHex, 16); + if (!Number.isInteger(pending) || !Number.isInteger(latest)) { + throw new Error( + `Could not read the nonce for ${address} on ${chain} (pending: ${pendingHex}, latest: ${latestHex}).`, + ); + } + + const gap = pending - latest; + if (gap > MAX_PENDING_NONCE_GAP) { + // Refuse rather than pile on. Signing at `pending` here produces a + // transaction that cannot execute until everything ahead of it does, and the + // symptom the operator sees is only "no receipt" — no indication that the + // real problem is a transaction from an earlier run. + throw new Error( + `${address} has ${gap} unmined transactions queued on ${chain} (next mined nonce ${latest}, next pending ${pending}). ` + + `Signing another would queue behind them and stay unexecutable until they clear. ` + + `Replace the transaction at nonce ${latest} with a higher fee first: request a fresh quote and run ` + + `"nansen bridge execute --quote --nonce ${latest} --priority-fee ". ` + + `Note that a load-balanced public RPC may deny holding a transaction it does in fact hold, so do not diagnose from one endpoint.`, + ); + } + + return pending; } /** @@ -544,11 +617,18 @@ export async function getEvmNonce(chain, address) { * * @param {string} chain - Chain name * @param {string} txHash - Transaction hash (0x...) - * @param {number} [timeoutMs=30000] - Max wait time + * The default window is deliberately generous: by the time this is called the + * transaction is already broadcast, so giving up early converts "still + * confirming" into a hard failure the caller has to interpret, without undoing + * anything. A tight 30s window did exactly that during a real Base deposit. + * + * @param {string} chain - Chain name + * @param {string} txHash - Transaction hash (0x...) + * @param {number} [timeoutMs=180000] - Max wait time * @param {number} [pollMs=2000] - Poll interval * @returns {Promise} Transaction receipt */ -export async function waitForReceipt(chain, txHash, timeoutMs = 30000, pollMs = 2000) { +export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs = 2000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { @@ -741,6 +821,49 @@ export function signLegacyTransaction(tx, privateKeyHex) { return '0x' + rlpEncode(signedFields).toString('hex'); } +/** + * Sign an EIP-1559 (type 2) transaction. + * + * Envelope: 0x02 || RLP([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, + * gasLimit, to, value, data, accessList, yParity, r, s]). + * + * Type 2 exists here because a legacy transaction pays exactly `gasPrice`: once + * the base fee rises above it the transaction is not slow, it is permanently + * unincludable at that nonce. A type-2 transaction pays baseFee + priority + * capped at maxFeePerGas, so it rides fee movement instead of dying. + * + * Note yParity is the raw recovery bit (0/1), not EIP-155's chainId*2+35+bit — + * the chain id is already a first-class field in the payload. + */ +export function signEip1559Transaction(tx, privateKeyHex) { + const payloadFields = [ + rlpNormalize(tx.chainId), + rlpNormalize(tx.nonce), + rlpNormalize(tx.maxPriorityFeePerGas), + rlpNormalize(tx.maxFeePerGas), + rlpNormalize(tx.gasLimit), + toBuffer(tx.to), + rlpNormalize(tx.value), + toBuffer(tx.data || '0x'), + [], // accessList — always empty; we never build access-listed transactions + ]; + + const msgHash = keccak256(Buffer.concat([Buffer.from([0x02]), rlpEncode(payloadFields)])); + const { r, s, v: recoveryBit } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex')); + + const signed = Buffer.concat([ + Buffer.from([0x02]), + rlpEncode([ + ...payloadFields, + rlpNormalize(recoveryBit), + stripLeadingZeros(r), + stripLeadingZeros(s), + ]), + ]); + + return '0x' + signed.toString('hex'); +} + export function toBuffer(v) { if (Buffer.isBuffer(v)) return v; if (typeof v === 'string') { @@ -1074,6 +1197,19 @@ export function buildTradingCommands(deps = {}) { 'INVALID_AGGREGATOR' ); } + // Slippage is a decimal fraction (0.03 = 3%). Reject non-numeric or + // out-of-range values so a percent-vs-decimal mix-up (e.g. "3" meaning 3%) + // can't become a 300% slippage tolerance. + for (const [optName, optVal] of [['slippage', slippage], ['max-auto-slippage', maxAutoSlippage]]) { + if (optVal == null) continue; + const n = Number(optVal); + if (!Number.isFinite(n) || n < 0 || n > 1) { + throw new CommandError( + `Invalid --${optName} "${optVal}". Use a decimal between 0 and 1 (e.g. 0.03 for 3%).`, + 'INVALID_SLIPPAGE' + ); + } + } if (!chain || !from || !to || !amount) { throw new CommandError(` @@ -1399,7 +1535,16 @@ EXAMPLES: } // --quote-index pins a specific quote (no fallback) - const pinIndex = options['quote-index'] != null ? parseInt(options['quote-index'], 10) : null; + let pinIndex = null; + if (options['quote-index'] != null) { + pinIndex = parseInt(options['quote-index'], 10); + if (!Number.isInteger(pinIndex) || pinIndex < 0 || pinIndex >= allQuotes.length) { + throw new CommandError( + `❌ Invalid --quote-index "${options['quote-index']}". Must be an integer between 0 and ${allQuotes.length - 1}.`, + 'INVALID_QUOTE_INDEX', + ); + } + } const startIndex = pinIndex ?? 0; const endIndex = pinIndex != null ? startIndex + 1 : allQuotes.length; diff --git a/src/wallet-signing.js b/src/wallet-signing.js new file mode 100644 index 00000000..50b56130 --- /dev/null +++ b/src/wallet-signing.js @@ -0,0 +1,87 @@ +/** + * Nansen CLI — shared wallet resolution for the money paths (perp, bridge). + * + * Every command that signs needs the same three things: the wallet's EVM + * address, a clear error when the wallet is encrypted but no password reached + * us, and the signing material (a local private key, or a Privy handle). + * + * These lived twice — once in perp.js, once in bridge.js — and the copies had + * already drifted: perp.js grew an explicit PASSWORD_REQUIRED error while + * bridge.js kept calling exportWallet(name, null), which reports "Incorrect + * password" for a password that was never entered. One copy means the next fix + * can't land on one path and miss the other. + */ + +import { CommandError } from './api.js'; +import { retrievePassword } from './keychain.js'; +import { exportWallet, getWalletConfig, showWallet } from './wallet.js'; + +const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +// Resolve --wallet (or the configured default) to a wallet with a usable EVM +// address. `context` names the feature in the error so the message stays +// actionable ("Hyperliquid perp trading requires an EVM wallet"). +// +// Returns the resolved name alongside the address so callers can hand the same +// resolution to resolveSigningCredentials instead of looking the wallet up a +// second time — a second lookup can read a different wallet if the default +// changed in between, and it re-reads the wallet file for no reason. +export function resolveEvmWallet(walletName, context = 'This command') { + const config = getWalletConfig(); + const name = walletName || config.defaultWallet; + const wallet = name ? showWallet(name) : undefined; + if (!wallet) { + throw new CommandError('No wallet found. Create one with: nansen wallet create', 'NO_WALLET'); + } + if (!wallet.evm || !EVM_ADDRESS_RE.test(wallet.evm)) { + throw new CommandError( + `Wallet "${wallet.name || name}" has no valid EVM address. ${context} requires an EVM wallet.`, + 'INVALID_WALLET', + ); + } + return { + name: wallet.name || name, + address: wallet.evm, + provider: wallet.provider || 'local', + privyWalletIds: wallet.privyWalletIds || null, + }; +} + +// The local private key for an already-resolved wallet. +export function resolvePrivateKey(wallet) { + const config = getWalletConfig(); + let password = null; + if (config.passwordHash) { + const { password: pw, source } = retrievePassword(); + if (source === 'file') { + process.stderr.write('⚠️ Password loaded from ~/.nansen/wallets/.credentials (insecure).\n'); + } + password = pw; + // Distinguish "no password reached us" from "wrong password": without this, + // exportWallet(name, null) fails with the misleading "Incorrect password" + // even though nothing was entered. Mirror trade/limit-order's + // PASSWORD_REQUIRED. + if (!password) { + throw new CommandError('Wallet is encrypted and no password was found.', 'PASSWORD_REQUIRED', { + error: 'PASSWORD_REQUIRED', + message: 'Wallet is encrypted and no password was found.', + resolution: [ + 'Set NANSEN_WALLET_PASSWORD environment variable', + 'Or run: nansen wallet create (password is saved to OS keychain automatically)', + ], + }); + } + } + const exported = exportWallet(wallet.name, password); + return exported.evm.privateKey; +} + +// Signing material for an already-resolved wallet. Privy wallets have no +// exportable key — the caller signs through the Privy client instead, using the +// privyWalletIds already on `wallet`. +export function resolveSigningCredentials(wallet) { + if (wallet.provider === 'privy') { + return { provider: 'privy', privateKey: null }; + } + return { provider: 'local', privateKey: resolvePrivateKey(wallet) }; +}