diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7169c9f..dfe7384 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - - name: Verify the live testnet lifecycle (Relay mocked) + - name: Verify the testnet lifecycle (Relay and Hyperliquid orders mocked) run: .github/scripts/testnet-run.sh - name: Record testnet coverage and outcome if: always() diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index b7b7359..33e4b58 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -39,7 +39,7 @@ For **testnet**, use an explicitly supported test origin chain/token and its `originDecimals` from `agent-cli schema fund`. Chain 42161 and the default origin token are mainnet values; do not send real funds to a testnet example. Relay HyperCore testnet routes may be unavailable. If no route is known, stop funding. -The CI lifecycle harness uses mocked Relay and pre-funded test collateral. +The CI lifecycle harness uses mocked Relay and mocked Hyperliquid orders. Show the returned `qrAscii`, deposit address, origin chain/token, minimum, fees, expected receipt, and expiry. The user transfers only the quoted origin token. diff --git a/apps/docs/content/docs/skill.mdx b/apps/docs/content/docs/skill.mdx index e2f9f7b..d45d2e2 100644 --- a/apps/docs/content/docs/skill.mdx +++ b/apps/docs/content/docs/skill.mdx @@ -46,7 +46,7 @@ Use `--network testnet` during testing; never silently change to mainnet. origin chain/token, minimum, fees, expected received amount and expiry. On testnet, do not treat the default Arbitrum mainnet chain/token as a test route: use an explicitly supported test chain/token and `originDecimals`, or the - credential-gated CI harness with mocked Relay and pre-funded test collateral. + credential-gated CI harness with mocked Relay and mocked Hyperliquid orders. If no test route is known, stop funding and report the missing route. The user transfers the quoted origin token. If `signatureSteps` require signatures, show the exact payload and submit each via `deposit` in returned order. @@ -164,8 +164,9 @@ Offline skill tests validate the documented command examples against CLI schemas Mocked lifecycle tests exercise resumable approvals, pending funding, events and failure handling. These checks do **not** prove a live agent or exchange run. The nightly testnet lifecycle uses only a company-owned disposable -CI master account and separate test Privy app, real Hyperliquid testnet and Privy, -and an explicitly mocked Relay route backed by already funded test collateral. +CI master account and separate test Privy app, live Privy and Hyperliquid +approvals/revocation, and explicitly mocked Relay plus mocked Hyperliquid orders +that never reach the exchange. Test USDC pre-funding is not required for that job. The same harness supports a caller-controlled external wallet approval adapter on a clean QA runner; its private key stays in that wallet. It never transfers real funds. Missing CI credentials fail the job; a skipped or diff --git a/apps/docs/public/SKILL.md b/apps/docs/public/SKILL.md index 7a7d5f0..6a6ee1e 100644 --- a/apps/docs/public/SKILL.md +++ b/apps/docs/public/SKILL.md @@ -32,7 +32,7 @@ Use `--network testnet` during testing; never silently change to mainnet. origin chain/token, minimum, fees, expected received amount and expiry. On testnet, do not treat the default Arbitrum mainnet chain/token as a test route: use an explicitly supported test chain/token and `originDecimals`, or the - credential-gated CI harness with mocked Relay and pre-funded test collateral. + credential-gated CI harness with mocked Relay and mocked Hyperliquid orders. If no test route is known, stop funding and report the missing route. The user transfers the quoted origin token. If `signatureSteps` require signatures, show the exact payload and submit each via `deposit` in returned order. @@ -150,8 +150,9 @@ Offline skill tests validate the documented command examples against CLI schemas Mocked lifecycle tests exercise resumable approvals, pending funding, events and failure handling. These checks do **not** prove a live agent or exchange run. The nightly testnet lifecycle uses only a company-owned disposable -CI master account and separate test Privy app, real Hyperliquid testnet and Privy, -and an explicitly mocked Relay route backed by already funded test collateral. +CI master account and separate test Privy app, live Privy and Hyperliquid +approvals/revocation, and explicitly mocked Relay plus mocked Hyperliquid orders +that never reach the exchange. Test USDC pre-funding is not required for that job. The same harness supports a caller-controlled external wallet approval adapter on a clean QA runner; its private key stays in that wallet. It never transfers real funds. Missing CI credentials fail the job; a skipped or diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 63c1dfa..15c74a1 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -19,7 +19,7 @@ Node 22.12 minimum. | #29–#32 | Locked sequence journal across independent writers/restarts, replay/types/follow, exact fill fee units; log filtering/redaction; runner heartbeat/staleness and deposit polling; mocked-clock expiry blocking and once-per-threshold warnings. | | #33–#36 | New approval verified before signer handoff/old revocation, resumable rotation and immediate local revocation tests; fsynced cross-process hash-chain with intent/decision/signature digest/response/fill linkage; tamper/redaction tests and linked full-lifecycle records. CLI/RPC pre-schema hex/mnemonic detector and threat model. | | #37–#38 | Skill source/frontmatter/error-catalog tests and every documented command input parsed through actual CLI schemas offline; user-owned Privy, ordered external approvals, bounded polling, exchange reconciliation and supported-test-route guidance; install/custody README, generated command reference and static Pages site. The skill deliverable is complete; the unproven coding-agent testnet session is tracked explicitly in #39. | -| #39 | Offline CLI/SDK/RPC lifecycle plus fifteen-stage testnet harness with external wallet approval adapter or separate CI-only disposable signer; exact prices, correlated events, post-rotation trading, partial-rotation cleanup and zero orders/agents/positions verification. Schema 2 reports/CI summaries validate stage and service evidence; Relay remains mocked. Missing configuration fails explicitly. Nightly live evidence and the independent coding-agent skill session remain pending. | +| #39 | Offline CLI/SDK/RPC lifecycle plus fifteen-stage testnet harness with external wallet approval adapter or separate CI-only disposable signer; exact prices, correlated events, post-rotation trading, partial-rotation cleanup and zero orders/agents/positions verification. Schema 2 reports/CI summaries validate stage and service evidence; Relay and Hyperliquid orders are mocked and blocked from the exchange. Missing configuration fails explicitly. Nightly live evidence and the independent coding-agent skill session remain pending. | | #40–#42 | Script-free packed global installation and installed bunx smoke, executable bin, license and matching skill versions; Node 22.12/24 install CI; Linux/macOS ARM/x64 native binary smoke; changesets/tagged-main/version/token-publish/asset pipeline with candidate-only manual runs. Publishing and public registry verification await the `BLOXWAP_PUBLISH` gate. | | #49 | Token guard rejects declarations anywhere in formatted/minified CSS. Unit regression and a mutation of global.css prove docs:check rejects the issue's exact same-line copy. | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a290644..14ca939 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -41,7 +41,7 @@ | #36 | done | Pre-schema recursive CLI/RPC secret detector, BIP39-like seed rejection, private authorization refs and env/log/error scrubbing; threat model docs. | | #37 | done | Skill content, packaged/embedded installation, schema-validated examples and public /SKILL.md hosting are complete. Frontmatter, gotchas, error handling, discovery links and source parity are verified. The unverified live coding-agent testnet criterion is explicitly transferred to #39. | | #38 | done | Install/testnet README, custody architecture, schema-generated command reference and static docs site with gated Pages workflow. | -| #39 | partial | Fifteen-stage testnet harness supports external wallet signing and separate CI-only disposable keys, exact exchange reconciliation, correlated WebSocket/follow/replay events, post-rotation trading, audit verification, partial-rotation cleanup and zero orders/agents/positions checks. Schema 2 reports and CI summaries require evidence. Offline regressions pass; nightly live journals and the independent coding-agent skill session transferred from #37 still require dedicated QA configuration and evidence. | +| #39 | partial | Fifteen-stage testnet harness supports external wallet signing and separate CI-only disposable keys, exact price reconciliation, correlated WebSocket/follow/replay events, post-rotation trading, audit verification, partial-rotation cleanup and zero orders/agents/positions checks. Hyperliquid order placement/modify/cancel (and the order-book/order-update responses those stages use) are explicitly mocked and blocked from the exchange; approvals, account reads and Privy stay live. Schema 2 reports and CI summaries require evidence and record order coverage as mocked. Offline regressions pass; nightly journals and the independent coding-agent skill session transferred from #37 still require dedicated QA configuration and evidence. Test USDC pre-funding is no longer required. | | #40 | partial | Script-free tarball global install and actual installed bunx help/schema smoke pass locally; executable/skill/license checks and Node 22.12/24 macOS/Linux CI. Public registry installation awaits publication. | | #41 | partial | Validated Changesets patch workflow with optional version PR, native binary smoke/checksums, tagged-main version gates, exact tested tarball publication and registry verification. Publish runs on the Mac mini fleet with the organization `NPM_TOKEN` (no OIDC trusted publishing or automatic provenance). Manual candidates cannot publish. Actual release needs the publishing gate; organization policy blocks automatic version PRs, so the default workflow uploads a reviewable patch. | | #42 | done | Final packaged flow audited and corrected (network/config isolation, exact loss/exposure/receipt checks, trigger preservation, readiness and stream errors). Skill shipped/embedded in npm and compiled CLI; Claude/Codex installation with CODEX_HOME, version synchronization, idempotency and overwrite protection. | @@ -80,9 +80,11 @@ This is release preparation; public npm installation is still pending. #39 now supports caller-controlled external wallet approvals alongside the separate disposable CI signer, with fifteen evidence-checked stages, correlated -events and interrupted-rotation cleanup. Runner shutdown is confirmed before -journal export. Build, lint, typecheck, 229 offline CLI tests, docs checks and the -static docs build pass. Nightly CI maps `AGENT_CLI_TEST_MASTER_KEY`, `AGENT_CLI_PRIVY_APP_ID` and +events and interrupted-rotation cleanup. Hyperliquid order placement, modification +and cancellation never reach the exchange: the preload mocks those endpoints and +fails closed if an order request escapes. 30 test USDC pre-funding is not required. +Runner shutdown is confirmed before journal export. Nightly CI maps +`AGENT_CLI_TEST_MASTER_KEY`, `AGENT_CLI_PRIVY_APP_ID` and `AGENT_CLI_PRIVY_APP_SECRET` from the `testnet` environment onto the harness `BLOXWAP_*` variables. Live journals and the independent coding-agent session are still not claimed. diff --git a/docs/issues/39.md b/docs/issues/39.md index ae0b12a..8eb10d4 100644 --- a/docs/issues/39.md +++ b/docs/issues/39.md @@ -22,9 +22,11 @@ the user's master key never enters the CLI or harness. The dedicated CI signer is a separate test-only mode. See [setup and protocol](../testnet.md). Fifteen stages reconcile both approvals, settlement, runner readiness, exact -exchange prices, correlated open/canceled events and replay, post-rotation -trading, final revocation and audit integrity. Cleanup tracks both run-owned -agents before approvals and verifies no remaining orders, agents or positions. +prices, correlated open/canceled events and replay, post-rotation trading, +final revocation and audit integrity. Order placement, modification and +cancellation are mocked and blocked from the exchange; approvals, account +reads and Privy stay live. Cleanup tracks both run-owned agents before +approvals and verifies no remaining live orders, agents or positions. Schema 2 reports and CI summaries reject incomplete evidence and preserve the separate, unverified coding-agent session requirement. diff --git a/docs/testnet.md b/docs/testnet.md index 3c630a6..b534c6b 100644 --- a/docs/testnet.md +++ b/docs/testnet.md @@ -82,32 +82,40 @@ not a production CLI key-import feature. Missing secrets fail the nightly job. ## Account preparation and coverage -Pre-fund the dedicated Hyperliquid **testnet** account with at least 30 test USDC. -It must have no positions, open orders or leftover agent approvals. Use a clean +The dedicated Hyperliquid **testnet** account must have no positions, open orders +or leftover agent approvals. 30 test USDC pre-funding is **not** required: the +harness never places, modifies or cancels orders on the exchange. Use a clean OS account or container: the harness refuses existing `~/.bloxwap` state, and never changes the CLI's state location. Do not use a mainnet account or real funds. -Hyperliquid testnet and Privy calls are real. Relay is explicitly mocked at the -testnet URL: the quote has zero expected output and settlement relies on existing -test collateral. No token transfer or bridge execution is claimed. The preload -blocks mainnet Hyperliquid and Relay HTTP endpoints in CLI child processes. +Privy and Hyperliquid agent approvals/revocation stay live, as do account info +reads (balances, positions, open orders, extra agents) and non-order WebSocket +subscriptions. Order placement, modification and cancellation — including the +order-book info and order-update WebSocket responses those stages reconcile +against — are explicitly mocked, the same way Relay is mocked. No order request +is forwarded to Hyperliquid. Fund-moving exchange actions (withdrawals, transfers +and similar) are blocked, not executed. Relay is mocked at the testnet URL: the +quote has zero expected output and no token transfer or bridge execution is +claimed. The preload blocks mainnet Hyperliquid and Relay HTTP endpoints and +fails loudly if a Hyperliquid order request escapes to the live exchange. Fifteen stages verify both init approvals, funding QR/settlement, runner signing -and readiness, resting post-only orders, exact exchange prices after modification, +and readiness, mocked resting post-only orders, exact prices after modification, cancellation, correlated open/canceled WebSocket events, cursor replay, signer -rotation and old-agent revocation, an order/cancel with the new signer, final -revocation, audit integrity and streamed-event delivery. `ok:true` alone never -counts as exchange acceptance. Orders use BTC with a 50 test USD notional/position +rotation and old-agent revocation, a mocked order/cancel with the new signer, +final revocation, audit integrity and streamed-event delivery. `ok:true` alone +never counts as acceptance. Orders use BTC with a 50 test USD notional/position cap and a two-open-order limit; the suite does not request fills. Cleanup records both agent addresses/names before approvals so an interrupted -rotation can revoke either or both. It cancels orders before revocation and -verifies zero orders, approved agents and positions. Unknown approvals are left -untouched and make cleanup fail. Existing local revocation is never bypassed to -cancel orders. Failed cleanup requires owner review of the dedicated test account. -A forced job termination can interrupt cleanup, so inspect timed-out runs too. -The harness also confirms runner process exit and removal of its PID/socket -before exporting journals; a shutdown timeout fails the report. +rotation can revoke either or both. It cancels harness-owned mocked orders +before revocation and verifies the live account still has zero orders, approved +agents and positions. Unknown approvals are left untouched and make cleanup fail. +Existing local revocation is never bypassed to cancel orders. Failed cleanup +requires owner review of the dedicated test account. A forced job termination +can interrupt cleanup, so inspect timed-out runs too. The harness also confirms +runner process exit and removal of its PID/socket before exporting journals; a +shutdown timeout fails the report. ## Evidence diff --git a/packages/agent-cli/SKILL.md b/packages/agent-cli/SKILL.md index 7a7d5f0..6a6ee1e 100644 --- a/packages/agent-cli/SKILL.md +++ b/packages/agent-cli/SKILL.md @@ -32,7 +32,7 @@ Use `--network testnet` during testing; never silently change to mainnet. origin chain/token, minimum, fees, expected received amount and expiry. On testnet, do not treat the default Arbitrum mainnet chain/token as a test route: use an explicitly supported test chain/token and `originDecimals`, or the - credential-gated CI harness with mocked Relay and pre-funded test collateral. + credential-gated CI harness with mocked Relay and mocked Hyperliquid orders. If no test route is known, stop funding and report the missing route. The user transfers the quoted origin token. If `signatureSteps` require signatures, show the exact payload and submit each via `deposit` in returned order. @@ -150,8 +150,9 @@ Offline skill tests validate the documented command examples against CLI schemas Mocked lifecycle tests exercise resumable approvals, pending funding, events and failure handling. These checks do **not** prove a live agent or exchange run. The nightly testnet lifecycle uses only a company-owned disposable -CI master account and separate test Privy app, real Hyperliquid testnet and Privy, -and an explicitly mocked Relay route backed by already funded test collateral. +CI master account and separate test Privy app, live Privy and Hyperliquid +approvals/revocation, and explicitly mocked Relay plus mocked Hyperliquid orders +that never reach the exchange. Test USDC pre-funding is not required for that job. The same harness supports a caller-controlled external wallet approval adapter on a clean QA runner; its private key stays in that wallet. It never transfers real funds. Missing CI credentials fail the job; a skipped or diff --git a/packages/agent-cli/scripts/e2e-hl-orders.mjs b/packages/agent-cli/scripts/e2e-hl-orders.mjs new file mode 100644 index 0000000..552a455 --- /dev/null +++ b/packages/agent-cli/scripts/e2e-hl-orders.mjs @@ -0,0 +1,305 @@ +import { + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const mockOrdersEnv = 'BLOXWAP_E2E_MOCK_HL_ORDERS'; +const liveExchangeActions = new Set(['approveAgent', 'approveBuilderFee']); +const mockedExchangeActions = new Set([ + 'order', + 'cancel', + 'cancelByCloid', + 'batchModify', + 'modify', + 'updateLeverage', + 'updateIsolatedMargin', + 'scheduleCancel', + 'twapOrder', + 'twapCancel', +]); +const orderInfoTypes = new Set(['frontendOpenOrders', 'openOrders']); +const mainnetHosts = new Set([ + 'api.hyperliquid.xyz', + 'rpc.hyperliquid.xyz', + 'api.relay.link', +]); +const testnetHosts = new Set([ + 'api.hyperliquid-testnet.xyz', + 'rpc.hyperliquid-testnet.xyz', +]); +const universe = ['BTC']; +const orderSockets = new Set(); +const home = () => process.env.HOME || homedir(); +const mockFile = () => join(home(), '.bloxwap', 'e2e-hl-orders.json'); + +export function mockHyperliquidOrdersEnabled(env = process.env) { + return env[mockOrdersEnv] === '1'; +} + +export function hyperliquidHostname(url) { + return new URL(url instanceof Request ? url.url : String(url)).hostname; +} + +export function isMainnetHarnessHost(hostname) { + return mainnetHosts.has(hostname); +} + +export function isHyperliquidTestnetHost(hostname) { + return testnetHosts.has(hostname); +} + +export function isHyperliquidTestnetWs(url) { + const target = new URL(String(url)); + return ( + (target.protocol === 'ws:' || target.protocol === 'wss:') && + isHyperliquidTestnetHost(target.hostname) + ); +} + +export async function readRequestJson(url, init) { + const text = + url instanceof Request + ? await url.clone().text() + : init?.body == null + ? '' + : String(init.body); + return text ? JSON.parse(text) : {}; +} + +function loadOrders() { + try { + const state = JSON.parse(readFileSync(mockFile(), 'utf8')); + if ( + Number.isSafeInteger(state.nextOid) && + state.nextOid > 0 && + Array.isArray(state.orders) + ) + return state; + } catch { + // A missing or unreadable mock book starts empty; never consult the exchange. + } + return { nextOid: 1, orders: [] }; +} + +function saveOrders(state) { + const directory = join(home(), '.bloxwap'); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const target = mockFile(); + const temporary = `${target}.${process.pid}.tmp`; + writeFileSync(temporary, JSON.stringify(state), { mode: 0o600 }); + renameSync(temporary, target); +} + +export function rememberUniverse(meta) { + if (!Array.isArray(meta?.universe)) return; + for (const [index, market] of meta.universe.entries()) { + if (market?.name) universe[index] = market.name; + } +} + +function coinForAsset(asset) { + return universe[asset] ?? (asset === 0 ? 'BTC' : `ASSET_${asset}`); +} + +function assertSignedExchange(body) { + const signature = body?.signature; + if ( + !Number.isSafeInteger(body?.nonce) || + body.nonce < 0 || + typeof signature?.r !== 'string' || + typeof signature?.s !== 'string' || + ![0, 1, 27, 28].includes(signature.v) + ) + throw new Error( + 'Unsigned Hyperliquid order request blocked by the CI test harness', + ); +} + +function exchangeOk(type, statuses) { + return { + status: 'ok', + response: { type, data: { statuses } }, + }; +} + +function orderUpdate(order, status) { + return { + order: { + coin: order.coin, + side: order.side, + limitPx: order.limitPx, + sz: order.sz, + oid: order.oid, + timestamp: Date.now(), + origSz: order.origSz ?? order.sz, + cloid: order.cloid, + }, + status, + statusTimestamp: Date.now(), + }; +} + +function resting(order) { + const cloid = typeof order.c === 'string' ? order.c : undefined; + return { + coin: coinForAsset(order.a), + side: order.b ? 'B' : 'A', + limitPx: String(order.p), + sz: String(order.s), + oid: 0, + cloid, + reduceOnly: Boolean(order.r), + tif: order.t?.limit?.tif ?? 'Gtc', + origSz: String(order.s), + }; +} + +function applyMockedAction(action) { + const state = loadOrders(); + const updates = []; + const type = action?.type; + if (type === 'order') { + const statuses = []; + for (const order of action.orders ?? []) { + const placed = { ...resting(order), oid: state.nextOid++ }; + state.orders.push(placed); + statuses.push({ resting: { oid: placed.oid } }); + updates.push(orderUpdate(placed, 'open')); + } + if (!statuses.length) + throw new Error('Mocked Hyperliquid order request had no orders'); + saveOrders(state); + return { response: exchangeOk('order', statuses), updates }; + } + if (type === 'cancel' || type === 'cancelByCloid') { + const requests = action.cancels ?? action.cancelsByCloid ?? []; + const statuses = []; + for (const request of requests) { + const index = state.orders.findIndex((order) => + type === 'cancelByCloid' + ? order.cloid === request.c && + (request.a === undefined || order.coin === coinForAsset(request.a)) + : order.oid === request.o, + ); + if (index < 0) { + statuses.push({ error: 'Order was never placed on the mocked book' }); + continue; + } + const [removed] = state.orders.splice(index, 1); + statuses.push('success'); + updates.push(orderUpdate(removed, 'canceled')); + } + saveOrders(state); + return { response: exchangeOk('cancel', statuses), updates }; + } + if (type === 'batchModify' || type === 'modify') { + const modifies = action.modifies ?? (action.oid ? [action] : []); + const statuses = []; + for (const request of modifies) { + const index = state.orders.findIndex((order) => + typeof request.oid === 'string' + ? order.cloid === request.oid + : order.oid === request.oid, + ); + if (index < 0 || !request.order) { + statuses.push({ error: 'Order was never placed on the mocked book' }); + continue; + } + const next = { + ...state.orders[index], + ...resting(request.order), + oid: state.orders[index].oid, + cloid: state.orders[index].cloid, + }; + state.orders[index] = next; + statuses.push({ resting: { oid: next.oid } }); + } + saveOrders(state); + return { response: exchangeOk('order', statuses), updates }; + } + saveOrders(state); + return { response: { status: 'ok', response: { type: 'default' } }, updates }; +} + +export function blockedOrderMessage(kind = 'order') { + return `Hyperliquid ${kind} request blocked by the CI test harness; order endpoints must not reach the exchange`; +} + +export function inspectExchangeAction(action) { + const type = action?.type; + if (liveExchangeActions.has(type)) return 'live'; + if (mockedExchangeActions.has(type)) return 'order'; + return 'forbidden'; +} + +export function mockExchangeAction(body) { + const decision = inspectExchangeAction(body?.action); + if (decision === 'live') return null; + if (decision === 'forbidden') + throw new Error( + `Unexpected Hyperliquid exchange action ${body?.action?.type ?? 'unknown'} blocked by the CI test harness`, + ); + if (!mockHyperliquidOrdersEnabled()) + throw new Error(blockedOrderMessage(body.action.type)); + assertSignedExchange(body); + return applyMockedAction(body.action); +} + +export function mockOpenOrders(type) { + return orderInfoTypes.has(type) && mockHyperliquidOrdersEnabled() + ? loadOrders().orders + : null; +} + +export function registerOrderSocket(socket) { + orderSockets.add(socket); + const cleanup = () => orderSockets.delete(socket); + if (typeof socket.once === 'function') socket.once('close', cleanup); + else if (typeof socket.addEventListener === 'function') + socket.addEventListener('close', cleanup, { once: true }); +} + +export function emitOrderUpdates(updates) { + if (!updates?.length) return; + const frame = JSON.stringify({ channel: 'orderUpdates', data: updates }); + for (const socket of orderSockets) { + if (socket.readyState !== 1) continue; + socket.emit('message', frame, false); + } +} + +export function inspectWebsocketPayload(data) { + let parsed; + try { + parsed = JSON.parse(String(data)); + } catch { + return 'pass'; + } + if (parsed?.method !== 'post' || parsed.request?.type !== 'action') + return 'pass'; + const action = parsed.request.payload?.action ?? parsed.request.payload; + const decision = inspectExchangeAction(action); + if (decision === 'live') return 'pass'; + if (decision === 'forbidden') + throw new Error( + `Unexpected Hyperliquid WebSocket action ${action?.type ?? 'unknown'} blocked by the CI test harness`, + ); + if (!mockHyperliquidOrdersEnabled()) + throw new Error(blockedOrderMessage(action?.type ?? 'order')); + assertSignedExchange(parsed.request.payload ?? parsed.request); + return applyMockedAction(action); +} + +export function websocketPostResponse(id, response) { + return { + channel: 'post', + data: { + id, + response: { type: 'action', payload: response }, + }, + }; +} diff --git a/packages/agent-cli/scripts/e2e-relay-hook.mjs b/packages/agent-cli/scripts/e2e-relay-hook.mjs index afab35a..a37a7be 100644 --- a/packages/agent-cli/scripts/e2e-relay-hook.mjs +++ b/packages/agent-cli/scripts/e2e-relay-hook.mjs @@ -1,13 +1,33 @@ // Loaded only by the explicit testnet e2e harness, never by the packaged CLI. +import { createRequire } from 'node:module'; +import { + emitOrderUpdates, + hyperliquidHostname, + inspectWebsocketPayload, + isHyperliquidTestnetHost, + isHyperliquidTestnetWs, + isMainnetHarnessHost, + mockExchangeAction, + mockOpenOrders, + readRequestJson, + registerOrderSocket, + rememberUniverse, + websocketPostResponse, +} from './e2e-hl-orders.mjs'; + const originalFetch = globalThis.fetch; globalThis.fetch = async (url, init) => { - const target = new URL(url instanceof Request ? url.url : String(url)); - if (['api.hyperliquid.xyz', 'api.relay.link'].includes(target.hostname)) + const hostname = hyperliquidHostname(url); + if (isMainnetHarnessHost(hostname)) throw new Error('Mainnet requests are forbidden in the CI test harness'); + const target = new URL(url instanceof Request ? url.url : String(url)); if (target.origin === 'https://api.testnets.relay.link') { if (target.pathname.startsWith('/quote/')) { - const quote = JSON.parse(url instanceof Request ? await url.clone().text() : String(init?.body)); - if (quote.originChainId !== 421614 || quote.originCurrency !== '0x' + '0'.repeat(40)) + const quote = await readRequestJson(url, init); + if ( + quote.originChainId !== 421614 || + quote.originCurrency !== '0x' + '0'.repeat(40) + ) throw new Error('Expected the explicit test-only Relay fixture route'); return Response.json({ steps: [ @@ -30,5 +50,70 @@ globalThis.fetch = async (url, init) => { return Response.json({ status: 'success' }); throw new Error('Unexpected Relay mock endpoint'); } + if (isHyperliquidTestnetHost(hostname)) { + const body = await readRequestJson(url, init); + if (target.pathname === '/exchange' || target.pathname.endsWith('/exchange')) { + const mocked = mockExchangeAction(body); + if (mocked) { + emitOrderUpdates(mocked.updates); + return Response.json(mocked.response); + } + } else if (target.pathname === '/info' || target.pathname.endsWith('/info')) { + const orders = mockOpenOrders(body.type); + if (orders) return Response.json(orders); + const response = await originalFetch(url, init); + if (body.type === 'meta' || body.type === 'metaAndAssetCtxs') { + try { + rememberUniverse(await response.clone().json()); + } catch { + // Live metadata remains authoritative even if the clone cannot be parsed. + } + } + return response; + } + } return originalFetch(url, init); }; + +const require = createRequire(import.meta.url); +try { + const wsPath = require.resolve('ws'); + const OriginalWebSocket = require(wsPath); + function HarnessWebSocket(url, protocols, options) { + const hostname = hyperliquidHostname(url); + if (isMainnetHarnessHost(hostname)) + throw new Error('Mainnet requests are forbidden in the CI test harness'); + const socket = new OriginalWebSocket(url, protocols, options); + if (isHyperliquidTestnetWs(url)) { + registerOrderSocket(socket); + const send = socket.send.bind(socket); + socket.send = (data, ...rest) => { + const intercepted = inspectWebsocketPayload(data); + if (intercepted === 'pass') return send(data, ...rest); + let id; + try { + id = JSON.parse(String(data)).id; + } catch { + id = undefined; + } + emitOrderUpdates(intercepted.updates); + queueMicrotask(() => { + if (socket.readyState === 1) + socket.emit( + 'message', + JSON.stringify(websocketPostResponse(id, intercepted.response)), + false, + ); + }); + }; + } + return socket; + } + Object.setPrototypeOf(HarnessWebSocket, OriginalWebSocket); + Object.assign(HarnessWebSocket, OriginalWebSocket); + HarnessWebSocket.WebSocket = HarnessWebSocket; + HarnessWebSocket.prototype = OriginalWebSocket.prototype; + require.cache[wsPath].exports = HarnessWebSocket; +} catch { + // The HTTP preload still blocks order endpoints if `ws` cannot be patched. +} diff --git a/packages/agent-cli/scripts/e2e-testnet.mjs b/packages/agent-cli/scripts/e2e-testnet.mjs index c8ad8fa..0abab6d 100644 --- a/packages/agent-cli/scripts/e2e-testnet.mjs +++ b/packages/agent-cli/scripts/e2e-testnet.mjs @@ -22,6 +22,8 @@ import { verifyTestnetReport } from './testnet-report.mjs'; import { startEventFollower } from './testnet-follower.mjs'; import { cleanupTestnetAccount } from './testnet-cleanup.mjs'; import { stopTestnetRunner } from './testnet-runner.mjs'; +import { mockOrdersEnv } from './e2e-hl-orders.mjs'; +import './e2e-relay-hook.mjs'; const directory = join(homedir(), '.bloxwap'); const config = join(directory, 'e2e-config.json'); @@ -46,10 +48,17 @@ const report = { accountOwnership: 'Dedicated disposable test account controlled by the caller; never a production wallet', coverage: { - hyperliquid: { mode: 'live testnet', verified: false }, + hyperliquid: { + mode: 'live testnet approvals, account info and non-order websocket', + verified: false, + }, + orders: { + mode: 'mocked placement, modification, cancellation and order websocket; exchange order endpoints blocked', + verified: false, + }, privy: { mode: 'live dedicated test app', verified: false }, relay: { - mode: 'mocked zero-value route; pre-funded collateral', + mode: 'mocked zero-value route; no token transfer', verified: false, }, }, @@ -167,6 +176,7 @@ try { ...process.env, BLOXWAP_MASTER_ADDRESS: signer.address, BLOXWAP_NETWORK: 'testnet', + [mockOrdersEnv]: '1', NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${JSON.stringify(hook)}`, }; delete childEnv.BLOXWAP_TEST_MASTER_KEY; @@ -188,6 +198,8 @@ try { if (stage.status === 'passed') { if (stage.name === 'test-account-preflight') report.coverage.hyperliquid.verified = true; + if (stage.name === 'place-and-reconcile-resting-order') + report.coverage.orders.verified = true; if (stage.name === 'init-agent-and-builder-approvals') report.coverage.privy.verified = true; if (stage.name === 'mock-relay-funding-and-settlement') @@ -276,5 +288,7 @@ try { // Even failed preflight emits a failure artifact and never becomes a skip. saveReport(); if (report.status === 'passed') - console.log('PASS: dedicated testnet lifecycle (Relay mocked; see report)'); + console.log( + 'PASS: dedicated testnet lifecycle (Relay and Hyperliquid orders mocked; see report)', + ); } diff --git a/packages/agent-cli/scripts/testnet-lifecycle.mjs b/packages/agent-cli/scripts/testnet-lifecycle.mjs index 867d9e0..31af66d 100644 --- a/packages/agent-cli/scripts/testnet-lifecycle.mjs +++ b/packages/agent-cli/scripts/testnet-lifecycle.mjs @@ -161,9 +161,10 @@ export async function testnetLifecycle({ } await stage('test-account-preflight', async () => { const state = await info('clearinghouseState', { user: masterAddress }); - assert.ok( - Number(state.withdrawable) >= 30, - 'Disposable account requires at least 30 test USDC; no real funding is performed', + assert.match( + String(state.withdrawable), + /^-?\d+(\.\d+)?$/, + 'Disposable account did not return a withdrawable balance', ); assert.ok( state.assetPositions.every( diff --git a/packages/agent-cli/scripts/testnet-report.mjs b/packages/agent-cli/scripts/testnet-report.mjs index ea4cd07..38118a5 100644 --- a/packages/agent-cli/scripts/testnet-report.mjs +++ b/packages/agent-cli/scripts/testnet-report.mjs @@ -30,12 +30,14 @@ const failureNames = new Set([ const stageStatuses = new Set(['running', 'passed', 'failed']); const cleanupStatuses = new Set(['not-started', 'failed', 'verified']); const services = Object.freeze([ - ['hyperliquid', 'Hyperliquid testnet'], + ['hyperliquid', 'Hyperliquid testnet approvals and account reads'], + ['orders', 'Hyperliquid orders (mocked)'], ['privy', 'Privy dedicated test app'], ['relay', 'Relay zero-value mock'], ]); const serviceEvidence = { hyperliquid: 'test-account-preflight', + orders: 'place-and-reconcile-resting-order', privy: 'init-agent-and-builder-approvals', relay: 'mock-relay-funding-and-settlement', }; @@ -163,7 +165,7 @@ function unprovenSummary() { '- Cleanup: **unverified**.', '- Independent agent session: **unverified**.', '', - 'Live Relay coverage and an independent AI-agent session are unproven.', + 'Live Relay coverage, live Hyperliquid order placement and an independent AI-agent session are unproven.', '', ].join('\n'); } @@ -201,7 +203,7 @@ export function formatTestnetSummary(report) { `- Cleanup: **${cleanup}**.`, '- Independent agent session: **not-run (unverified)**.', '', - 'Relay verification covers the zero-value mock only; live Relay remains unverified. A passing deterministic script does not verify an independent AI-agent session.', + 'Relay verification covers the zero-value mock only; live Relay remains unverified. Hyperliquid order placement, modification and cancellation are mocked and blocked from the exchange. A passing deterministic script does not verify an independent AI-agent session.', '', '| Lifecycle stage | Outcome |', '| --- | --- |', diff --git a/packages/agent-cli/src/core/skill-content.ts b/packages/agent-cli/src/core/skill-content.ts index ecd1f4d..72e7736 100644 --- a/packages/agent-cli/src/core/skill-content.ts +++ b/packages/agent-cli/src/core/skill-content.ts @@ -1,3 +1,3 @@ // Generated from SKILL.md and package.json. export const cliVersion="0.2.0"; -export const skillContent="---\nname: agent-cli\ndescription: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, funding, orders, event monitoring, rotation, and normalized error recovery.\nmetadata:\n version: \"0.2.0\"\n---\n\nUse `agent-cli` for the user's authorized Hyperliquid account operations. Obtain\ncommand schemas with `agent-cli schema COMMAND`; arguments can be flags or\n`--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one\nJSON object: `{ok:true,data}` or `{ok:false,code,message,retryable,details}`.\nLogs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines.\nUse `--network testnet` during testing; never silently change to mainnet.\n\n## Onboarding sequence\n\n1. The user creates and owns their own Privy account and app. Configure that\n app ID and a local secret reference, e.g. `config set privyAppId APP` and\n `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets the\n environment secret outside the conversation on their own machine or runner.\n Bloxwap does not host the app, receive credentials, or custody user keys.\n Privy manages the trading-agent key in the user's app; master-wallet approval\n always remains in the user's external wallet. The default Bloxwap builder is\n `0x71b09A08257078A4d642F7Dd7315e656c837329a`, with `f=10` (1 bp = 0.01%).\n Override with `config set builder.address` / `config set builder.fee` or\n `BLOXWAP_BUILDER_ADDRESS` / `BLOXWAP_BUILDER_FEE`.\n2. Run `init --master-address 0x... --network testnet`. Show the returned approval\n payload/deeplink/QR. The user signs in their master wallet; submit only the\n resulting signature with `init --signature 0x...`. Submit builder approval\n separately with `init --builder-signature 0x...` for the configured 0.01% default. Continue only after `approved:true`.\n3. Run `fund --amount 10 --origin-chain 42161`. Show `qrAscii`/deposit address,\n origin chain/token, minimum, fees, expected received amount and expiry. On\n testnet, do not treat the default Arbitrum mainnet chain/token as a test route:\n use an explicitly supported test chain/token and `originDecimals`, or the\n credential-gated CI harness with mocked Relay and pre-funded test collateral.\n If no test route is known, stop funding and report the missing route. The user\n transfers the quoted origin token. If `signatureSteps` require signatures,\n show the exact payload and submit each via `deposit` in returned order.\n4. Poll `status` until each active deposit is `settled`, for at most 12 polls\n or 5 minutes, whichever comes first. Start with 1-second delays, double to a\n 15-second cap, and stop with an explicit pending report if the budget expires.\n Settlement requires Relay success and Hyperliquid balance receipt. Stop on\n failed/refunded deposits and explain their status. Do not place orders while\n any deposit is awaiting funds or pending.\n5. Run `run --detach`, then monitor `events --follow --since SEQ`. Persist the\n last handled sequence; replay after reconnect. An unfilled or rejected order\n remains incomplete: do not claim the requested trade succeeded. A fill updates the trading\n plan, an error follows the catalog below, and expiry warnings prompt rotation\n using external master-wallet approval.\n\n## Completion and approval boundaries\n\nRead this skill and query schemas before acting. Keep a progress record with the\nnetwork, current stage, deposit IDs, last event sequence and order cloid/oid;\nrecord public IDs only. A missing user-owned Privy app, wallet signature, funding\nroute or settled balance is a blocked stage. Report the exact next action without\nrequesting any secret in chat or moving to the next stage. Repeat `init` to resume\npersisted approvals rather than creating another agent. An `ok:true` transport\nenvelope does not imply exchange acceptance: inspect `data.result` and reconcile\n`orders`/`events` after uncertain mutations. Never retry an order with a new cloid\nuntil the original intent has been reconciled.\n\nFor rotation, verify `swapped:true` before asking the external wallet to sign old\nagent revocation. Require `oldRevoked:true` before claiming rotation complete.\n`revoke` immediately disables local signing; only `exchangeRevoked:true` confirms\nthat the signed exchange revocation was submitted. Inspect `status` to verify the\nagent is absent from exchange approvals. Cancel outstanding orders before final\nrevocation, and report any cancellation or cleanup failure explicitly.\n\n## Commands and example envelopes\n\nExamples show representative `data`, not live balances or service promises.\nFull inputs/outputs are discoverable with `schema`; amounts/prices are strings.\n\n| Input | Example output |\n| --- | --- |\n| `--help` | `{\"ok\":true,\"data\":{\"commands\":[{\"name\":\"status\",\"description\":\"Read account status\"}],\"globalFlags\":[\"--json\",\"--config\",\"--network\",\"--pretty\"]}}` |\n| `schema order` | `{\"ok\":true,\"data\":{\"input\":{\"type\":\"object\"},\"output\":{\"oneOf\":[]}}}` (actual schemas include properties) |\n| `config get network` | `{\"ok\":true,\"data\":\"testnet\"}` |\n| `config set limits.maxLeverage 5` | `{\"ok\":true,\"data\":{\"version\":1,\"limits\":{\"maxLeverage\":5}}}` |\n| `config validate` | `{\"ok\":true,\"data\":{\"valid\":true,\"version\":1}}` |\n| `init --master-address 0x...` | `{\"ok\":true,\"data\":{\"approved\":false,\"requiresSignature\":true,\"approvals\":{\"agent\":{\"typedData\":{}}},\"qrAscii\":\"...\"}}` |\n| `fund --amount 10 --no-qr` | `{\"ok\":true,\"data\":{\"depositId\":\"...\",\"depositAddress\":\"0x...\",\"expectedReceived\":\"100000000\",\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"state\":\"awaiting_funds\",\"qrAscii\":null}}` |\n| `deposit --deposit-id ID` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[{\"id\":\"step:0\",\"requiresSignature\":true,\"payload\":{}}]}}` |\n| `deposit --deposit-id ID --step-id step:0 --signature 0x...` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[]}}` |\n| `status` | `{\"ok\":true,\"data\":{\"balance\":{\"accountValue\":\"100\"},\"agent\":{\"approved\":true,\"daysRemaining\":\"179\"},\"deposits\":[],\"policy\":{\"id\":\"...\",\"version\":\"2\"},\"runner\":{\"pid\":\"123\"}}}` |\n| `balance` | `{\"ok\":true,\"data\":{\"accountValue\":\"100\",\"accountValueUnits\":\"10000000000\",\"withdrawable\":\"96\",\"marginUsed\":\"4\"}}` |\n| `market --coin BTC` | `{\"ok\":true,\"data\":{\"coin\":\"BTC\",\"asset\":\"0\",\"szDecimals\":\"5\",\"maxLeverage\":\"40\",\"mid\":\"20000\"}}` |\n| `positions` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"size\":\"0.001\",\"entryPrice\":\"20000\",\"leverage\":\"5\",\"marginMode\":\"cross\"}]}` |\n| `orders` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"oid\":\"123\",\"sz\":\"0.001\",\"limitPx\":\"20000\",\"side\":\"B\"}]}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"buy\",\"size\":\"0.001\",\"price\":\"20000\",\"tif\":\"Gtc\",\"cloid\":\"0x11111111111111111111111111111111\"}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"resting\":{\"oid\":123}}]}}},\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"cloid\":\"0x...\"}}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"sell\",\"size\":\"0.001\",\"type\":\"market\",\"slippage\":0.01,\"reduceOnly\":true}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"filled\":{\"oid\":123}}]}}}}}` |\n| `cancel --oid 123` or `cancel --all` | `{\"ok\":true,\"data\":{\"cancelled\":[\"123\"],\"result\":{\"status\":\"ok\"}}}` |\n| `modify --json '{\"modifications\":[{\"oid\":123,\"price\":\"20100\",\"size\":\"0.001\"}]}'` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `leverage --coin BTC --leverage 5 --mode isolated` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `run --detach` | `{\"ok\":true,\"data\":{\"pid\":123,\"startedAt\":1700000000000,\"network\":\"testnet\"}}` |\n| `events --since 12 --types '[\"fill\"]'` | `{\"ok\":true,\"data\":[{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}]}` |\n| `events --follow` | `{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}` (one line per event) |\n| `logs --follow --level warn` | `{\"level\":\"warn\",\"requestId\":\"...\",\"message\":\"Risk rejected\",\"time\":\"...\"}` (JSON lines) |\n| `rotate` then `rotate --signature 0x...` | `{\"ok\":true,\"data\":{\"oldAgent\":\"0x...\",\"newAgent\":\"0x...\",\"swapped\":true,\"oldRevoked\":false,\"revokeApproval\":{}}}` |\n| `rotate --revoke-signature 0x...` | `{\"ok\":true,\"data\":{\"swapped\":true,\"oldRevoked\":true}}` |\n| `revoke --cancel-orders` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":false,\"requiresSignature\":true,\"approval\":{}}}` |\n| `revoke --signature 0x...` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":true}}` |\n| `audit --intent ID` | `{\"ok\":true,\"data\":[{\"intentId\":\"ID\",\"stage\":\"decision\",\"data\":{\"allowed\":true},\"previousHash\":\"...\",\"hash\":\"...\"}]}` |\n| `skill install --target codex` | `{\"ok\":true,\"data\":{\"target\":\"codex\",\"path\":\".../agent-cli/SKILL.md\",\"version\":\"0.2.0\"}}` |\n\nBefore an order, query `market --coin BTC` for `mid`, `szDecimals`, asset and\nmarket leverage ceiling. Compute size/notional with decimal strings/bigints,\nusing the user-approved limit or the bounded IOC execution price. A valid schema\nalone does not establish that an order is tradable or within the budget.\n\nUse `--intent-id ID --intent \"reason\"` on trading actions to connect the user's\nreasoning to the audit. Keep cloid stable when retrying a known order intent.\nFor TP/SL use `type:\"tp\"|\"sl\"` and `triggerPrice` with size/price/reduceOnly;\nmarket orders use an explicit slippage-bounded IOC, not an unbounded market order.\n\n## Error recovery\n\n| Code | Action |\n| --- | --- |\n| RATE_LIMIT, NETWORK_ERROR | At most 3 read retries with exponential backoff; for uncertain mutations, reconcile order state/cloid before retrying. Stop and report unresolved results |\n| NONCE_COLLISION | Retry using a newly issued nonce; inspect a stale lock if persistent |\n| DEPOSIT_PENDING | Poll status until settled; stop on terminal funding failure |\n| RUNNER_NOT_RUNNING | Start runner or use configured direct fallback |\n| INSUFFICIENT_MARGIN | Reduce exposure or request authorized funding |\n| POLICY_REJECTED | Stop and revise intent within limits; do not bypass policy |\n| AGENT_EXPIRED | Rotate with master-wallet approval |\n| AGENT_NOT_APPROVED | Complete init/approval or explain revocation |\n| INVALID_SIZE, INVALID_PRICE | Correct input using market precision |\n| UNKNOWN_MARKET | Refresh metadata; use an allowed market |\n| ORDER_NOT_FOUND | Refresh orders before changing intent |\n| CONFIG_INVALID | Correct configuration/input; never insert secrets |\n| UPSTREAM_ERROR, INTERNAL | Stop, inspect redacted details/logs and report failure |\n\n## Gotchas\n\nHyperCore perps USDC uses **8 decimals**; EVM USDC uses **6**. Preserve strings and\nbigints. Some deposits require **two distinct signatures**; resume stored steps\nrather than restarting the route. Agent expiry is at most **180 days**. Never\nask for, paste, log or import a master private key or seed. The managed agent is\ntrade-only; local limits include markets, size/notional, leverage, daily loss and\nopen orders. Privy sees an L1 action hash, so it cannot independently inspect\nnotional/action contents; Hyperliquid agent permissions and local checks enforce\nthose boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`.\n\n\n## Verification boundaries\n\nOffline skill tests validate the documented command examples against CLI schemas.\nMocked lifecycle tests exercise resumable approvals, pending funding, events\nand failure handling. These checks do **not** prove a live agent or\nexchange run. The nightly testnet lifecycle uses only a company-owned disposable\nCI master account and separate test Privy app, real Hyperliquid testnet and Privy,\nand an explicitly mocked Relay route backed by already funded test collateral.\nThe same harness supports a caller-controlled external wallet approval adapter\non a clean QA runner; its private key stays in that wallet. It never transfers\nreal funds. Missing CI credentials fail the job; a skipped or\nmocked test must never be reported as live validation. Record the workflow run,\ncommit, lifecycle report, event and audit artifacts before claiming a testnet pass.\nSee `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup.\n"; +export const skillContent="---\nname: agent-cli\ndescription: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, funding, orders, event monitoring, rotation, and normalized error recovery.\nmetadata:\n version: \"0.2.0\"\n---\n\nUse `agent-cli` for the user's authorized Hyperliquid account operations. Obtain\ncommand schemas with `agent-cli schema COMMAND`; arguments can be flags or\n`--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one\nJSON object: `{ok:true,data}` or `{ok:false,code,message,retryable,details}`.\nLogs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines.\nUse `--network testnet` during testing; never silently change to mainnet.\n\n## Onboarding sequence\n\n1. The user creates and owns their own Privy account and app. Configure that\n app ID and a local secret reference, e.g. `config set privyAppId APP` and\n `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets the\n environment secret outside the conversation on their own machine or runner.\n Bloxwap does not host the app, receive credentials, or custody user keys.\n Privy manages the trading-agent key in the user's app; master-wallet approval\n always remains in the user's external wallet. The default Bloxwap builder is\n `0x71b09A08257078A4d642F7Dd7315e656c837329a`, with `f=10` (1 bp = 0.01%).\n Override with `config set builder.address` / `config set builder.fee` or\n `BLOXWAP_BUILDER_ADDRESS` / `BLOXWAP_BUILDER_FEE`.\n2. Run `init --master-address 0x... --network testnet`. Show the returned approval\n payload/deeplink/QR. The user signs in their master wallet; submit only the\n resulting signature with `init --signature 0x...`. Submit builder approval\n separately with `init --builder-signature 0x...` for the configured 0.01% default. Continue only after `approved:true`.\n3. Run `fund --amount 10 --origin-chain 42161`. Show `qrAscii`/deposit address,\n origin chain/token, minimum, fees, expected received amount and expiry. On\n testnet, do not treat the default Arbitrum mainnet chain/token as a test route:\n use an explicitly supported test chain/token and `originDecimals`, or the\n credential-gated CI harness with mocked Relay and mocked Hyperliquid orders.\n If no test route is known, stop funding and report the missing route. The user\n transfers the quoted origin token. If `signatureSteps` require signatures,\n show the exact payload and submit each via `deposit` in returned order.\n4. Poll `status` until each active deposit is `settled`, for at most 12 polls\n or 5 minutes, whichever comes first. Start with 1-second delays, double to a\n 15-second cap, and stop with an explicit pending report if the budget expires.\n Settlement requires Relay success and Hyperliquid balance receipt. Stop on\n failed/refunded deposits and explain their status. Do not place orders while\n any deposit is awaiting funds or pending.\n5. Run `run --detach`, then monitor `events --follow --since SEQ`. Persist the\n last handled sequence; replay after reconnect. An unfilled or rejected order\n remains incomplete: do not claim the requested trade succeeded. A fill updates the trading\n plan, an error follows the catalog below, and expiry warnings prompt rotation\n using external master-wallet approval.\n\n## Completion and approval boundaries\n\nRead this skill and query schemas before acting. Keep a progress record with the\nnetwork, current stage, deposit IDs, last event sequence and order cloid/oid;\nrecord public IDs only. A missing user-owned Privy app, wallet signature, funding\nroute or settled balance is a blocked stage. Report the exact next action without\nrequesting any secret in chat or moving to the next stage. Repeat `init` to resume\npersisted approvals rather than creating another agent. An `ok:true` transport\nenvelope does not imply exchange acceptance: inspect `data.result` and reconcile\n`orders`/`events` after uncertain mutations. Never retry an order with a new cloid\nuntil the original intent has been reconciled.\n\nFor rotation, verify `swapped:true` before asking the external wallet to sign old\nagent revocation. Require `oldRevoked:true` before claiming rotation complete.\n`revoke` immediately disables local signing; only `exchangeRevoked:true` confirms\nthat the signed exchange revocation was submitted. Inspect `status` to verify the\nagent is absent from exchange approvals. Cancel outstanding orders before final\nrevocation, and report any cancellation or cleanup failure explicitly.\n\n## Commands and example envelopes\n\nExamples show representative `data`, not live balances or service promises.\nFull inputs/outputs are discoverable with `schema`; amounts/prices are strings.\n\n| Input | Example output |\n| --- | --- |\n| `--help` | `{\"ok\":true,\"data\":{\"commands\":[{\"name\":\"status\",\"description\":\"Read account status\"}],\"globalFlags\":[\"--json\",\"--config\",\"--network\",\"--pretty\"]}}` |\n| `schema order` | `{\"ok\":true,\"data\":{\"input\":{\"type\":\"object\"},\"output\":{\"oneOf\":[]}}}` (actual schemas include properties) |\n| `config get network` | `{\"ok\":true,\"data\":\"testnet\"}` |\n| `config set limits.maxLeverage 5` | `{\"ok\":true,\"data\":{\"version\":1,\"limits\":{\"maxLeverage\":5}}}` |\n| `config validate` | `{\"ok\":true,\"data\":{\"valid\":true,\"version\":1}}` |\n| `init --master-address 0x...` | `{\"ok\":true,\"data\":{\"approved\":false,\"requiresSignature\":true,\"approvals\":{\"agent\":{\"typedData\":{}}},\"qrAscii\":\"...\"}}` |\n| `fund --amount 10 --no-qr` | `{\"ok\":true,\"data\":{\"depositId\":\"...\",\"depositAddress\":\"0x...\",\"expectedReceived\":\"100000000\",\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"state\":\"awaiting_funds\",\"qrAscii\":null}}` |\n| `deposit --deposit-id ID` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[{\"id\":\"step:0\",\"requiresSignature\":true,\"payload\":{}}]}}` |\n| `deposit --deposit-id ID --step-id step:0 --signature 0x...` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[]}}` |\n| `status` | `{\"ok\":true,\"data\":{\"balance\":{\"accountValue\":\"100\"},\"agent\":{\"approved\":true,\"daysRemaining\":\"179\"},\"deposits\":[],\"policy\":{\"id\":\"...\",\"version\":\"2\"},\"runner\":{\"pid\":\"123\"}}}` |\n| `balance` | `{\"ok\":true,\"data\":{\"accountValue\":\"100\",\"accountValueUnits\":\"10000000000\",\"withdrawable\":\"96\",\"marginUsed\":\"4\"}}` |\n| `market --coin BTC` | `{\"ok\":true,\"data\":{\"coin\":\"BTC\",\"asset\":\"0\",\"szDecimals\":\"5\",\"maxLeverage\":\"40\",\"mid\":\"20000\"}}` |\n| `positions` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"size\":\"0.001\",\"entryPrice\":\"20000\",\"leverage\":\"5\",\"marginMode\":\"cross\"}]}` |\n| `orders` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"oid\":\"123\",\"sz\":\"0.001\",\"limitPx\":\"20000\",\"side\":\"B\"}]}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"buy\",\"size\":\"0.001\",\"price\":\"20000\",\"tif\":\"Gtc\",\"cloid\":\"0x11111111111111111111111111111111\"}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"resting\":{\"oid\":123}}]}}},\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"cloid\":\"0x...\"}}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"sell\",\"size\":\"0.001\",\"type\":\"market\",\"slippage\":0.01,\"reduceOnly\":true}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"filled\":{\"oid\":123}}]}}}}}` |\n| `cancel --oid 123` or `cancel --all` | `{\"ok\":true,\"data\":{\"cancelled\":[\"123\"],\"result\":{\"status\":\"ok\"}}}` |\n| `modify --json '{\"modifications\":[{\"oid\":123,\"price\":\"20100\",\"size\":\"0.001\"}]}'` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `leverage --coin BTC --leverage 5 --mode isolated` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `run --detach` | `{\"ok\":true,\"data\":{\"pid\":123,\"startedAt\":1700000000000,\"network\":\"testnet\"}}` |\n| `events --since 12 --types '[\"fill\"]'` | `{\"ok\":true,\"data\":[{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}]}` |\n| `events --follow` | `{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}` (one line per event) |\n| `logs --follow --level warn` | `{\"level\":\"warn\",\"requestId\":\"...\",\"message\":\"Risk rejected\",\"time\":\"...\"}` (JSON lines) |\n| `rotate` then `rotate --signature 0x...` | `{\"ok\":true,\"data\":{\"oldAgent\":\"0x...\",\"newAgent\":\"0x...\",\"swapped\":true,\"oldRevoked\":false,\"revokeApproval\":{}}}` |\n| `rotate --revoke-signature 0x...` | `{\"ok\":true,\"data\":{\"swapped\":true,\"oldRevoked\":true}}` |\n| `revoke --cancel-orders` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":false,\"requiresSignature\":true,\"approval\":{}}}` |\n| `revoke --signature 0x...` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":true}}` |\n| `audit --intent ID` | `{\"ok\":true,\"data\":[{\"intentId\":\"ID\",\"stage\":\"decision\",\"data\":{\"allowed\":true},\"previousHash\":\"...\",\"hash\":\"...\"}]}` |\n| `skill install --target codex` | `{\"ok\":true,\"data\":{\"target\":\"codex\",\"path\":\".../agent-cli/SKILL.md\",\"version\":\"0.2.0\"}}` |\n\nBefore an order, query `market --coin BTC` for `mid`, `szDecimals`, asset and\nmarket leverage ceiling. Compute size/notional with decimal strings/bigints,\nusing the user-approved limit or the bounded IOC execution price. A valid schema\nalone does not establish that an order is tradable or within the budget.\n\nUse `--intent-id ID --intent \"reason\"` on trading actions to connect the user's\nreasoning to the audit. Keep cloid stable when retrying a known order intent.\nFor TP/SL use `type:\"tp\"|\"sl\"` and `triggerPrice` with size/price/reduceOnly;\nmarket orders use an explicit slippage-bounded IOC, not an unbounded market order.\n\n## Error recovery\n\n| Code | Action |\n| --- | --- |\n| RATE_LIMIT, NETWORK_ERROR | At most 3 read retries with exponential backoff; for uncertain mutations, reconcile order state/cloid before retrying. Stop and report unresolved results |\n| NONCE_COLLISION | Retry using a newly issued nonce; inspect a stale lock if persistent |\n| DEPOSIT_PENDING | Poll status until settled; stop on terminal funding failure |\n| RUNNER_NOT_RUNNING | Start runner or use configured direct fallback |\n| INSUFFICIENT_MARGIN | Reduce exposure or request authorized funding |\n| POLICY_REJECTED | Stop and revise intent within limits; do not bypass policy |\n| AGENT_EXPIRED | Rotate with master-wallet approval |\n| AGENT_NOT_APPROVED | Complete init/approval or explain revocation |\n| INVALID_SIZE, INVALID_PRICE | Correct input using market precision |\n| UNKNOWN_MARKET | Refresh metadata; use an allowed market |\n| ORDER_NOT_FOUND | Refresh orders before changing intent |\n| CONFIG_INVALID | Correct configuration/input; never insert secrets |\n| UPSTREAM_ERROR, INTERNAL | Stop, inspect redacted details/logs and report failure |\n\n## Gotchas\n\nHyperCore perps USDC uses **8 decimals**; EVM USDC uses **6**. Preserve strings and\nbigints. Some deposits require **two distinct signatures**; resume stored steps\nrather than restarting the route. Agent expiry is at most **180 days**. Never\nask for, paste, log or import a master private key or seed. The managed agent is\ntrade-only; local limits include markets, size/notional, leverage, daily loss and\nopen orders. Privy sees an L1 action hash, so it cannot independently inspect\nnotional/action contents; Hyperliquid agent permissions and local checks enforce\nthose boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`.\n\n\n## Verification boundaries\n\nOffline skill tests validate the documented command examples against CLI schemas.\nMocked lifecycle tests exercise resumable approvals, pending funding, events\nand failure handling. These checks do **not** prove a live agent or\nexchange run. The nightly testnet lifecycle uses only a company-owned disposable\nCI master account and separate test Privy app, live Privy and Hyperliquid\napprovals/revocation, and explicitly mocked Relay plus mocked Hyperliquid orders\nthat never reach the exchange. Test USDC pre-funding is not required for that job.\nThe same harness supports a caller-controlled external wallet approval adapter\non a clean QA runner; its private key stays in that wallet. It never transfers\nreal funds. Missing CI credentials fail the job; a skipped or\nmocked test must never be reported as live validation. Record the workflow run,\ncommit, lifecycle report, event and audit artifacts before claiming a testnet pass.\nSee `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup.\n"; diff --git a/packages/agent-cli/test/e2e-harness.test.js b/packages/agent-cli/test/e2e-harness.test.js index dc3673b..5bee14f 100644 --- a/packages/agent-cli/test/e2e-harness.test.js +++ b/packages/agent-cli/test/e2e-harness.test.js @@ -251,6 +251,20 @@ test('a successful envelope without exchange acceptance cannot complete the life expect(f.stop).toHaveBeenCalledOnce(); }); +test('preflight accepts an unfunded account because orders never reach the exchange', async () => { + const f = fixture(); + const info = f.info; + f.info = vi.fn(async (type) => + type === 'clearinghouseState' + ? { withdrawable: '0', assetPositions: [] } + : info(type), + ); + await testnetLifecycle(f); + expect( + f.checkpoint.mock.calls.filter(([stage]) => stage.status === 'passed'), + ).toHaveLength(15); +}); + test('a funded but non-disposable account is rejected before any write', async () => { const f = fixture({ info: async (type) => diff --git a/packages/agent-cli/test/e2e-hl-orders.test.js b/packages/agent-cli/test/e2e-hl-orders.test.js new file mode 100644 index 0000000..06a0cba --- /dev/null +++ b/packages/agent-cli/test/e2e-hl-orders.test.js @@ -0,0 +1,169 @@ +import { afterEach, expect, test } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + blockedOrderMessage, + inspectExchangeAction, + inspectWebsocketPayload, + mockExchangeAction, + mockHyperliquidOrdersEnabled, + mockOpenOrders, + mockOrdersEnv, +} from '../scripts/e2e-hl-orders.mjs'; + +const signature = { r: '0x' + '1'.repeat(64), s: '0x' + '2'.repeat(64), v: 27 }; +const cloid = '0x' + 'ab'.repeat(16); + +async function withHome(action) { + const home = await mkdtemp(join(tmpdir(), 'bloxwap-e2e-orders-')); + const previousHome = process.env.HOME; + const previousMock = process.env[mockOrdersEnv]; + process.env.HOME = home; + try { + return await action(); + } finally { + process.env.HOME = previousHome; + if (previousMock === undefined) delete process.env[mockOrdersEnv]; + else process.env[mockOrdersEnv] = previousMock; + await rm(home, { recursive: true, force: true }); + } +} + +afterEach(() => { + delete process.env[mockOrdersEnv]; +}); + +test('approvals stay live while order and fund-moving actions cannot escape', () => { + expect(inspectExchangeAction({ type: 'approveAgent' })).toBe('live'); + expect(inspectExchangeAction({ type: 'approveBuilderFee' })).toBe('live'); + expect(inspectExchangeAction({ type: 'order' })).toBe('order'); + expect(inspectExchangeAction({ type: 'cancel' })).toBe('order'); + expect(inspectExchangeAction({ type: 'batchModify' })).toBe('order'); + expect(inspectExchangeAction({ type: 'withdraw3' })).toBe('forbidden'); + expect(inspectExchangeAction({ type: 'usdSend' })).toBe('forbidden'); + expect(inspectExchangeAction({ type: 'usdClassTransfer' })).toBe('forbidden'); + expect(mockHyperliquidOrdersEnabled()).toBe(false); + expect(() => + mockExchangeAction({ + action: { type: 'order', orders: [] }, + nonce: 1, + signature, + }), + ).toThrow(blockedOrderMessage('order')); +}); + +test('the mocked book records resting, modified and canceled orders without claiming fills', async () => { + await withHome(async () => { + process.env[mockOrdersEnv] = '1'; + const placed = mockExchangeAction({ + action: { + type: 'order', + orders: [ + { + a: 0, + b: true, + p: '7174.9', + s: '0.00279', + r: false, + t: { limit: { tif: 'Alo' } }, + c: cloid, + }, + ], + }, + nonce: 1, + signature, + }); + expect(placed.response.response.data.statuses[0].resting.oid).toBe(1); + expect(placed.updates).toEqual([ + expect.objectContaining({ + status: 'open', + order: expect.objectContaining({ + coin: 'BTC', + oid: 1, + cloid, + limitPx: '7174.9', + }), + }), + ]); + expect(mockOpenOrders('frontendOpenOrders')).toMatchObject([ + { oid: 1, coin: 'BTC', cloid, limitPx: '7174.9', sz: '0.00279' }, + ]); + + const modified = mockExchangeAction({ + action: { + type: 'batchModify', + modifies: [ + { + oid: 1, + order: { + a: 0, + b: true, + p: '7031.4', + s: '0.00279', + r: false, + t: { limit: { tif: 'Alo' } }, + c: cloid, + }, + }, + ], + }, + nonce: 2, + signature, + }); + expect(modified.response.response.data.statuses[0].resting.oid).toBe(1); + expect(mockOpenOrders('frontendOpenOrders')[0].limitPx).toBe('7031.4'); + + const canceled = mockExchangeAction({ + action: { type: 'cancel', cancels: [{ a: 0, o: 1 }] }, + nonce: 3, + signature, + }); + expect(canceled.response.response.data.statuses).toEqual(['success']); + expect(canceled.updates[0].status).toBe('canceled'); + expect(mockOpenOrders('openOrders')).toEqual([]); + }); +}); + +test('unsigned or unknown exchange actions fail closed even when the mock is enabled', async () => { + await withHome(async () => { + process.env[mockOrdersEnv] = '1'; + expect(() => + mockExchangeAction({ + action: { + type: 'order', + orders: [{ a: 0, b: true, p: '1', s: '1', r: false, t: { limit: { tif: 'Alo' } } }], + }, + }), + ).toThrow('Unsigned'); + expect(() => + mockExchangeAction({ + action: { type: 'withdraw3' }, + nonce: 1, + signature, + }), + ).toThrow('Unexpected Hyperliquid exchange action'); + expect(mockOpenOrders('clearinghouseState')).toBeNull(); + }); +}); + +test('WebSocket action posts cannot place a live order', () => { + const frame = JSON.stringify({ + method: 'post', + id: 7, + request: { + type: 'action', + payload: { + action: { type: 'order', orders: [] }, + nonce: 1, + signature, + }, + }, + }); + expect(() => inspectWebsocketPayload(frame)).toThrow( + blockedOrderMessage('order'), + ); + expect(inspectWebsocketPayload(JSON.stringify({ method: 'subscribe' }))).toBe( + 'pass', + ); +}); diff --git a/packages/agent-cli/test/e2e-relay-hook.test.js b/packages/agent-cli/test/e2e-relay-hook.test.js index a8b8cff..45461d1 100644 --- a/packages/agent-cli/test/e2e-relay-hook.test.js +++ b/packages/agent-cli/test/e2e-relay-hook.test.js @@ -1,23 +1,96 @@ import { afterEach, expect, test, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mockOrdersEnv } from '../scripts/e2e-hl-orders.mjs'; -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env[mockOrdersEnv]; +}); + +const signedOrder = { + action: { + type: 'order', + orders: [ + { + a: 0, + b: true, + p: '7174.9', + s: '0.00279', + r: false, + t: { limit: { tif: 'Alo' } }, + c: '0x' + '1'.repeat(32), + }, + ], + }, + nonce: 1, + signature: { r: '0x' + '1'.repeat(64), s: '0x' + '2'.repeat(64), v: 27 }, +}; + +test('test preload blocks mainnet, mocks Relay and Hyperliquid orders, and passes through live test services', async () => { + const home = await mkdtemp(join(tmpdir(), 'bloxwap-e2e-hook-')); + const previousHome = process.env.HOME; + process.env.HOME = home; + try { + const upstream = vi.fn(async () => Response.json({ liveServiceFixture: true })); + vi.stubGlobal('fetch', upstream); + await import('../scripts/e2e-relay-hook.mjs'); + await expect(fetch('https://api.hyperliquid.xyz/info')).rejects.toThrow('Mainnet'); + await expect(fetch(new Request('https://api.relay.link/quote/v2'))).rejects.toThrow('Mainnet'); + await expect(fetch('https://rpc.hyperliquid.xyz/info')).rejects.toThrow('Mainnet'); + await expect(fetch('https://api.testnets.relay.link/quote/v2', { + body: JSON.stringify({ originChainId: 42161 }), + })).rejects.toThrow('test-only'); + const quote = await (await fetch(new Request('https://api.testnets.relay.link/quote/v2', { + method: 'POST', body: JSON.stringify({ originChainId: 421614, originCurrency: '0x' + '0'.repeat(40) }), + }))).json(); + expect(quote.details.currencyOut.amount).toBe('0'); + expect(await (await fetch('https://api.testnets.relay.link/intents/status?id=test')).json()) + .toEqual({ status: 'success' }); + + await expect(fetch('https://api.hyperliquid-testnet.xyz/exchange', { + method: 'POST', + body: JSON.stringify(signedOrder), + })).rejects.toThrow('must not reach the exchange'); + await expect(fetch('https://api.hyperliquid-testnet.xyz/exchange', { + method: 'POST', + body: JSON.stringify({ + action: { type: 'withdraw3' }, + nonce: 1, + signature: signedOrder.signature, + }), + })).rejects.toThrow('Unexpected Hyperliquid exchange action'); + + process.env[mockOrdersEnv] = '1'; + const placed = await (await fetch('https://api.hyperliquid-testnet.xyz/exchange', { + method: 'POST', + body: JSON.stringify(signedOrder), + })).json(); + expect(placed.response.data.statuses[0].resting.oid).toBe(1); + expect(await (await fetch('https://api.hyperliquid-testnet.xyz/info', { + method: 'POST', + body: JSON.stringify({ type: 'frontendOpenOrders' }), + })).json()).toMatchObject([{ oid: 1, coin: 'BTC', limitPx: '7174.9' }]); -test('test preload blocks mainnet, requires the explicit mocked route, and passes through real test services', async () => { - const upstream = vi.fn(async () => Response.json({ liveServiceFixture: true })); - vi.stubGlobal('fetch', upstream); - await import('../scripts/e2e-relay-hook.mjs'); - await expect(fetch('https://api.hyperliquid.xyz/info')).rejects.toThrow('Mainnet'); - await expect(fetch(new Request('https://api.relay.link/quote/v2'))).rejects.toThrow('Mainnet'); - await expect(fetch('https://api.testnets.relay.link/quote/v2', { - body: JSON.stringify({ originChainId: 42161 }), - })).rejects.toThrow('test-only'); - const quote = await (await fetch(new Request('https://api.testnets.relay.link/quote/v2', { - method: 'POST', body: JSON.stringify({ originChainId: 421614, originCurrency: '0x' + '0'.repeat(40) }), - }))).json(); - expect(quote.details.currencyOut.amount).toBe('0'); - expect(await (await fetch('https://api.testnets.relay.link/intents/status?id=test')).json()) - .toEqual({ status: 'success' }); - await fetch('https://api.hyperliquid-testnet.xyz/info'); - await fetch(new Request('https://api.privy.io/v1/wallets')); - expect(upstream).toHaveBeenCalledTimes(2); + await fetch('https://api.hyperliquid-testnet.xyz/info'); + await fetch(new Request('https://api.privy.io/v1/wallets')); + await fetch('https://api.hyperliquid-testnet.xyz/exchange', { + method: 'POST', + body: JSON.stringify({ + action: { type: 'approveAgent', agentAddress: '0x' + '1'.repeat(40) }, + nonce: 2, + signature: signedOrder.signature, + }), + }); + expect(upstream).toHaveBeenCalledTimes(3); + expect(upstream.mock.calls.map(([url]) => String(url instanceof Request ? url.url : url))).toEqual([ + 'https://api.hyperliquid-testnet.xyz/info', + 'https://api.privy.io/v1/wallets', + 'https://api.hyperliquid-testnet.xyz/exchange', + ]); + } finally { + process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } }); diff --git a/packages/agent-cli/test/testnet-report.test.js b/packages/agent-cli/test/testnet-report.test.js index 06e7775..a415a9d 100644 --- a/packages/agent-cli/test/testnet-report.test.js +++ b/packages/agent-cli/test/testnet-report.test.js @@ -15,10 +15,17 @@ const complete = () => ({ status: 'passed', network: 'testnet', coverage: { - hyperliquid: { mode: 'live testnet', verified: true }, + hyperliquid: { + mode: 'live testnet approvals, account info and non-order websocket', + verified: true, + }, + orders: { + mode: 'mocked placement, modification, cancellation and order websocket; exchange order endpoints blocked', + verified: true, + }, privy: { mode: 'live dedicated test app', verified: true }, relay: { - mode: 'mocked zero-value route; pre-funded collateral', + mode: 'mocked zero-value route; no token transfer', verified: true, }, }, @@ -36,6 +43,7 @@ const failed = () => ({ stages: [], coverage: { hyperliquid: { verified: false }, + orders: { verified: false }, privy: { verified: false }, relay: { verified: false }, }, @@ -53,6 +61,11 @@ test('a complete report validates without treating the script as an independent 'Independent agent session: **not-run (unverified)**', ); expect(summary).toContain('live Relay remains unverified'); + expect(summary).toContain('blocked from the exchange'); + expect(summary).toContain('Hyperliquid orders (mocked): **verified**'); + expect(summary).toContain( + 'Hyperliquid testnet approvals and account reads: **verified**', + ); expect(summary).toContain('verified (0 orders, 0 agents, 0 positions)'); for (const name of lifecycleStages) expect(summary).toContain(`| ${name} | passed |`); @@ -81,7 +94,7 @@ test('a passed report cannot leave a stage running', () => { expect(() => verifyTestnetReport(report)).toThrow('incomplete stage'); }); -test.each(['hyperliquid', 'privy', 'relay'])( +test.each(['hyperliquid', 'orders', 'privy', 'relay'])( 'a passed report requires verified %s coverage', (name) => { for (const value of [false, undefined, 'true', 1]) { @@ -117,7 +130,10 @@ test('a failed preflight remains a valid failed report with explicitly unverifie expect(verifyTestnetReport(report)).toBe(report); const summary = formatTestnetSummary(report); expect(summary).toContain('Deterministic testnet lifecycle: **failed**'); - expect(summary).toContain('Hyperliquid testnet: **unverified**'); + expect(summary).toContain( + 'Hyperliquid testnet approvals and account reads: **unverified**', + ); + expect(summary).toContain('Hyperliquid orders (mocked): **unverified**'); expect(summary).toContain('Privy dedicated test app: **unverified**'); expect(summary).toContain('Relay zero-value mock: **unverified**'); expect(summary).toContain('Cleanup: **not-started (unverified)**'); @@ -133,7 +149,10 @@ test('a failed report preserves partial verified coverage and failed stage outco ]; report.cleanup = { status: 'failed' }; const summary = formatTestnetSummary(report); - expect(summary).toContain('Hyperliquid testnet: **verified**'); + expect(summary).toContain( + 'Hyperliquid testnet approvals and account reads: **verified**', + ); + expect(summary).toContain('Hyperliquid orders (mocked): **unverified**'); expect(summary).toContain('Privy dedicated test app: **unverified**'); expect(summary).toContain(`| ${lifecycleStages[0]} | passed |`); expect(summary).toContain(`| ${lifecycleStages[1]} | failed |`); @@ -153,7 +172,7 @@ test('failed cleanup can record unreadable resource counts as null without claim ); }); -test.each(['hyperliquid', 'privy', 'relay'])( +test.each(['hyperliquid', 'orders', 'privy', 'relay'])( 'failed reports cannot claim verified %s without evidence', (service) => { const report = failed();