diff --git a/.github/scripts/testnet-run.sh b/.github/scripts/testnet-run.sh new file mode 100755 index 0000000..9fc473e --- /dev/null +++ b/.github/scripts/testnet-run.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Run dedicated testnet QA in a clean Linux container without changing host HOME. +set -eu + +script_dir=$(CDPATH='' cd "$(dirname "$0")" && pwd) +workspace=$(git -C "$script_dir/../.." rev-parse --show-toplevel) +artifact_output="$workspace/packages/agent-cli/artifacts" +stage='' + +cleanup() { + original_status=$? + cleanup_failed=0 + trap - EXIT HUP INT TERM + set +e + # Remove previous run evidence even when setup/npm failed before a new report. + if ! mkdir -p "$artifact_output"; then + cleanup_failed=1 + echo 'testnet-run.sh: could not prepare artifact output' >&2 + fi + if ! rm -f "$artifact_output/lifecycle-report.json" \ + "$artifact_output/events.jsonl" "$artifact_output/events-follow.jsonl" \ + "$artifact_output/audit.jsonl" "$artifact_output"/runner*.jsonl; then + cleanup_failed=1 + echo 'testnet-run.sh: could not purge previous test artifacts' >&2 + fi + if [ -n "$stage" ]; then + source_artifacts="$stage/packages/agent-cli/artifacts" + for artifact in "$source_artifacts/lifecycle-report.json" \ + "$source_artifacts/events.jsonl" "$source_artifacts/events-follow.jsonl" \ + "$source_artifacts/audit.jsonl" "$source_artifacts"/runner*.jsonl; do + if [ -f "$artifact" ] && [ ! -L "$artifact" ]; then + if ! cp "$artifact" "$artifact_output/"; then + cleanup_failed=1 + echo 'testnet-run.sh: could not copy a test artifact' >&2 + fi + fi + done + if ! rm -rf "$stage"; then + cleanup_failed=1 + echo 'testnet-run.sh: could not remove test staging' >&2 + fi + fi + if [ "$original_status" -eq 0 ] && [ ! -f "$artifact_output/lifecycle-report.json" ]; then + cleanup_failed=1 + echo 'testnet-run.sh: successful test run produced no report artifact' >&2 + fi + if [ "$original_status" -eq 0 ] && [ "$cleanup_failed" -ne 0 ]; then + original_status=1 + fi + exit "$original_status" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +stage=$(sh "$script_dir/colima-stage.sh") +# shellcheck disable=SC1091 +. "$script_dir/ensure-colima.sh" + +git -C "$workspace" archive --format=tar HEAD > "$stage/source.tar" +tar -xf "$stage/source.tar" -C "$stage" +rm "$stage/source.tar" + +# Secret values stay out of argv/env files. Docker copies only these named +# variables into its fresh environment; the image keeps its own default HOME. +docker run --rm --init --platform linux/arm64 \ + -e CI=true \ + -e GITHUB_SHA -e GITHUB_RUN_ID \ + -e BLOXWAP_E2E -e BLOXWAP_TEST_ACCOUNT_ONLY \ + -e BLOXWAP_TEST_MASTER_KEY -e BLOXWAP_PRIVY_APP_ID -e BLOXWAP_PRIVY_APP_SECRET \ + -v "$stage:/work" -w /work node:24-bookworm \ + sh -eu -c ' +npm ci +npm run build +npm run test:e2e +' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3f56b0d..6d7e1de 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -25,32 +25,11 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - cache: npm - - run: npm ci - - run: npm run build - name: Verify the live testnet lifecycle (Relay mocked) - env: - HOME: ${{ runner.temp }}/e2e-home - run: | - mkdir -p "$HOME" - npm run test:e2e + run: .github/scripts/testnet-run.sh - name: Record testnet coverage and outcome if: always() - run: | - node --input-type=module <<'NODE' - import { readFileSync, appendFileSync, existsSync } from 'node:fs'; - const path = 'packages/agent-cli/artifacts/lifecycle-report.json'; - if (!existsSync(path)) { - appendFileSync(process.env.GITHUB_STEP_SUMMARY, 'No lifecycle report was produced. Live coverage is unproven.\n'); - process.exit(1); - } - const report = JSON.parse(readFileSync(path)); - const stages = report.stages.filter(stage => stage.status !== 'running'); - appendFileSync(process.env.GITHUB_STEP_SUMMARY, - `Testnet lifecycle: **${report.status}**. Cleanup: ${report.cleanup}.\n\n` + - 'Hyperliquid testnet and dedicated Privy app are live; Relay is a zero-value mock using pre-funded test collateral. This script is not an AI-agent session.\n\n' + - stages.map(stage => `- ${stage.name}: ${stage.status}\n`).join('')); - NODE + run: node packages/agent-cli/scripts/testnet-summary.mjs packages/agent-cli/artifacts/lifecycle-report.json - uses: actions/upload-artifact@v4 if: always() with: diff --git a/apps/docs/content/docs/skill.mdx b/apps/docs/content/docs/skill.mdx index 3adb274..e2f9f7b 100644 --- a/apps/docs/content/docs/skill.mdx +++ b/apps/docs/content/docs/skill.mdx @@ -166,7 +166,9 @@ 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. -It never transfers real funds. Missing CI credentials fail the job; a skipped or +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 mocked test must never be reported as live validation. Record the workflow run, commit, lifecycle report, event and audit artifacts before claiming a testnet pass. See `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup. diff --git a/apps/docs/public/SKILL.md b/apps/docs/public/SKILL.md index f608c5b..7a7d5f0 100644 --- a/apps/docs/public/SKILL.md +++ b/apps/docs/public/SKILL.md @@ -152,7 +152,9 @@ 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. -It never transfers real funds. Missing CI credentials fail the job; a skipped or +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 mocked test must never be reported as live validation. Record the workflow run, commit, lifecycle report, event and audit artifacts before claiming a testnet pass. See `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 2e02190..08a0fad 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 full CLI/SDK/RPC account lifecycle test and testnet-driver assertions; separate nightly script signs only for an acknowledged disposable CI test account, uses mocked Relay and already funded test collateral, reconciles exchange outcomes and exports event/audit/report artifacts. Explicit live execution fails when prerequisites are missing. Nightly live evidence remains 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 remains mocked. 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/provenance/asset pipeline with candidate-only manual runs. Publishing and public registry verification await npm configuration. | | #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 92e7ca9..7aa7990 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -13,7 +13,7 @@ | #8 | done | Service error adapters, redacted upstream detail, fixture-driven classification and retry defaults. | | #9 | done | Bounded jittered retries and per-signer persisted monotonic nonces guarded by exclusive locks. Stale locks fail closed. | | #10 | done | Privy server SDK create/link, P-256 owner, private authorization storage and typed-data signer; sandbox test env-gated. | -| #11 | partial | User-owned Privy app, v2 domain/network and no-export policy, authorized wallet attachment and init revalidation; local typed-data and withdrawal rejection tests pass. Direct live Privy rejection still needs a dedicated test app. L1 action hashes prevent Privy per-action/notional inspection; exchange agent permissions enforce the withdrawal boundary. | +| #11 | partial | Each user brings their own external master wallet and Privy app. v2 domain/network and no-export policy, authorized wallet attachment and init revalidation; local typed-data and withdrawal rejection tests pass. Direct live Privy rejection still needs a dedicated test app. L1 action hashes prevent Privy per-action/notional inspection; exchange agent permissions enforce the withdrawal boundary. | | #12 | done | Idempotent managed-wallet onboarding, persisted external master EIP-712 approvals/deeplink, expiry bound, agent/builder verification and master-key rejection. | | #13 | done | Default Bloxwap builder address with f=10 (0.01%); config/env overrides, init approval, approved-maximum checks and fund/order address/rate reporting. | | #14 | done | Typed validated Relay quote/status adapter, perps USDC destination, multiple origin routes and fee parsing; mocked fixtures. | @@ -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 | Full mocked CLI/SDK/RPC lifecycle and fail-closed testnet harness, with builder approval, exchange outcome reconciliation, runner/events, rotation/revoke and audit assertions. Nightly live execution/journals and the independent coding-agent skill session transferred from #37 need dedicated QA setup and evidence; neither is claimed complete. | +| #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. | | #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 with provenance and registry verification. Manual candidates cannot publish. Actual release needs npm authentication and 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. | @@ -75,3 +75,12 @@ account trading were used for this review. The validated version-patch workflow prepared 0.2.0, consuming the pending changesets and synchronizing package/lock/skill versions and generated docs. This is release preparation; public npm installation/provenance is still pending. + +## Testnet harness — 2026-10-18 + +#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. The `testnet` environment still needs QA configuration; +no credentialed lifecycle or independent coding-agent session is claimed. diff --git a/docs/ci-runners.md b/docs/ci-runners.md index 9985ac5..811bd6f 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -60,6 +60,7 @@ docker pull --platform linux/amd64 debian:bookworm-slim docker pull --platform linux/arm64 debian:bookworm-slim docker pull --platform linux/amd64 node:22.12.0-bookworm docker pull --platform linux/amd64 node:24-bookworm +docker pull --platform linux/arm64 node:24-bookworm ``` `debian:bookworm-slim` runs the compiled Linux CLI (glibc). The Node bookworm @@ -76,9 +77,10 @@ it. Host-side scratch space is `$RUNNER_TEMP` (GitHub Actions empties it at the start and end of each job). Docker bind mounts use `$HOME/.bloxwap-ci/` because -Colima does not see `/opt/github-runners`. The testnet job sets -`HOME=$RUNNER_TEMP/e2e-home` so a persistent Mac mini cannot reuse `~/.bloxwap` -state. +Colima does not see `/opt/github-runners`. The testnet job stages tracked source +there and builds/runs in a fresh `linux/arm64` Node 24 container with its default +home. The host's wallet state is not mounted. Only explicit report/journal +artifacts are copied back, including after a failed run, before staging is removed. ## Network @@ -100,6 +102,8 @@ The runner needs outbound access to: - **Install smoke** still covers macOS and Linux × Node 22.12 and 24. Linux cases pack on the Mac, then install inside a `linux/amd64` Node container. - **Docs smoke** uses Playwright Chromium already installed for macOS. +- **Testnet lifecycle** runs in a fresh `linux/arm64` Node 24 container so repeated + jobs cannot reuse wallet state on the persistent runner. CI helper scripts live in `.github/scripts/` and are POSIX `/bin/sh` (BSD userland). They must stay free of GNU-only flags, `apt`, and `sudo`. diff --git a/docs/custody.md b/docs/custody.md index 037a3f4..7f3266d 100644 --- a/docs/custody.md +++ b/docs/custody.md @@ -6,7 +6,7 @@ shared Bloxwap Privy account, credential proxy, or hosted Bloxwap signer. Do not send Privy credentials or wallet keys to Bloxwap, an AI conversation, repository, issue, or support message. -The master wallet stays external and approves a separate Hyperliquid API agent. +Each user brings their own master wallet. It stays external and approves a separate Hyperliquid API agent. Privy manages the agent wallet key in the user's app; the CLI generates its P-256 owner authorization key locally. Only the public authorization key is registered with Privy. The private authorization key remains in a private local diff --git a/docs/issues/39.md b/docs/issues/39.md index 30d260b..ae0b12a 100644 --- a/docs/issues/39.md +++ b/docs/issues/39.md @@ -3,7 +3,7 @@ Labels: Security & Observability, size:L ## Description -Scripted e2e on HL testnet: init (with test master key in CI only), fund (mocked Relay), run, order/cancel/modify, events, rotate, revoke. +Scripted e2e on HL testnet: init with external wallet approvals (or a disposable CI-only test signer), fund (mocked Relay), run, order/cancel/modify, events, rotate, revoke. ## Acceptance criteria - [ ] Runs in CI nightly with secrets @@ -16,6 +16,21 @@ The live coding-agent session remains unverified. It is separate from the deterministic CI harness and offline fixture tests. #37 covers the completed skill deliverable; this issue owns both forms of live testnet evidence. +## Implementation +The harness supports a caller-controlled external signer over JSON stdin/stdout; +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. +Schema 2 reports and CI summaries reject incomplete evidence and preserve the +separate, unverified coding-agent session requirement. + +Live acceptance is pending a configured QA wallet and Privy app; offline tests +do not check these acceptance boxes. + ## Dependencies - #27 - #29 diff --git a/docs/testnet.md b/docs/testnet.md index 0248911..e108546 100644 --- a/docs/testnet.md +++ b/docs/testnet.md @@ -2,58 +2,129 @@ The `Testnet lifecycle` GitHub workflow runs nightly and on manual dispatch in the `testnet` environment on the self-hosted Mac mini fleet (see [CI runners](ci-runners.md)). -It isolates `HOME` under `$RUNNER_TEMP` so a persistent runner cannot reuse -`~/.bloxwap`. It fails when required credentials are absent. A successful -unit test, skipped integration test or workflow that stops before the lifecycle -is not a live testnet pass. +Each run uses a fresh Node 24 Linux container with its default home and state +location; the persistent host's wallet state is never mounted. It fails when +required configuration is absent. Offline +fixtures, skipped tests and runs that stop before the lifecycle do not establish +live coverage. -Set these environment secret **names**, using a dedicated disposable company CI -account and separate company test Privy app: +Every production user brings their own external master wallet and Privy app. +Bloxwap receives neither their keys nor their app credentials. The test harness +supports an external wallet on a caller-controlled QA runner and a separate +CI-only signer for a disposable company test account. Neither mode imports a +production wallet into the CLI. + +## External wallet on your QA runner + +Create a dedicated test Privy app and choose a disposable testnet wallet that you +control. Set these variables locally through your secret manager or environment; +never put secrets in a conversation, issue, source file or command argument: + +| Variable | Purpose | +| --- | --- | +| `BLOXWAP_TEST_MASTER_ADDRESS` | Public address of your test wallet | +| `BLOXWAP_TEST_SIGNER` | Absolute path to your executable wallet approval adapter, without arguments | +| `BLOXWAP_PRIVY_APP_ID` | Your dedicated test Privy app ID | +| `BLOXWAP_PRIVY_APP_SECRET` | Your dedicated test Privy app secret | +| `BLOXWAP_E2E` | Explicitly set to `1` | +| `BLOXWAP_TEST_ACCOUNT_ONLY` | Explicitly set to `1` to acknowledge a disposable test account | + +Do not set `BLOXWAP_TEST_MASTER_KEY` in this mode. The external wallet retains its +private key; the harness receives only signatures. From the repository root: + +```sh +npm ci +npm run build +npm run test:e2e +``` + +The adapter is executed without a shell or arguments. It receives one JSON object +on stdin (EOF ends the request): + +```json +{ + "schemaVersion": 1, + "network": "testnet", + "address": "0x...public test wallet address...", + "action": {"type": "approveAgent", "...": "canonical exchange action"}, + "typedData": {"domain": {}, "types": {}, "primaryType": "...", "message": {}} +} +``` + +It must return exactly `{"signature":"0x...65-byte signature..."}` on stdout. +Connect it to your wallet's signing/approval mechanism; this protocol is not a +wallet implementation. It has a 60-second timeout and a 16 KiB output limit. +Privy credentials and the CI master-key variable are removed from its environment. + +Before invoking the adapter, the harness verifies the exact Hyperliquid approval +domain/types, testnet network and correspondence between action and typed data. +Only `approveAgent` (including revocation) and `approveBuilderFee` are accepted. +Withdrawals, trades and mainnet approvals are refused. The recovered signature +must match your configured public master address. Review the exact agent address, +name/expiry and builder fee before approving. + +## Nightly CI configuration + +Set these secret names in the repository's `testnet` environment, using a separate +disposable company test wallet and company QA Privy app: | Secret | Purpose | | --- | --- | -| `BLOXWAP_TEST_MASTER_KEY` | CI-only master signer for a testnet-only disposable account | +| `BLOXWAP_TEST_MASTER_KEY` | CI-only signer for the disposable testnet account | | `BLOXWAP_PRIVY_APP_ID` | Dedicated test Privy app ID | | `BLOXWAP_PRIVY_APP_SECRET` | Dedicated test Privy app secret | -No production user key or credential belongs in this workflow. Production users -create and own their own Privy account/app and approve actions from their external -wallet. Bloxwap never receives those keys or secrets. The CI-only test signer is -not shipped as a CLI key-import mode and its key is removed from CLI child process -environments. +This mode requires `CI=true` or `CI=1`, both explicit test flags, and no external +signer variables. Its key is removed from CLI child process environments. It is +not a production CLI key-import feature. Missing secrets fail the nightly job. + +## Account preparation and coverage -Pre-fund the disposable Hyperliquid **testnet** account with at least 30 test USDC. -It must have no positions, open orders or leftover agent approvals. Use an -ephemeral clean CI home: the harness refuses existing `~/.bloxwap` state. CI sets -`BLOXWAP_E2E=1` and `BLOXWAP_TEST_ACCOUNT_ONLY=1`; signing is refused outside CI. -Do not run this workflow with a mainnet account or real funds. +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 +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. Mainnet Relay -and Hyperliquid HTTP endpoints are blocked by the harness preload. This boundary -is visible in every lifecycle report and workflow summary. - -The lifecycle asserts both init approvals, funding QR and settled state, runner -signing/readiness, post-only resting order placement, observable modification and -cancellation, WebSocket order events, `events --follow`/cursor replay, verified -rotation/old-agent revocation, final local/exchange revocation and hash-verified -audit intent/signature/response records. Assertions reconcile exchange state; -`ok:true` alone never counts as completion. Orders use BTC with a 50 test USD -notional/position cap and a two-open-order limit. Test fills are not requested. - -Artifacts contain `lifecycle-report.json`, event and audit journals, streamed -events and redacted runner logs. They exclude configs, approvals and key files, -are checked against the known secret values before writing and expire after -seven days. The report records the commit/run and each stage's outcome. Failed -runs attempt order/agent cleanup; `failed-manual-review-required` means inspect -the disposable account before another run. A forced job termination can interrupt -cleanup, so account owners should review failed/timed-out runs. - -An AI-driven skill forward run remains a separate acceptance requirement: have a -coding agent read the installed skill, use the dedicated testnet setup, follow its -approval/funding boundaries, and record its agent/session ID plus lifecycle and -journal evidence. The deterministic CI script is not represented as that agent -session. #37 covers the completed skill content, installation and hosting. Its -live-session criterion is now tracked in #39, which remains open until both forms -of live testnet evidence are recorded. +test collateral. No token transfer or bridge execution is claimed. The preload +blocks mainnet Hyperliquid and Relay HTTP endpoints in CLI child processes. + +Fifteen stages verify both init approvals, funding QR/settlement, runner signing +and readiness, resting post-only orders, exact exchange 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 +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. + +## Evidence + +Schema 2 `lifecycle-report.json` records the commit/run, stage outcomes, observed +service coverage and cleanup counts. Reports are saved at stage checkpoints. +A pass requires all fifteen stages and verified cleanup. CI summaries validate +the report and never infer live coverage from missing or failed prerequisites. + +Artifacts include the report, event/audit journals, streamed events and redacted +runner logs. Configs, approval payloads and key files are excluded. The harness +clears its explicit artifact filenames at the next run so previous journals +cannot masquerade as current evidence, checks files against known secret values, +and CI expires uploads after seven days. Save evidence elsewhere before rerunning. + +An independent coding-agent session remains a separate acceptance requirement: +have a coding agent read the [hosted skill](https://bloxwap.github.io/agent-cli/SKILL.md) +or installed skill, follow the dedicated testnet flow with your external approvals, +and record its actual agent/session ID, commit, commands/results and event/audit +journals. Do not invent a session ID or relabel this deterministic script as that +session. Its report deliberately retains `agentSession.status: "not-run"`. +#37 covers the completed skill deliverable; #39 remains open until both the +credentialed nightly lifecycle and independent agent evidence are recorded. diff --git a/packages/agent-cli/SKILL.md b/packages/agent-cli/SKILL.md index f608c5b..7a7d5f0 100644 --- a/packages/agent-cli/SKILL.md +++ b/packages/agent-cli/SKILL.md @@ -152,7 +152,9 @@ 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. -It never transfers real funds. Missing CI credentials fail the job; a skipped or +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 mocked test must never be reported as live validation. Record the workflow run, commit, lifecycle report, event and audit artifacts before claiming a testnet pass. See `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup. diff --git a/packages/agent-cli/scripts/e2e-testnet.mjs b/packages/agent-cli/scripts/e2e-testnet.mjs index 337ccff..c8ad8fa 100644 --- a/packages/agent-cli/scripts/e2e-testnet.mjs +++ b/packages/agent-cli/scripts/e2e-testnet.mjs @@ -1,179 +1,280 @@ -import { spawn, spawnSync } from 'node:child_process'; -import { mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { + mkdirSync, + existsSync, + readFileSync, + writeFileSync, + readdirSync, + unlinkSync, +} from 'node:fs'; import { resolve, join } from 'node:path'; import { homedir } from 'node:os'; -import { createConnection } from 'node:net'; -import { privateKeyToAccount } from 'viem/accounts'; -import { requireTestnetEnvironment, requiredTestnetSecrets, testnetLifecycle } from './testnet-lifecycle.mjs'; +import { fileURLToPath } from 'node:url'; +import { testnetLifecycle } from './testnet-lifecycle.mjs'; +import { + requireTestnetEnvironment, + requiredTestnetSecrets, + missingTestnetConfiguration, + createTestnetSigner, + createTestnetRevocation, +} from './testnet-signers.mjs'; +import { verifyTestnetReport } from './testnet-report.mjs'; +import { startEventFollower } from './testnet-follower.mjs'; +import { cleanupTestnetAccount } from './testnet-cleanup.mjs'; +import { stopTestnetRunner } from './testnet-runner.mjs'; const directory = join(homedir(), '.bloxwap'); const config = join(directory, 'e2e-config.json'); +const cli = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); +const hook = fileURLToPath(new URL('./e2e-relay-hook.mjs', import.meta.url)); const artifacts = resolve('artifacts'); mkdirSync(artifacts, { recursive: true }); +const artifactName = (name) => + [ + 'lifecycle-report.json', + 'events.jsonl', + 'events-follow.jsonl', + 'audit.jsonl', + ].includes(name) || /^runner[^/]*\.jsonl$/.test(name); +// Never attach journals from a preceding run to a fresh preflight failure. +for (const name of readdirSync(artifacts).filter(artifactName)) + unlinkSync(join(artifacts, name)); const report = { - schemaVersion: 1, status: 'failed', network: 'testnet', - accountOwnership: 'company-owned disposable CI account; never a user wallet', + schemaVersion: 2, + status: 'failed', + network: 'testnet', + accountOwnership: + 'Dedicated disposable test account controlled by the caller; never a production wallet', coverage: { hyperliquid: { mode: 'live testnet', verified: false }, privy: { mode: 'live dedicated test app', verified: false }, - relay: { mode: 'mocked zero-value route; pre-funded collateral', verified: false }, + relay: { + mode: 'mocked zero-value route; pre-funded collateral', + verified: false, + }, + }, + agentSession: { + status: 'not-run', + reason: + 'Deterministic script; the independent coding-agent skill session requires separate evidence', }, - agentSession: { status: 'not-run', reason: 'Deterministic CI script; issue 39 tracks the independent coding-agent session transferred from issue 37' }, commit: process.env.GITHUB_SHA ?? null, workflowRun: process.env.GITHUB_RUN_ID ?? null, - startedAt: new Date().toISOString(), stages: [], cleanup: 'not-started', + startedAt: new Date().toISOString(), + stages: [], + cleanup: { status: 'not-started' }, }; -let account; +let signer; let childEnv; let ownedState = false; let follower; -let followed = ''; -let followFailure = false; -let stoppingFollower = false; +let runnerPid; +let nonce = 0; +const agents = new Map(); function command(name, input = {}) { - const result = spawnSync(process.execPath, [ - 'dist/cli.js', name, '--config', config, '--network', 'testnet', - ], { input: JSON.stringify(input), encoding: 'utf8', env: childEnv, timeout: 60_000, maxBuffer: 4_194_304 }); + const result = spawnSync( + process.execPath, + [cli, name, '--config', config, '--network', 'testnet'], + { + input: JSON.stringify(input), + encoding: 'utf8', + env: childEnv, + timeout: 60_000, + maxBuffer: 4_194_304, + }, + ); let envelope; - try { envelope = JSON.parse(result.stdout); } - catch { throw new Error(`Command ${name} did not return one JSON envelope`); } + try { + envelope = JSON.parse(result.stdout); + } catch { + throw new Error(`Command ${name} did not return one JSON envelope`); + } if (result.status !== 0 || !envelope.ok) - throw new Error(`Command ${name} failed (${envelope.code ?? 'PROCESS_ERROR'})`); + throw new Error(`Command ${name} failed`); + if (name === 'run') { + runnerPid = envelope.data.pid; + if ( + !Number.isSafeInteger(runnerPid) || + runnerPid <= 0 || + runnerPid === process.pid + ) + throw new Error('Runner did not identify its detached process'); + } return envelope.data; } -async function sign(approval) { - if (approval.typedData.domain.name !== 'HyperliquidSignTransaction' || - approval.typedData.message.hyperliquidChain !== 'Testnet') - throw new Error('Refusing to sign approval outside Hyperliquid testnet'); - return account.signTypedData(approval.typedData); -} async function info(type, params = {}) { const response = await fetch('https://api.hyperliquid-testnet.xyz/info', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ type, ...params }), signal: AbortSignal.timeout(15_000), + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type, ...params }), + signal: AbortSignal.timeout(15_000), }); if (!response.ok) throw new Error('Testnet info request failed'); return response.json(); } -async function stopRunner() { - await new Promise((resolveStop) => { - const socket = createConnection(join(directory, 'run.sock')); - socket.on('connect', () => socket.end(JSON.stringify({ - jsonrpc: '2.0', id: 1, method: 'shutdown', params: {}, - }) + '\n')); - socket.on('error', resolveStop); - socket.on('close', resolveStop); - socket.setTimeout(5000, () => { socket.destroy(); resolveStop(); }); - }); -} -async function stopFollowing() { - if (!follower || follower.exitCode !== null) return; - const child = follower; - stoppingFollower = true; - await new Promise((resolveStop) => { - const timer = setTimeout(() => { followFailure = true; child.kill('SIGKILL'); }, 5000); - child.once('exit', () => { clearTimeout(timer); resolveStop(); }); - child.kill('SIGTERM'); +async function revoke({ name }) { + nonce = Math.max(Date.now(), nonce + 1); + const approval = createTestnetRevocation(name, nonce); + const signature = await signer.sign(approval); + const recovery = Number.parseInt(signature.slice(-2), 16); + const response = await fetch('https://api.hyperliquid-testnet.xyz/exchange', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: approval.action, + nonce, + signature: { + r: signature.slice(0, 66), + s: '0x' + signature.slice(66, 130), + v: recovery < 27 ? recovery + 27 : recovery, + }, + }), + signal: AbortSignal.timeout(15_000), }); + if (!response.ok || (await response.json()).status !== 'ok') + throw new Error('Testnet cleanup revocation failed'); } function safeArtifact(name, content) { - // Upload only the explicit journals, never configs, approval payloads, keys or - // arbitrary process errors. Drop the entire artifact if a known secret leaks. + // Only explicit journals/report are exported, never configs, approvals, keys, + // or raw SDK errors. Reject the entire artifact if a known secret appears. for (const secretName of requiredTestnetSecrets) { const secret = process.env[secretName]; - if (secret && content.includes(secret)) throw new Error('Secret detected in test artifact'); + if (secret && content.includes(secret)) + throw new Error('Secret detected in test artifact'); } if (/-----BEGIN [^-]*PRIVATE KEY-----/.test(content)) throw new Error('Private key detected in test artifact'); writeFileSync(join(artifacts, name), content, { mode: 0o600 }); } +function saveReport() { + safeArtifact('lifecycle-report.json', JSON.stringify(report, null, 2) + '\n'); +} +function fail(stage) { + report.status = 'failed'; + report.failure ??= stage; + process.exitCode = 1; +} try { - requireTestnetEnvironment(process.env); - // A different config path alone does not isolate journals or sockets. Refuse - // any existing state directory so cleanup can only touch this CI run. - if (existsSync(directory)) throw new Error('Use a clean CI home without ~/.bloxwap'); + const configuration = requireTestnetEnvironment(process.env); + // Config paths do not isolate sockets/journals. Use a clean OS account or + // container, keeping the production state location unchanged at ~/.bloxwap. + if (existsSync(directory)) + throw new Error('Use a clean QA home without ~/.bloxwap'); + signer = await createTestnetSigner(configuration); + report.signerMode = signer.mode; ownedState = true; - account = privateKeyToAccount(process.env.BLOXWAP_TEST_MASTER_KEY); - const hook = resolve('scripts/e2e-relay-hook.mjs'); - childEnv = { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${hook}` }; + childEnv = { + ...process.env, + BLOXWAP_MASTER_ADDRESS: signer.address, + BLOXWAP_NETWORK: 'testnet', + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${JSON.stringify(hook)}`, + }; delete childEnv.BLOXWAP_TEST_MASTER_KEY; + delete childEnv.BLOXWAP_TEST_SIGNER; await testnetLifecycle({ - command, info, sign, masterAddress: account.address, appId: process.env.BLOXWAP_PRIVY_APP_ID, + command, + info, + sign: (approval) => { + nonce = Math.max(nonce, approval.action.nonce); + return signer.sign(approval); + }, + masterAddress: signer.address, + appId: process.env.BLOXWAP_PRIVY_APP_ID, + rememberAgent: (agent) => { + agents.set(agent.address.toLowerCase(), agent); + }, checkpoint: (stage) => { report.stages.push({ ...stage, at: new Date().toISOString() }); if (stage.status === 'passed') { - if (stage.name === 'test-account-preflight') report.coverage.hyperliquid.verified = true; - if (stage.name === 'init-agent-and-builder-approvals') report.coverage.privy.verified = true; - if (stage.name === 'mock-relay-funding-and-settlement') report.coverage.relay.verified = true; + if (stage.name === 'test-account-preflight') + report.coverage.hyperliquid.verified = true; + if (stage.name === 'init-agent-and-builder-approvals') + report.coverage.privy.verified = true; + if (stage.name === 'mock-relay-funding-and-settlement') + report.coverage.relay.verified = true; } + saveReport(); }, startFollow: async () => { - follower = spawn(process.execPath, [ - 'dist/cli.js', 'events', '--follow', '--since', '0', '--network', 'testnet', '--config', config, - ], { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }); - follower.on('error', () => { followFailure = true; }); - follower.on('exit', (code) => { if (!stoppingFollower || code !== 0) followFailure = true; }); - follower.stdout.setEncoding('utf8'); - follower.stdout.on('data', (line) => { - followed += line; - if (Buffer.byteLength(followed) > 4_194_304) { - followFailure = true; - follower.kill('SIGTERM'); - } + follower = startEventFollower({ + command: process.execPath, + args: [ + cli, + 'events', + '--follow', + '--since', + '0', + '--network', + 'testnet', + '--config', + config, + ], + env: childEnv, }); - // Do not echo raw child errors or credentials into hosted logs. - follower.stderr.resume(); - return async () => { - await stopFollowing(); - if (followFailure) throw new Error('events --follow terminated unexpectedly'); - const lines = followed.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); - if (!lines.some((event) => event.type === 'order_update')) - throw new Error('events --follow did not deliver order updates'); - }; + return () => follower.stop(); }, }); report.status = 'passed'; } catch { - report.failure = report.stages.findLast((stage) => stage.status === 'failed')?.name ?? 'testnet-preflight'; - report.missingSecrets = requiredTestnetSecrets.filter((name) => !process.env[name]); - // Never export arbitrary SDK errors: they can contain credentials or payloads. - console.error('FAIL: dedicated testnet lifecycle; inspect lifecycle-report.json'); - process.exitCode = 1; + fail( + report.stages.find((stage) => stage.status === 'failed')?.name ?? + 'testnet-preflight', + ); + report.missingConfiguration = missingTestnetConfiguration(); + report.missingSecrets = requiredTestnetSecrets.filter((name) => + report.missingConfiguration.includes(name), + ); + console.error( + 'FAIL: dedicated testnet lifecycle; inspect lifecycle-report.json', + ); } finally { if (ownedState && existsSync(config)) { + report.cleanup = await cleanupTestnetAccount({ + info, + masterAddress: signer.address, + agents: [...agents.values()], + revoke, + cancel: (order) => command('cancel', { oid: Number(order.oid) }), + }); + if (report.cleanup.status !== 'verified') fail('testnet-cleanup'); + } + if (follower) { try { - if ((await info('extraAgents', { user: account.address })).length) { - // Best effort cleanup is itself signed only by the disposable CI - // account. Remaining approvals are a failed run requiring review. - const pending = command('revoke', { cancelOrders: true }); - command('revoke', { signature: await sign(pending.approval) }); - } - const agents = await info('extraAgents', { user: account.address }); - const orders = await info('frontendOpenOrders', { user: account.address }); - if (agents.length || orders.length) throw new Error('Test approvals or orders remain'); - report.cleanup = 'verified-no-orders-or-agents'; + await follower.stop(); + } catch { + fail('events-follow-verification'); + } + } + if (ownedState && existsSync(config)) { + try { + await stopTestnetRunner({ directory, expectedPid: runnerPid }); } catch { - report.cleanup = 'failed-manual-review-required'; - report.status = 'failed'; - process.exitCode = 1; + fail('testnet-runner-shutdown'); } - await stopFollowing(); - await stopRunner(); try { for (const file of ['events.jsonl', 'audit.jsonl']) - if (existsSync(join(directory, file))) safeArtifact(file, readFileSync(join(directory, file), 'utf8')); - if (followed) safeArtifact('events-follow.jsonl', followed); + if (existsSync(join(directory, file))) + safeArtifact(file, readFileSync(join(directory, file), 'utf8')); + if (follower?.text) safeArtifact('events-follow.jsonl', follower.text); const logs = join(directory, 'logs'); - if (existsSync(logs)) for (const file of readdirSync(logs).filter((file) => file.endsWith('.jsonl'))) - safeArtifact(file, readFileSync(join(logs, file), 'utf8')); + if (existsSync(logs)) + for (const file of readdirSync(logs).filter((file) => + /^runner[^/]*\.jsonl$/.test(file), + )) + safeArtifact(file, readFileSync(join(logs, file), 'utf8')); } catch { - report.status = 'failed'; + fail('testnet-artifacts'); report.artifacts = 'rejected-secret-or-unreadable-journal'; - process.exitCode = 1; } } report.finishedAt = new Date().toISOString(); - // A failed preflight still produces an explicit failure artifact; never skip. - safeArtifact('lifecycle-report.json', JSON.stringify(report, null, 2) + '\n'); - if (report.status === 'passed') console.log('PASS: dedicated testnet lifecycle (Relay mocked; see report)'); + try { + verifyTestnetReport(report); + } catch { + fail('testnet-report-verification'); + } + // 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)'); } diff --git a/packages/agent-cli/scripts/testnet-cleanup.mjs b/packages/agent-cli/scripts/testnet-cleanup.mjs new file mode 100644 index 0000000..246c577 --- /dev/null +++ b/packages/agent-cli/scripts/testnet-cleanup.mjs @@ -0,0 +1,75 @@ +// Run-owned public approvals are recorded before signing so interrupted init or +// rotation can clean both agents without relying on the currently active config. +export async function cleanupTestnetAccount({ + info, + cancel, + revoke, + masterAddress, + agents, +}) { + const result = { status: 'failed' }; + let failed = false; + const query = (type) => info(type, { user: masterAddress }); + try { + const orders = await query('frontendOpenOrders'); + if (!Array.isArray(orders)) throw new Error(); + for (const order of orders) { + try { + await cancel(order); + } catch { + failed = true; + } + } + } catch { + failed = true; + } + try { + const approved = await query('extraAgents'); + if (!Array.isArray(approved)) throw new Error(); + for (const agent of approved) { + const owned = agents.find( + (candidate) => + candidate.address.toLowerCase() === + String(agent.address).toLowerCase() && + candidate.name === agent.name, + ); + if (!owned) { + failed = true; + continue; + } + try { + await revoke(owned); + } catch { + failed = true; + } + } + } catch { + failed = true; + } + // Independently inspect every boundary even if one request or cleanup failed. + for (const [type, field] of [ + ['frontendOpenOrders', 'orders'], + ['extraAgents', 'agents'], + ['clearinghouseState', 'positions'], + ]) { + try { + const state = await query(type); + const values = field === 'positions' ? state.assetPositions : state; + if (!Array.isArray(values)) throw new Error(); + result[field] = + field === 'positions' + ? values.filter(({ position }) => { + const size = position?.szi; + if (typeof size !== 'string' || !/^-?\d+(\.\d+)?$/.test(size)) + throw new Error(); + return !/^-?0+(\.0+)?$/.test(size); + }).length + : values.length; + if (result[field] !== 0) failed = true; + } catch { + failed = true; + } + } + if (!failed) result.status = 'verified'; + return result; +} diff --git a/packages/agent-cli/scripts/testnet-follower.mjs b/packages/agent-cli/scripts/testnet-follower.mjs new file mode 100644 index 0000000..f03c218 --- /dev/null +++ b/packages/agent-cli/scripts/testnet-follower.mjs @@ -0,0 +1,94 @@ +import { spawn } from 'node:child_process'; + +// Capture close immediately: exit can precede trailing stdout, and a signaled +// process has a null exitCode even after it has already stopped. +export function startEventFollower({ + command, + args, + env, + timeoutMs = 5000, + maxBytes = 4_194_304, +}) { + const child = spawn(command, args, { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let text = ''; + let bytes = 0; + let stopping = false; + let closed = false; + let failure; + let stopPromise; + const close = new Promise((resolve) => { + child.on('error', () => { + failure = 'Event follower could not start'; + }); + child.once('exit', (code, signal) => { + if (!stopping || (code !== 0 && signal !== 'SIGTERM')) + failure ??= 'Event follower terminated unexpectedly'; + }); + child.once('close', () => { + closed = true; + resolve(); + }); + }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + bytes += Buffer.byteLength(chunk); + if (bytes > maxBytes) { + failure = 'Event follower exceeded its output limit'; + child.kill('SIGKILL'); + return; + } + text += chunk; + }); + // Arbitrary process errors may include credentials. Never export stderr. + child.stderr.resume(); + async function finish() { + stopping = true; + if ( + child.pid && + !closed && + child.exitCode === null && + child.signalCode === null + ) + child.kill('SIGTERM'); + let timer; + const timedOut = await Promise.race([ + close.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), timeoutMs); + }), + ]); + clearTimeout(timer); + if (timedOut) { + failure ??= 'Event follower did not stop within its time limit'; + if (child.pid) child.kill('SIGKILL'); + let killTimer; + await Promise.race([ + close, + new Promise((resolve) => { + killTimer = setTimeout(resolve, timeoutMs); + }), + ]); + clearTimeout(killTimer); + } + if (failure) throw new Error(failure); + try { + return text + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); + } catch { + throw new Error('Event follower returned malformed JSON'); + } + } + return { + get text() { + return text; + }, + stop() { + return (stopPromise ??= finish()); + }, + }; +} diff --git a/packages/agent-cli/scripts/testnet-lifecycle.mjs b/packages/agent-cli/scripts/testnet-lifecycle.mjs index 839db4d..867d9e0 100644 --- a/packages/agent-cli/scripts/testnet-lifecycle.mjs +++ b/packages/agent-cli/scripts/testnet-lifecycle.mjs @@ -1,28 +1,91 @@ import assert from 'node:assert/strict'; +import { formatPrice } from '@bloxwap/hyperliquid/utils'; -export const requiredTestnetSecrets = [ - 'BLOXWAP_TEST_MASTER_KEY', - 'BLOXWAP_PRIVY_APP_ID', - 'BLOXWAP_PRIVY_APP_SECRET', -]; +export { + requiredTestnetSecrets, + requireTestnetEnvironment, +} from './testnet-signers.mjs'; -export function requireTestnetEnvironment(env) { - assert.equal(env.BLOXWAP_E2E, '1', 'Explicit BLOXWAP_E2E=1 is required'); - assert.ok(['true', '1'].includes(env.CI), 'Test master signing is CI-only'); - assert.equal(env.BLOXWAP_TEST_ACCOUNT_ONLY, '1', - 'A disposable company-owned test account must be explicitly acknowledged'); - const missing = requiredTestnetSecrets.filter((name) => !env[name]); - assert.equal(missing.length, 0, `Missing testnet secrets: ${missing.join(', ')}`); -} - -// No wallet key enters this driver. CI supplies a narrowly scoped external test -// signer; production approvals always stay in the user's external wallet. +// No wallet key enters this driver. Approvals use the caller's external wallet +// or the separate disposable CI-only signer; production keys stay external. export async function testnetLifecycle({ - command, info, sign, masterAddress, appId, startFollow, - checkpoint = () => {}, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + command, + info, + sign, + masterAddress, + appId, + startFollow, + rememberAgent = () => {}, + checkpoint = () => {}, + wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, }) { const intent = `testnet-lifecycle-${now()}`; + const ownedOrders = []; + let orderSerial = 0; + const decimal = (value) => { + assert.match(String(value), /^\d+(\.\d+)?$/, 'Expected a decimal string'); + const [whole, fraction = ''] = String(value).split('.'); + return { units: BigInt(whole + fraction), places: fraction.length }; + }; + const decimalEqual = (left, right) => { + const a = decimal(left), + b = decimal(right); + return ( + a.units * 10n ** BigInt(b.places) === b.units * 10n ** BigInt(a.places) + ); + }; + const fixed = (units, places) => { + const digits = units.toString().padStart(places + 1, '0'); + return places + ? `${digits.slice(0, -places)}.${digits.slice(-places)}` + : digits; + }; + const discountedPrice = (value, basisPoints, decimals) => { + const { units, places } = decimal(value); + return formatPrice( + fixed(units * BigInt(basisPoints), places + 4), + decimals, + ); + }; + async function remember(address, approval) { + assert.match( + address, + /^0x[0-9a-fA-F]{40}$/, + 'Expected a public agent address', + ); + assert.equal( + approval.action.agentAddress.toLowerCase(), + address.toLowerCase(), + ); + const name = approval.action.agentName.replace(/ valid_until \d+$/, ''); + assert.ok(name.length > 0 && name.length <= 16, 'Expected an agent name'); + await rememberAgent({ address, name }); + } + function hasOrderUpdates(events, orders = ownedOrders) { + if (!Array.isArray(events)) return false; + const updates = events.flatMap((event) => + event.type === 'order_update' && Array.isArray(event.data) + ? event.data + : [], + ); + return ( + orders.length > 0 && + orders.every((order) => + ['open', 'canceled'].every((status) => + updates.some( + (update) => + update.status === status && + update.order?.coin === 'BTC' && + (status === 'canceled' + ? String(update.order.oid) === String(order.oid) + : order.oids.has(String(update.order.oid))) && + (!update.order.cloid || update.order.cloid === order.cloid), + ), + ), + ) + ); + } async function stage(name, action) { checkpoint({ name, status: 'running' }); try { @@ -36,23 +99,90 @@ export async function testnetLifecycle({ } async function until(read, accept, message) { const started = now(); - for (let attempt = 0; attempt < 12 && now() - started < 300_000; attempt++) { + for ( + let attempt = 0; + attempt < 12 && now() - started < 300_000; + attempt++ + ) { const result = await read(); if (accept(result)) return result; await wait(Math.min(1000 * 2 ** attempt, 15_000)); } throw new Error(message); } + async function placeRestingOrder() { + const market = await command('market', { coin: 'BTC' }); + const decimals = Number(market.szDecimals); + assert.ok( + Number.isInteger(decimals) && decimals >= 0 && decimals <= 6, + 'Invalid market precision', + ); + // Exact decimal arithmetic keeps the submitted price and reconciled price + // identical after the SDK applies exchange tick/significant-digit rules. + const price = discountedPrice(market.mid, 7000, decimals); + const { units, places } = decimal(price); + assert.ok(units > 0n); + const target = 20n * 10n ** BigInt(places + decimals); + const size = fixed((target + units - 1n) / units, decimals); + const cloid = + '0x' + + (BigInt(now()) * 2n + BigInt(orderSerial++)) + .toString(16) + .padStart(32, '0'); + const placed = await command('order', { + coin: 'BTC', + side: 'buy', + size, + price, + tif: 'Alo', + cloid, + intentId: intent, + }); + const oid = placed.result.response.data.statuses[0].resting?.oid; + assert.ok( + Number.isSafeInteger(oid) && oid > 0, + 'Expected a resting post-only test order, not a fill', + ); + const order = { oid, cloid, decimals, oids: new Set([String(oid)]) }; + ownedOrders.push(order); + await until( + () => command('orders'), + (orders) => + orders.some( + (candidate) => + Number(candidate.oid) === oid && + candidate.cloid === cloid && + candidate.coin === 'BTC' && + decimalEqual(candidate.limitPx, price), + ), + 'Accepted order not visible on exchange', + ); + return order; + } 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.ok(state.assetPositions.every(({ position }) => Number(position.szi) === 0), - 'Disposable test account must have no positions'); - assert.deepEqual(await info('frontendOpenOrders', { user: masterAddress }), [], - 'Disposable test account must have no open orders'); - assert.deepEqual(await info('extraAgents', { user: masterAddress }), [], - 'Disposable test account must have no leftover agent approvals'); + assert.ok( + Number(state.withdrawable) >= 30, + 'Disposable account requires at least 30 test USDC; no real funding is performed', + ); + assert.ok( + state.assetPositions.every( + ({ position }) => + typeof position?.szi === 'string' && + /^-?0+(\.0+)?$/.test(position.szi), + ), + 'Disposable test account must have no positions', + ); + assert.deepEqual( + await info('frontendOpenOrders', { user: masterAddress }), + [], + 'Disposable test account must have no open orders', + ); + assert.deepEqual( + await info('extraAgents', { user: masterAddress }), + [], + 'Disposable test account must have no leftover agent approvals', + ); }); await stage('configure', async () => { for (const [key, value] of [ @@ -62,33 +192,58 @@ export async function testnetLifecycle({ ['limits.maxNotional', '50'], ['limits.maxPositionNotional', '50'], ['limits.maxOpenOrders', '2'], - ]) await command('config', { args: ['set', key, value] }); + ]) + await command('config', { args: ['set', key, value] }); }); await stage('init-agent-and-builder-approvals', async () => { const pending = await command('init', { masterAddress, qr: false }); + await remember(pending.agentAddress, pending.approvals.agent); assert.equal(pending.approved, false); assert.ok(pending.approvals.agent.typedData); let approved = await command('init', { - signature: await sign(pending.approvals.agent), qr: false, + signature: await sign(pending.approvals.agent), + qr: false, }); - if (pending.approvals.builder) approved = await command('init', { - builderSignature: await sign(pending.approvals.builder), qr: false, - }); - assert.equal(approved.approved, true, 'Both exchange approvals must be visible'); + if (pending.approvals.builder) + approved = await command('init', { + builderSignature: await sign(pending.approvals.builder), + qr: false, + }); + assert.equal( + approved.approved, + true, + 'Both exchange approvals must be visible', + ); }); await stage('mock-relay-funding-and-settlement', async () => { const deposit = await command('fund', { - amount: '10', originChain: 421614, - originCurrency: '0x' + '0'.repeat(40), originDecimals: 6, qr: true, + amount: '10', + originChain: 421614, + originCurrency: '0x' + '0'.repeat(40), + originDecimals: 6, + qr: true, }); - assert.equal(deposit.expectedReceived, '0', 'Only the explicit zero-value Relay fixture is allowed'); + assert.equal( + deposit.expectedReceived, + '0', + 'Only the explicit zero-value Relay fixture is allowed', + ); assert.ok(deposit.qrAscii, 'Funding QR must be rendered'); - await until(() => command('status'), (status) => { - const current = status.deposits.find(({ id }) => id === deposit.depositId); - assert.ok(current, 'Quoted deposit must appear in status'); - assert.ok(!['failed', 'refunded'].includes(current.state), 'Funding failed'); - return current.state === 'settled'; - }, 'Funding did not settle within the polling budget'); + await until( + () => command('status'), + (status) => { + const current = status.deposits.find( + ({ id }) => id === deposit.depositId, + ); + assert.ok(current, 'Quoted deposit must appear in status'); + assert.ok( + !['failed', 'refunded'].includes(current.state), + 'Funding failed', + ); + return current.state === 'settled'; + }, + 'Funding did not settle within the polling budget', + ); }); await stage('runner-readiness', async () => { const runner = await command('run', { detach: true }); @@ -96,99 +251,192 @@ export async function testnetLifecycle({ assert.equal(runner.ready, true); await command('config', { args: ['set', 'runnerFallback', 'false'] }); const status = await command('status'); - assert.equal(status.runner.signingSession, 'ready', 'Signing must route through the runner'); + assert.equal( + status.runner.signingSession, + 'ready', + 'Signing must route through the runner', + ); assert.equal(status.runner.wsState, 'connected'); }); - const stopFollowing = await startFollow(); - let oid; + const stopFollowing = await stage('events-follow-start', startFollow); + let initialOrder; + let followStopped = false; + let causalFailure = false; try { await stage('place-and-reconcile-resting-order', async () => { - const market = await command('market', { coin: 'BTC' }); - const mid = Number(market.mid); - const decimals = Number(market.szDecimals); - assert.ok(Number.isFinite(mid) && mid > 0 && Number.isInteger(decimals)); - // Size ~20 test USD at a post-only bid well below the market; never an IOC. - const price = String(Math.floor(mid * 0.7)); - const size = (Math.ceil(20 / Number(price) * 10 ** decimals) / 10 ** decimals) - .toFixed(decimals); - const cloid = '0x' + now().toString(16).padStart(32, '0'); - const placed = await command('order', { - coin: 'BTC', side: 'buy', size, price, tif: 'Alo', cloid, intentId: intent, - }); - oid = placed.result.response.data.statuses[0].resting?.oid; - assert.ok(oid, 'Expected a resting post-only test order, not a fill'); - await until(() => command('orders'), - (orders) => orders.some((order) => Number(order.oid) === oid && order.cloid === cloid), - 'Accepted order not visible on exchange'); + initialOrder = await placeRestingOrder(); }); await stage('modify-and-reconcile', async () => { - const [original] = (await command('orders')).filter((order) => Number(order.oid) === oid); + const [original] = (await command('orders')).filter( + (order) => + Number(order.oid) === initialOrder.oid && + order.cloid === initialOrder.cloid, + ); assert.ok(original, 'Order disappeared before modification'); - const price = String(Math.floor(Number(original.limitPx) * 0.98)); - await command('modify', { modifications: [{ oid, price }], intentId: intent }); - const orders = await until(() => command('orders'), - (orders) => orders.some((order) => Number(order.limitPx) === Number(price)), - 'Modified price not visible on exchange'); - oid = Number(orders.find((order) => Number(order.limitPx) === Number(price)).oid); + const price = discountedPrice( + original.limitPx, + 9800, + initialOrder.decimals, + ); + await command('modify', { + modifications: [{ oid: initialOrder.oid, price }], + intentId: intent, + }); + const matches = (order) => + order.cloid === initialOrder.cloid && + order.coin === 'BTC' && + decimalEqual(order.limitPx, price); + const orders = await until( + () => command('orders'), + (orders) => orders.some(matches), + 'Modified price not visible on exchange', + ); + initialOrder.oid = Number(orders.find(matches).oid); + assert.ok( + Number.isSafeInteger(initialOrder.oid) && initialOrder.oid > 0, + 'Invalid modified order ID', + ); + initialOrder.oids.add(String(initialOrder.oid)); }); await stage('cancel-and-reconcile', async () => { - await command('cancel', { oid, intentId: intent }); - await until(() => command('orders'), (orders) => orders.length === 0, - 'Cancellation not visible on exchange'); + await command('cancel', { oid: initialOrder.oid, intentId: intent }); + await until( + () => command('orders'), + (orders) => orders.length === 0, + 'Cancellation not visible on exchange', + ); }); await stage('websocket-events-and-replay', async () => { - const events = await until(() => command('events', { since: 0 }), - (events) => events.some((event) => event.type === 'order_update') && + const events = await until( + () => command('events', { since: 0 }), + (events) => + hasOrderUpdates(events) && events.some((event) => event.type === 'runner_state'), - 'No real order-update and runner events observed'); - assert.ok(events.every((event, i) => !i || event.seq > events[i - 1].seq)); + 'Missing correlated open/canceled order updates and runner events', + ); + assert.ok( + events.every((event, i) => !i || event.seq > events[i - 1].seq), + ); const cursor = events[Math.floor(events.length / 2)].seq; const replay = await command('events', { since: cursor }); - assert.deepEqual(replay.filter((event) => event.seq <= events.at(-1).seq), - events.filter((event) => event.seq > cursor)); + assert.deepEqual( + replay.filter((event) => event.seq <= events.at(-1).seq), + events.filter((event) => event.seq > cursor), + ); }); - await stage('rotate-and-revoke-old-agent', async () => { - const pending = await command('rotate', { intentId: intent }); - const swapped = await command('rotate', { - signature: await sign(pending.approval), intentId: intent, - }); - assert.equal(swapped.swapped, true); - assert.notEqual(swapped.newAgent, swapped.oldAgent); - const revoked = await command('rotate', { - revokeSignature: await sign(swapped.revokeApproval), intentId: intent, - }); - assert.equal(revoked.oldRevoked, true); - await until(() => info('extraAgents', { user: masterAddress }), - (agents) => agents.some((agent) => agent.address.toLowerCase() === swapped.newAgent.toLowerCase()) && - !agents.some((agent) => agent.address.toLowerCase() === swapped.oldAgent.toLowerCase()), - 'Rotation not confirmed on exchange'); - assert.equal((await command('status')).runner.signingSession, 'ready'); + const rotatedAddress = await stage( + 'rotate-and-revoke-old-agent', + async () => { + const pending = await command('rotate', { intentId: intent }); + await remember(pending.newAgent, pending.approval); + const swapped = await command('rotate', { + signature: await sign(pending.approval), + intentId: intent, + }); + assert.equal(swapped.swapped, true); + assert.notEqual(swapped.newAgent, swapped.oldAgent); + const revoked = await command('rotate', { + revokeSignature: await sign(swapped.revokeApproval), + intentId: intent, + }); + assert.equal(revoked.oldRevoked, true); + await until( + () => info('extraAgents', { user: masterAddress }), + (agents) => + agents.some( + (agent) => + agent.address.toLowerCase() === swapped.newAgent.toLowerCase(), + ) && + !agents.some( + (agent) => + agent.address.toLowerCase() === swapped.oldAgent.toLowerCase(), + ), + 'Rotation not confirmed on exchange', + ); + assert.equal((await command('status')).runner.signingSession, 'ready'); + return swapped.newAgent; + }, + ); + await stage('post-rotation-order-and-cancel', async () => { + assert.equal( + (await command('status')).agent.address.toLowerCase(), + rotatedAddress.toLowerCase(), + ); + const order = await placeRestingOrder(); + await command('cancel', { oid: order.oid, intentId: intent }); + await until( + () => command('orders'), + (orders) => orders.length === 0, + 'Post-rotation cancellation not visible on exchange', + ); + await until( + () => command('events', { since: 0 }), + (events) => hasOrderUpdates(events), + 'Missing post-rotation open/canceled order updates', + ); }); await stage('revoke-and-confirm-exchange-state', async () => { - const pending = await command('revoke', { cancelOrders: true, intentId: intent }); + const pending = await command('revoke', { + cancelOrders: true, + intentId: intent, + }); assert.equal(pending.locallyRevoked, true); const revoked = await command('revoke', { - signature: await sign(pending.approval), intentId: intent, + signature: await sign(pending.approval), + intentId: intent, }); assert.equal(revoked.exchangeRevoked, true); - await until(() => command('status'), (status) => - status.agent.revoked && !status.agent.approved && - status.runner.signingSession === 'unavailable', - 'Final revocation not visible locally and on exchange'); + await until( + () => command('status'), + (status) => + status.agent.revoked && + !status.agent.approved && + status.runner.signingSession === 'unavailable', + 'Final revocation not visible locally and on exchange', + ); assert.deepEqual(await command('orders'), []); }); await stage('audit-integrity-and-intent-linkage', async () => { // The audit command verifies the entire hash chain before filtering. const entries = await command('audit', { intent }); for (const action of ['order', 'modify', 'cancel', 'rotate', 'revoke']) { - assert.ok(entries.some((entry) => entry.stage === 'intent' && entry.data.action === action), - `Missing ${action} audit intent`); - assert.ok(entries.some((entry) => entry.stage === 'response' && entry.data.action === action), - `Missing ${action} audit response`); + assert.ok( + entries.some( + (entry) => entry.stage === 'intent' && entry.data.action === action, + ), + `Missing ${action} audit intent`, + ); + assert.ok( + entries.some( + (entry) => + entry.stage === 'response' && entry.data.action === action, + ), + `Missing ${action} audit response`, + ); } - assert.ok(entries.some((entry) => entry.stage === 'signature'), 'Missing signing audit evidence'); + assert.ok( + entries.some((entry) => entry.stage === 'signature'), + 'Missing signing audit evidence', + ); + }); + await stage('events-follow-verification', async () => { + followStopped = true; + const events = await stopFollowing(); + assert.ok( + hasOrderUpdates(events), + 'Follower did not deliver correlated open/canceled updates for both orders', + ); }); + } catch (error) { + causalFailure = true; + throw error; } finally { - await stopFollowing(); + if (!followStopped) { + try { + await stopFollowing(); + } catch (error) { + if (!causalFailure) throw error; + } + } } } diff --git a/packages/agent-cli/scripts/testnet-report.mjs b/packages/agent-cli/scripts/testnet-report.mjs new file mode 100644 index 0000000..ea4cd07 --- /dev/null +++ b/packages/agent-cli/scripts/testnet-report.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; + +// Keep this dependency-free: hosted summaries must work even if npm ci failed. +export const lifecycleStages = Object.freeze([ + 'test-account-preflight', + 'configure', + 'init-agent-and-builder-approvals', + 'mock-relay-funding-and-settlement', + 'runner-readiness', + 'events-follow-start', + 'place-and-reconcile-resting-order', + 'modify-and-reconcile', + 'cancel-and-reconcile', + 'websocket-events-and-replay', + 'rotate-and-revoke-old-agent', + 'post-rotation-order-and-cancel', + 'revoke-and-confirm-exchange-state', + 'audit-integrity-and-intent-linkage', + 'events-follow-verification', +]); +const stageNames = new Set(lifecycleStages); +const failureNames = new Set([ + ...lifecycleStages, + 'testnet-preflight', + 'testnet-cleanup', + 'testnet-artifacts', + 'testnet-runner-shutdown', + 'testnet-report-verification', +]); +const stageStatuses = new Set(['running', 'passed', 'failed']); +const cleanupStatuses = new Set(['not-started', 'failed', 'verified']); +const services = Object.freeze([ + ['hyperliquid', 'Hyperliquid testnet'], + ['privy', 'Privy dedicated test app'], + ['relay', 'Relay zero-value mock'], +]); +const serviceEvidence = { + hyperliquid: 'test-account-preflight', + privy: 'init-agent-and-builder-approvals', + relay: 'mock-relay-funding-and-settlement', +}; +const resources = ['orders', 'agents', 'positions']; +const object = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export function verifyTestnetReport(report) { + assert.ok(object(report), 'Lifecycle report must be an object'); + assert.equal( + report.schemaVersion, + 2, + 'Lifecycle report schemaVersion must be 2', + ); + assert.equal( + report.network, + 'testnet', + 'Lifecycle report must target testnet', + ); + assert.ok( + ['passed', 'failed'].includes(report.status), + 'Invalid lifecycle status', + ); + assert.ok(Array.isArray(report.stages), 'Lifecycle stages must be an array'); + for (const stage of report.stages) { + assert.ok( + object(stage) && stageNames.has(stage.name), + 'Unknown lifecycle stage', + ); + assert.ok( + stageStatuses.has(stage.status), + 'Invalid lifecycle stage status', + ); + } + assert.ok(object(report.coverage), 'Service coverage is required'); + for (const [service] of services) { + assert.ok( + object(report.coverage[service]), + 'Service coverage entry is required', + ); + assert.equal( + typeof report.coverage[service].verified, + 'boolean', + 'Service verification must be boolean', + ); + if (report.status !== 'passed' && report.coverage[service].verified) { + const evidence = report.stages.filter( + (stage) => stage.name === serviceEvidence[service], + ); + assert.ok( + evidence.length > 0 && + evidence.at(-1).status === 'passed' && + !evidence.some((stage) => stage.status === 'failed'), + 'Verified service coverage requires a passed evidence stage', + ); + } + } + assert.ok( + object(report.cleanup) && cleanupStatuses.has(report.cleanup.status), + 'Invalid cleanup status', + ); + for (const resource of resources) { + const count = report.cleanup[resource]; + if ( + (count !== undefined && count !== null) || + report.cleanup.status === 'verified' + ) + assert.ok( + Number.isSafeInteger(count) && count >= 0, + 'Cleanup counts must be nonnegative integers', + ); + if (report.cleanup.status === 'verified') + assert.equal(count, 0, 'Verified cleanup must leave no test resources'); + } + // This report proves a deterministic lifecycle only. Independent coding-agent + // sessions require separate evidence and must never be inferred from its pass. + assert.ok( + object(report.agentSession), + 'Independent agent session status is required', + ); + assert.equal( + report.agentSession.status, + 'not-run', + 'A deterministic report cannot verify an independent agent session', + ); + if (report.status === 'passed') { + for (const name of lifecycleStages) { + const events = report.stages.filter((stage) => stage.name === name); + assert.ok( + events.length > 0, + 'Passed lifecycle is missing a required stage', + ); + assert.ok( + !events.some((stage) => stage.status === 'failed'), + 'Passed lifecycle contains a failed stage', + ); + assert.equal( + events.at(-1).status, + 'passed', + 'Passed lifecycle contains an incomplete stage', + ); + } + for (const [service] of services) + assert.equal( + report.coverage[service].verified, + true, + 'Passed lifecycle has unverified service coverage', + ); + assert.equal( + report.cleanup.status, + 'verified', + 'Passed lifecycle has unverified cleanup', + ); + } + return report; +} + +function unprovenSummary() { + return [ + 'Deterministic testnet lifecycle: **unproven**.', + '', + 'No valid schemaVersion 2 lifecycle report was available.', + '', + ...services.map(([, label]) => `- ${label}: **unverified**.`), + '- Cleanup: **unverified**.', + '- Independent agent session: **unverified**.', + '', + 'Live Relay coverage and an independent AI-agent session are unproven.', + '', + ].join('\n'); +} + +export function formatTestnetSummary(report) { + try { + verifyTestnetReport(report); + } catch { + return unprovenSummary(); + } + const stageOutcome = (name) => { + const events = report.stages.filter((stage) => stage.name === name); + return events.some((stage) => stage.status === 'failed') + ? 'failed' + : (events.at(-1)?.status ?? 'not-run'); + }; + const cleanup = + report.cleanup.status === 'verified' + ? 'verified (0 orders, 0 agents, 0 positions)' + : `${report.cleanup.status} (unverified)`; + // Only fixed labels, known enums and booleans reach CI output. Never echo raw + // errors, reasons, account metadata, report paths or arbitrary service modes. + return [ + `Deterministic testnet lifecycle: **${report.status}**.`, + '', + ...(report.status === 'failed' + ? [ + `- Failure: **${failureNames.has(report.failure) ? report.failure : 'unclassified'}**.`, + ] + : []), + ...services.map( + ([key, label]) => + `- ${label}: **${report.coverage[key].verified ? 'verified' : 'unverified'}**.`, + ), + `- 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.', + '', + '| Lifecycle stage | Outcome |', + '| --- | --- |', + ...lifecycleStages.map((name) => `| ${name} | ${stageOutcome(name)} |`), + '', + ].join('\n'); +} diff --git a/packages/agent-cli/scripts/testnet-runner.mjs b/packages/agent-cli/scripts/testnet-runner.mjs new file mode 100644 index 0000000..69b9cac --- /dev/null +++ b/packages/agent-cli/scripts/testnet-runner.mjs @@ -0,0 +1,183 @@ +import { lstat, readFile } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; +import { createConnection } from 'node:net'; +import { performance } from 'node:perf_hooks'; + +async function inspect(path) { + try { + return await lstat(path); + } catch (error) { + if (error.code === 'ENOENT') return undefined; + throw new Error('Runner state could not be inspected'); + } +} +async function readRunner(path) { + const stat = await inspect(path); + if (!stat) return undefined; + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16_384) + throw new Error('Runner PID metadata must be a regular bounded file'); + let runner; + try { + runner = JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + // A genuine shutdown can unlink the file between lstat and readFile. + if (error.code === 'ENOENT') return undefined; + throw new Error('Runner PID metadata could not be read'); + } + if ( + !runner || + typeof runner !== 'object' || + Array.isArray(runner) || + !Number.isSafeInteger(runner.pid) || + runner.pid <= 0 || + runner.pid === process.pid || + runner.network !== 'testnet' || + !Number.isSafeInteger(runner.startedAt) || + runner.startedAt <= 0 + ) + throw new Error( + 'Runner PID metadata does not identify a separate testnet process', + ); + return { + pid: runner.pid, + network: runner.network, + startedAt: runner.startedAt, + }; +} +async function inspectSocket(path) { + const stat = await inspect(path); + if (stat && (!stat.isSocket() || stat.isSymbolicLink())) + throw new Error('Runner socket path is not a Unix socket'); + return Boolean(stat); +} +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw new Error('Original runner process could not be inspected'); + } +} +function requestShutdown(path, timeoutMs) { + return new Promise((resolve) => { + let socket; + let timer; + let settled = false; + let buffer = ''; + function finish() { + if (settled) return; + settled = true; + clearTimeout(timer); + socket?.destroy(); + resolve(); + } + // RPC delivery/acknowledgement alone is never proof of shutdown. Even a + // connection failure is followed by independent file and process checks. + try { + socket = createConnection(path); + timer = setTimeout(finish, Math.max(1, timeoutMs)); + socket.once('connect', () => { + socket.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'shutdown', + params: {}, + }) + '\n', + ); + }); + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { + buffer += chunk; + if (Buffer.byteLength(buffer) > 16_384 || buffer.includes('\n')) + finish(); + }); + socket.once('error', finish); + socket.once('close', finish); + } catch { + finish(); + } + }); +} + +export async function stopTestnetRunner({ + directory, + timeoutMs = 5000, + expectedPid, + isAlive = processIsAlive, +}) { + if (typeof directory !== 'string' || !isAbsolute(directory)) + throw new Error('Runner state directory must be an absolute path'); + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > 60_000 || + typeof isAlive !== 'function' + ) + throw new Error( + 'Runner shutdown needs a bounded positive timeout and process probe', + ); + if ( + expectedPid !== undefined && + (!Number.isSafeInteger(expectedPid) || + expectedPid <= 0 || + expectedPid === process.pid) + ) + throw new Error( + 'Expected runner PID must identify a separate positive integer process', + ); + const deadline = performance.now() + timeoutMs; + const pidPath = join(directory, 'runner.pid'); + const socketPath = join(directory, 'run.sock'); + const original = await readRunner(pidPath); + const socketExists = await inspectSocket(socketPath); + if (original && expectedPid !== undefined && original.pid !== expectedPid) + throw new Error('Runner PID metadata does not match the expected process'); + const trackedPid = expectedPid ?? original?.pid; + if (trackedPid === undefined) { + if (!socketExists) return; + throw new Error('Runner socket has no trusted PID metadata'); + } + async function alive() { + let result; + try { + result = await isAlive(trackedPid); + } catch { + throw new Error('Original runner process could not be inspected'); + } + if (typeof result !== 'boolean') + throw new Error('Original runner process liveness is unproven'); + return result; + } + const initiallyAlive = await alive(); + if (socketExists && initiallyAlive) + await requestShutdown( + socketPath, + Math.max(1, deadline - performance.now()), + ); + for (;;) { + const current = await readRunner(pidPath); + const socketRemains = await inspectSocket(socketPath); + if ( + current && + (current.pid !== trackedPid || + (original && current.startedAt !== original.startedAt)) + ) + throw new Error('Tracked runner changed during shutdown'); + const stillAlive = await alive(); + if (!current && !socketRemains && !stillAlive) return; + if (!initiallyAlive) + throw new Error( + 'Stale runner state remains after the original process stopped', + ); + const remaining = deadline - performance.now(); + if (remaining <= 0) + throw new Error( + 'Runner shutdown could not be verified within its time limit', + ); + await new Promise((resolve) => + setTimeout(resolve, Math.min(25, remaining)), + ); + } +} diff --git a/packages/agent-cli/scripts/testnet-signers.mjs b/packages/agent-cli/scripts/testnet-signers.mjs new file mode 100644 index 0000000..17597c9 --- /dev/null +++ b/packages/agent-cli/scripts/testnet-signers.mjs @@ -0,0 +1,325 @@ +import { execFile } from 'node:child_process'; +import { accessSync, constants, statSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { getAddress, recoverTypedDataAddress } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { + ApproveAgentTypes, + buildApproveAgent, +} from '@bloxwap/hyperliquid/api/exchange/approveAgent'; +import { + ApproveBuilderFeeTypes, + buildApproveBuilderFee, +} from '@bloxwap/hyperliquid/api/exchange/approveBuilderFee'; + +// Retain this ordering for legacy preflight reports and known-secret scanning. +export const requiredTestnetSecrets = [ + 'BLOXWAP_TEST_MASTER_KEY', + 'BLOXWAP_PRIVY_APP_ID', + 'BLOXWAP_PRIVY_APP_SECRET', +]; +const externalNames = ['BLOXWAP_TEST_MASTER_ADDRESS', 'BLOXWAP_TEST_SIGNER']; +const zeroAddress = '0x' + '0'.repeat(40); +const testnetDomain = { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 42161, + verifyingContract: zeroAddress, +}; + +function configured(value) { + return typeof value === 'string' && value.trim().length > 0; +} +function externalMode(env) { + return externalNames.some((name) => configured(env[name])); +} +export function requiredTestnetEnvironmentNames(env = process.env) { + return externalMode(env) + ? [...externalNames, 'BLOXWAP_PRIVY_APP_ID', 'BLOXWAP_PRIVY_APP_SECRET'] + : [...requiredTestnetSecrets]; +} +export function missingTestnetConfiguration(env = process.env) { + return requiredTestnetEnvironmentNames(env).filter( + (name) => !configured(env[name]), + ); +} + +export function requireTestnetEnvironment(env = process.env) { + if (env.BLOXWAP_E2E !== '1') + throw new Error('Explicit BLOXWAP_E2E=1 is required'); + const external = externalMode(env); + if (external && configured(env.BLOXWAP_TEST_MASTER_KEY)) + throw new Error( + 'External-wallet mode cannot be combined with BLOXWAP_TEST_MASTER_KEY', + ); + if (!external && !['true', '1'].includes(env.CI)) + throw new Error('Disposable company test master-key signing is CI-only'); + if (env.BLOXWAP_TEST_ACCOUNT_ONLY !== '1') + throw new Error( + 'Explicit BLOXWAP_TEST_ACCOUNT_ONLY=1 disposable test-account acknowledgement is required', + ); + const missing = missingTestnetConfiguration(env); + if (missing.length) + throw new Error(`Missing testnet configuration: ${missing.join(', ')}`); + if (!external) return { mode: 'ci-master-key' }; + + let address; + try { + if (!/^0x[0-9a-fA-F]{40}$/.test(env.BLOXWAP_TEST_MASTER_ADDRESS)) + throw new Error('Invalid address'); + address = getAddress(env.BLOXWAP_TEST_MASTER_ADDRESS); + if (address === zeroAddress) throw new Error('Invalid address'); + } catch { + throw new Error( + 'BLOXWAP_TEST_MASTER_ADDRESS must be a nonzero public Ethereum address', + ); + } + const executable = env.BLOXWAP_TEST_SIGNER; + if (!isAbsolute(executable)) + throw new Error( + 'BLOXWAP_TEST_SIGNER must be an absolute executable path without arguments', + ); + try { + if (!statSync(executable).isFile()) throw new Error('Not a file'); + accessSync(executable, constants.X_OK); + } catch { + throw new Error( + 'BLOXWAP_TEST_SIGNER must name an accessible executable file', + ); + } + return { mode: 'external', address, executable }; +} + +function exactKeys(value, keys) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + isDeepStrictEqual(Object.keys(value).sort(), [...keys].sort()) + ); +} + +// Validate the actual action and its canonical EIP-712 representation before +// asking any wallet to sign. This adapter can never sign trades or withdrawals. +export function validateTestnetApproval(approval) { + try { + if (!exactKeys(approval, ['action', 'typedData'])) throw new Error(); + const { action, typedData } = approval; + if ( + !exactKeys(typedData, ['domain', 'types', 'primaryType', 'message']) || + !isDeepStrictEqual(typedData.domain, testnetDomain) || + action.hyperliquidChain !== 'Testnet' || + action.signatureChainId !== '0xa4b1' || + !Number.isSafeInteger(action.nonce) || + action.nonce < 0 + ) + throw new Error(); + let types; + if (action.type === 'approveAgent') { + if ( + !exactKeys(action, [ + 'type', + 'signatureChainId', + 'hyperliquidChain', + 'agentAddress', + 'agentName', + 'nonce', + ]) + ) + throw new Error(); + buildApproveAgent({ + agentAddress: action.agentAddress, + agentName: action.agentName, + }); + if (typeof action.agentName !== 'string') throw new Error(); + types = ApproveAgentTypes; + } else if (action.type === 'approveBuilderFee') { + if ( + !exactKeys(action, [ + 'type', + 'signatureChainId', + 'hyperliquidChain', + 'maxFeeRate', + 'builder', + 'nonce', + ]) + ) + throw new Error(); + buildApproveBuilderFee({ + maxFeeRate: action.maxFeeRate, + builder: action.builder, + }); + types = ApproveBuilderFeeTypes; + } else throw new Error(); + const message = Object.fromEntries( + Object.entries(action).filter( + ([name]) => !['type', 'signatureChainId'].includes(name), + ), + ); + if ( + !isDeepStrictEqual(typedData.types, types) || + typedData.primaryType !== Object.keys(types)[0] || + !isDeepStrictEqual(typedData.message, message) + ) + throw new Error(); + return approval; + } catch { + // SDK errors can embed submitted data; never relay them to logs/artifacts. + throw new Error( + 'Refusing malformed approval or action outside canonical Hyperliquid testnet approvals', + ); + } +} + +export function createTestnetRevocation(name, nonce) { + const action = { + type: 'approveAgent', + signatureChainId: '0xa4b1', + hyperliquidChain: 'Testnet', + agentAddress: zeroAddress, + agentName: name, + nonce, + }; + const approval = { + action, + typedData: { + domain: { ...testnetDomain }, + types: structuredClone(ApproveAgentTypes), + primaryType: 'HyperliquidTransaction:ApproveAgent', + message: { + hyperliquidChain: 'Testnet', + agentAddress: zeroAddress, + agentName: name, + nonce, + }, + }, + }; + return validateTestnetApproval(approval); +} + +function externalSignature(executable, request, env, options) { + // The external wallet needs the public approval, not the QA Privy credentials + // or the separate CI master-key mode. No shell or command-line payload is used. + const signerEnv = { ...env }; + for (const name of requiredTestnetSecrets) delete signerEnv[name]; + return new Promise((resolve, reject) => { + const child = execFile( + executable, + [], + { + env: signerEnv, + shell: false, + encoding: 'utf8', + windowsHide: true, + timeout: options.timeoutMs, + maxBuffer: options.maxBuffer, + killSignal: 'SIGKILL', + }, + (error, stdout) => { + if (error) + return reject( + new Error( + 'External test signer failed or exceeded its time/output limit', + ), + ); + try { + const response = JSON.parse(stdout); + if ( + !exactKeys(response, ['signature']) || + typeof response.signature !== 'string' + ) + throw new Error(); + resolve(response.signature); + } catch { + reject( + new Error( + 'External test signer must return one JSON object containing only signature', + ), + ); + } + }, + ); + child.stdin.on('error', () => {}); + child.stdin.end(JSON.stringify(request)); + }); +} + +export async function createTestnetSigner( + config, + env = process.env, + options = {}, +) { + const verified = requireTestnetEnvironment(env); + if (!isDeepStrictEqual(config, verified)) + throw new Error( + 'Test signer configuration does not match the explicit environment', + ); + const limits = { timeoutMs: 60_000, maxBuffer: 16_384, ...options }; + if ( + !Number.isSafeInteger(limits.timeoutMs) || + limits.timeoutMs < 1 || + limits.timeoutMs > 60_000 || + !Number.isSafeInteger(limits.maxBuffer) || + limits.maxBuffer < 1 || + limits.maxBuffer > 65_536 + ) + throw new Error( + 'Test signer time/output limits must be positive bounded integers', + ); + let account; + if (verified.mode === 'ci-master-key') { + try { + account = privateKeyToAccount(env.BLOXWAP_TEST_MASTER_KEY); + } catch { + throw new Error('Invalid disposable CI test master key'); + } + } + const address = verified.address ?? account.address; + return { + mode: verified.mode, + address, + async sign(approval) { + let snapshot; + try { + snapshot = structuredClone(approval); + } catch { + throw new Error('Refusing malformed test approval'); + } + validateTestnetApproval(snapshot); + const signature = account + ? await account.signTypedData(snapshot.typedData) + : await externalSignature( + verified.executable, + { + schemaVersion: 1, + network: 'testnet', + address, + action: snapshot.action, + typedData: snapshot.typedData, + }, + env, + limits, + ); + let recovered; + try { + if ( + !/^0x[0-9a-fA-F]{130}$/.test(signature) || + ![0, 1, 27, 28].includes(Number.parseInt(signature.slice(-2), 16)) + ) + throw new Error(); + recovered = await recoverTypedDataAddress({ + ...snapshot.typedData, + signature, + }); + } catch { + throw new Error('Test signer returned an invalid EIP-712 signature'); + } + if (recovered.toLowerCase() !== address.toLowerCase()) + throw new Error( + 'Test signer signature does not match the test master address', + ); + return signature; + }, + }; +} diff --git a/packages/agent-cli/scripts/testnet-summary.mjs b/packages/agent-cli/scripts/testnet-summary.mjs new file mode 100644 index 0000000..a44b7e9 --- /dev/null +++ b/packages/agent-cli/scripts/testnet-summary.mjs @@ -0,0 +1,27 @@ +import { appendFileSync, readFileSync } from 'node:fs'; +import { + formatTestnetSummary, + verifyTestnetReport, +} from './testnet-report.mjs'; + +let report; +let passed = false; +try { + report = JSON.parse(readFileSync(process.argv[2], 'utf8')); + verifyTestnetReport(report); + passed = report.status === 'passed'; +} catch { + // Missing artifacts, malformed JSON and unverifiable metadata all fail closed. + // Do not write a raw filesystem/validation error that may contain secrets. + report = undefined; +} +const summary = formatTestnetSummary(report); +try { + if (process.env.GITHUB_STEP_SUMMARY) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + else process.stdout.write(summary); +} catch { + process.stdout.write(summary); + passed = false; +} +process.exitCode = passed ? 0 : 1; diff --git a/packages/agent-cli/src/core/skill-content.ts b/packages/agent-cli/src/core/skill-content.ts index 9d3ec33..ecd1f4d 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.\nIt never transfers real 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 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"; diff --git a/packages/agent-cli/test/e2e-harness.test.js b/packages/agent-cli/test/e2e-harness.test.js index 308206a..dc3673b 100644 --- a/packages/agent-cli/test/e2e-harness.test.js +++ b/packages/agent-cli/test/e2e-harness.test.js @@ -1,31 +1,90 @@ import { expect, test, vi } from 'vitest'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { requireTestnetEnvironment, testnetLifecycle } from '../scripts/testnet-lifecycle.mjs'; +import { + requireTestnetEnvironment, + testnetLifecycle, +} from '../scripts/testnet-lifecycle.mjs'; +import { verifyTestnetReport } from '../scripts/testnet-report.mjs'; function fixture(overrides = {}) { + const oldAgent = '0x' + '1'.repeat(40); + const newAgent = '0x' + '2'.repeat(40); let orders = []; + let nextOid = 1; let rotated = false; let revoked = false; let started = false; let polls = 0; - const stop = vi.fn(async () => {}); + const journal = [ + { seq: 1, type: 'runner_state', data: { wsState: 'connected' } }, + ]; + const update = (order, status) => + journal.push({ + seq: journal.length + 1, + type: 'order_update', + data: [ + { + order: { ...order, oid: Number(order.oid) }, + status, + statusTimestamp: 123456789, + }, + ], + }); + const stop = vi.fn(async () => structuredClone(journal)); const checkpoint = vi.fn(); - const approvals = { agent: { kind: 'agent', typedData: {} }, builder: { kind: 'builder', typedData: {} } }; + const approvals = { + agent: { + kind: 'agent', + action: { + agentAddress: oldAgent, + agentName: 'bloxwap valid_until 9999999999999', + }, + typedData: {}, + }, + builder: { kind: 'builder', typedData: {} }, + }; const command = vi.fn(async (name, input = {}) => { if (name === 'config') return {}; - if (name === 'init') return { - approved: !!input.builderSignature, approvals, - }; - if (name === 'fund') return { depositId: 'fixture-deposit', expectedReceived: '0', qrAscii: 'QR' }; - if (name === 'status') return { - deposits: [{ id: 'fixture-deposit', state: ++polls > 1 ? 'settled' : 'pending' }], - runner: started ? { signingSession: revoked ? 'unavailable' : 'ready', wsState: 'connected' } : null, - agent: { approved: !revoked, revoked }, - }; + if (name === 'init') + return { + agentAddress: oldAgent, + approved: !!input.builderSignature, + approvals, + }; + if (name === 'fund') + return { + depositId: 'fixture-deposit', + expectedReceived: '0', + qrAscii: 'QR', + }; + if (name === 'status') + return { + deposits: [ + { id: 'fixture-deposit', state: ++polls > 1 ? 'settled' : 'pending' }, + ], + runner: started + ? { + signingSession: revoked ? 'unavailable' : 'ready', + wsState: 'connected', + } + : null, + agent: { + address: rotated ? newAgent : oldAgent, + approved: !revoked, + revoked, + }, + }; if (name === 'run') { expect(polls).toBeGreaterThan(1); started = true; @@ -33,48 +92,97 @@ function fixture(overrides = {}) { } if (name === 'market') return { mid: '100000', szDecimals: '5' }; if (name === 'order') { - orders = [{ oid: '1', cloid: input.cloid, limitPx: input.price }]; - return { result: { response: { data: { statuses: [{ resting: { oid: 1 } }] } } } }; + const oid = nextOid++; + orders = [ + { + coin: 'BTC', + oid: String(oid), + cloid: input.cloid, + limitPx: input.price, + }, + ]; + update(orders[0], 'open'); + return { + result: { response: { data: { statuses: [{ resting: { oid } }] } } }, + }; } if (name === 'orders') return orders; if (name === 'modify') { expect(input.modifications).toHaveLength(1); - orders = [{ ...orders[0], oid: '2', limitPx: input.modifications[0].price }]; + update(orders[0], 'canceled'); + orders = [ + { + ...orders[0], + oid: String(nextOid++), + limitPx: input.modifications[0].price, + }, + ]; + update(orders[0], 'open'); return {}; } - if (name === 'cancel') { expect(input.oid).toBe(2); orders = []; return {}; } - if (name === 'events') return [ - { seq: 1, type: 'runner_state' }, { seq: 2, type: 'order_update' }, - ].filter((event) => event.seq > (input.since ?? 0)); + if (name === 'cancel') { + expect(input.oid).toBe(Number(orders[0].oid)); + update(orders[0], 'canceled'); + orders = []; + return {}; + } + if (name === 'events') + return structuredClone( + journal.filter((event) => event.seq > (input.since ?? 0)), + ); if (name === 'rotate') { if (input.signature) rotated = true; return { - oldAgent: '0xold', newAgent: '0xnew', swapped: rotated, + oldAgent, + newAgent, + swapped: rotated, oldRevoked: !!input.revokeSignature, - approval: { kind: 'rotate' }, revokeApproval: { kind: 'old-revoke' }, + approval: { + kind: 'rotate', + action: { + agentAddress: newAgent, + agentName: 'blox-next valid_until 9999999999999', + }, + }, + revokeApproval: { kind: 'old-revoke' }, }; } if (name === 'revoke') { revoked = true; - return { locallyRevoked: true, exchangeRevoked: !!input.signature, approval: { kind: 'final-revoke' } }; + return { + locallyRevoked: true, + exchangeRevoked: !!input.signature, + approval: { kind: 'final-revoke' }, + }; } - if (name === 'audit') return [ - ...['order', 'modify', 'cancel', 'rotate', 'revoke'].flatMap((action) => - ['intent', 'response'].map((stage) => ({ stage, data: { action } }))), - { stage: 'signature' }, - ]; + if (name === 'audit') + return [ + ...['order', 'modify', 'cancel', 'rotate', 'revoke'].flatMap((action) => + ['intent', 'response'].map((stage) => ({ stage, data: { action } })), + ), + { stage: 'signature' }, + ]; throw new Error(`Unexpected fixture command: ${name}`); }); return { - command, checkpoint, stop, masterAddress: '0xtest', appId: 'test-app', + command, + checkpoint, + stop, + rememberAgent: vi.fn(), + masterAddress: '0xtest', + appId: 'test-app', sign: vi.fn(async (approval) => `${approval.kind}-signature`), info: vi.fn(async (type) => { - if (type === 'clearinghouseState') return { withdrawable: '30', assetPositions: [] }; + if (type === 'clearinghouseState') + return { withdrawable: '30', assetPositions: [] }; if (type === 'frontendOpenOrders') return []; - if (type === 'extraAgents') return rotated && !revoked ? [{ address: '0xnew' }] : []; + if (type === 'extraAgents') + return rotated && !revoked ? [{ address: newAgent }] : []; throw new Error(`Unexpected fixture info: ${type}`); }), - startFollow: vi.fn(async () => stop), wait: vi.fn(async () => {}), now: () => 123456789, + startFollow: vi.fn(async () => stop), + wait: vi.fn(async () => {}), + now: () => 123456789, ...overrides, }; } @@ -83,55 +191,301 @@ test('offline lifecycle driver verifies both approvals, settlement, reconciled w const f = fixture(); await testnetLifecycle(f); expect(f.sign.mock.calls.map(([approval]) => approval.kind)).toEqual([ - 'agent', 'builder', 'rotate', 'old-revoke', 'final-revoke', + 'agent', + 'builder', + 'rotate', + 'old-revoke', + 'final-revoke', ]); - expect(f.command.mock.calls.find(([name]) => name === 'order')[1]).toMatchObject({ tif: 'Alo' }); + expect( + f.command.mock.calls.find(([name]) => name === 'order')[1], + ).toMatchObject({ tif: 'Alo' }); expect(f.wait).toHaveBeenCalledWith(1000); - expect(f.checkpoint.mock.calls.filter(([stage]) => stage.status === 'passed')).toHaveLength(12); + expect( + f.checkpoint.mock.calls.filter(([stage]) => stage.status === 'passed'), + ).toHaveLength(15); + expect(f.rememberAgent.mock.calls.map(([agent]) => agent)).toEqual([ + { address: '0x' + '1'.repeat(40), name: 'bloxwap' }, + { address: '0x' + '2'.repeat(40), name: 'blox-next' }, + ]); + const rotation = f.command.mock.calls.findIndex( + ([name, input]) => name === 'rotate' && input.revokeSignature, + ); + expect( + f.command.mock.calls + .slice(rotation + 1) + .filter(([name]) => name === 'order'), + ).toHaveLength(1); expect(f.stop).toHaveBeenCalledOnce(); }); test('unsettled funding stops the workflow within the documented polling budget', async () => { const f = fixture(); const command = f.command; - f.command = vi.fn(async (name, input) => name === 'status' - ? { deposits: [{ id: 'fixture-deposit', state: 'pending' }] } - : command(name, input)); + f.command = vi.fn(async (name, input) => + name === 'status' + ? { deposits: [{ id: 'fixture-deposit', state: 'pending' }] } + : command(name, input), + ); await expect(testnetLifecycle(f)).rejects.toThrow('polling budget'); - expect(f.command.mock.calls.filter(([name]) => name === 'status')).toHaveLength(12); - expect(f.command.mock.calls.some(([name]) => ['run', 'order'].includes(name))).toBe(false); + expect( + f.command.mock.calls.filter(([name]) => name === 'status'), + ).toHaveLength(12); + expect( + f.command.mock.calls.some(([name]) => ['run', 'order'].includes(name)), + ).toBe(false); }); test('a successful envelope without exchange acceptance cannot complete the lifecycle', async () => { const f = fixture(); const command = f.command; - f.command = vi.fn(async (name, input) => name === 'order' - ? { result: { response: { data: { statuses: [{ error: 'rejected' }] } } } } - : command(name, input)); + f.command = vi.fn(async (name, input) => + name === 'order' + ? { + result: { response: { data: { statuses: [{ error: 'rejected' }] } } }, + } + : command(name, input), + ); await expect(testnetLifecycle(f)).rejects.toThrow('resting post-only'); expect(f.command.mock.calls.some(([name]) => name === 'rotate')).toBe(false); expect(f.stop).toHaveBeenCalledOnce(); }); test('a funded but non-disposable account is rejected before any write', async () => { - const f = fixture({ info: async (type) => type === 'clearinghouseState' - ? { withdrawable: '30', assetPositions: [{ position: { szi: '0.1' } }] } : [] }); + const f = fixture({ + info: async (type) => + type === 'clearinghouseState' + ? { withdrawable: '30', assetPositions: [{ position: { szi: '0.1' } }] } + : [], + }); await expect(testnetLifecycle(f)).rejects.toThrow('no positions'); expect(f.command).not.toHaveBeenCalled(); }); +test.each([null, '', 0, '0.' + '0'.repeat(400) + '1'])( + 'invalid or nonzero position state fails preflight (%#)', + async (szi) => { + const f = fixture({ + info: async (type) => + type === 'clearinghouseState' + ? { withdrawable: '30', assetPositions: [{ position: { szi } }] } + : [], + }); + await expect(testnetLifecycle(f)).rejects.toThrow('no positions'); + expect(f.command).not.toHaveBeenCalled(); + }, +); + +test('public run-owned agents are recorded before either approval signer is called', async () => { + const f = fixture(); + f.sign.mockImplementation(async (approval) => { + if (['agent', 'rotate'].includes(approval.kind)) { + const address = approval.action.agentAddress; + expect( + f.rememberAgent.mock.calls.some(([agent]) => agent.address === address), + ).toBe(true); + } + return `${approval.kind}-signature`; + }); + await testnetLifecycle(f); +}); + +test('exchange price precision is applied before placement, modification and reconciliation', async () => { + const f = fixture(); + const command = f.command; + f.command = vi.fn(async (name, input) => + name === 'market' + ? { mid: '10249.99', szDecimals: '5' } + : command(name, input), + ); + await testnetLifecycle(f); + expect(f.command.mock.calls.find(([name]) => name === 'order')[1].price).toBe( + '7174.9', + ); + expect( + f.command.mock.calls.find(([name]) => name === 'modify')[1].modifications[0] + .price, + ).toBe('7031.4'); +}); + +test.each([ + [], + [ + { seq: 1, type: 'runner_state' }, + { seq: 2, type: 'order_update', data: [] }, + ], + [ + { seq: 1, type: 'runner_state' }, + { + seq: 2, + type: 'order_update', + data: [ + { order: { coin: 'BTC', oid: 9876 }, status: 'open' }, + { order: { coin: 'BTC', oid: 9876 }, status: 'canceled' }, + ], + }, + ], +])( + 'empty or unrelated journal updates cannot pass WebSocket verification (%#)', + async (events) => { + const f = fixture(); + const command = f.command; + f.command = vi.fn(async (name, input) => + name === 'events' ? events : command(name, input), + ); + await expect(testnetLifecycle(f)).rejects.toThrow('Missing correlated'); + expect( + f.checkpoint.mock.calls + .filter(([stage]) => stage.status === 'failed') + .map(([stage]) => stage.name), + ).toEqual(['websocket-events-and-replay']); + expect(f.command.mock.calls.some(([name]) => name === 'rotate')).toBe( + false, + ); + }, +); + +test('modification cancellation cannot substitute for the final order cancellation event', async () => { + const f = fixture(); + const command = f.command; + f.command = vi.fn(async (name, input) => { + const result = await command(name, input); + return name === 'events' + ? result.map((event) => + event.type === 'order_update' + ? { + ...event, + data: event.data.filter( + (update) => + !(update.status === 'canceled' && update.order.oid === 2), + ), + } + : event, + ) + : result; + }); + await expect(testnetLifecycle(f)).rejects.toThrow('Missing correlated'); + expect(f.command.mock.calls.some(([name]) => name === 'rotate')).toBe(false); +}); + +test('a matching price on an unrelated order cannot prove modification', async () => { + const f = fixture(); + const command = f.command; + let modified = false; + f.command = vi.fn(async (name, input) => { + const result = await command(name, input); + if (name === 'modify') modified = true; + return name === 'orders' && modified + ? result.map((order) => ({ ...order, cloid: '0x' + 'f'.repeat(32) })) + : result; + }); + await expect(testnetLifecycle(f)).rejects.toThrow( + 'Modified price not visible', + ); + expect(f.command.mock.calls.some(([name]) => name === 'cancel')).toBe(false); +}); + +test('the rotated agent must place and cancel a real resting order after old-agent revocation', async () => { + const f = fixture(); + const command = f.command; + let oldRevoked = false; + f.command = vi.fn(async (name, input) => { + if (name === 'rotate' && input.revokeSignature) oldRevoked = true; + if (name === 'order' && oldRevoked) + throw new Error('New agent cannot sign'); + return command(name, input); + }); + await expect(testnetLifecycle(f)).rejects.toThrow('New agent cannot sign'); + expect( + f.checkpoint.mock.calls + .filter(([stage]) => stage.status === 'failed') + .map(([stage]) => stage.name), + ).toEqual(['post-rotation-order-and-cancel']); +}); + +test('follower verification has its own failed stage and rejects unrelated streamed events', async () => { + const f = fixture(); + f.stop.mockResolvedValue([{ seq: 1, type: 'order_update', data: [] }]); + await expect(testnetLifecycle(f)).rejects.toThrow( + 'Follower did not deliver correlated', + ); + expect( + f.checkpoint.mock.calls + .filter(([stage]) => stage.status === 'failed') + .map(([stage]) => stage.name), + ).toEqual(['events-follow-verification']); + expect(f.stop).toHaveBeenCalledOnce(); +}); + +test('follower cleanup cannot replace the causal exchange rejection', async () => { + const f = fixture(); + f.stop.mockRejectedValue(new Error('Follower cleanup failed')); + const command = f.command; + f.command = vi.fn(async (name, input) => { + if (name === 'order') throw new Error('Exchange rejected test order'); + return command(name, input); + }); + await expect(testnetLifecycle(f)).rejects.toThrow( + 'Exchange rejected test order', + ); + expect( + f.checkpoint.mock.calls + .filter(([stage]) => stage.status === 'failed') + .map(([stage]) => stage.name), + ).toEqual(['place-and-reconcile-resting-order']); + expect(f.stop).toHaveBeenCalledOnce(); +}); + test('credential absence fails the actual entrypoint with a failure artifact and no secret output', async () => { const cwd = await mkdtemp(join(tmpdir(), 'bloxwap-e2e-report-')); try { - const env = { ...process.env, CI: 'true', BLOXWAP_E2E: '1', BLOXWAP_TEST_ACCOUNT_ONLY: '1' }; - for (const name of ['BLOXWAP_TEST_MASTER_KEY', 'BLOXWAP_PRIVY_APP_ID', 'BLOXWAP_PRIVY_APP_SECRET']) delete env[name]; - const result = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/e2e-testnet.mjs', import.meta.url))], - { cwd, env, encoding: 'utf8' }); + await mkdir(join(cwd, 'artifacts')); + for (const name of [ + 'lifecycle-report.json', + 'events.jsonl', + 'events-follow.jsonl', + 'audit.jsonl', + 'runner-old.jsonl', + ]) + await writeFile(join(cwd, 'artifacts', name), 'stale evidence'); + const env = { + ...process.env, + CI: 'true', + BLOXWAP_E2E: '1', + BLOXWAP_TEST_ACCOUNT_ONLY: '1', + }; + for (const name of [ + 'BLOXWAP_TEST_MASTER_KEY', + 'BLOXWAP_TEST_MASTER_ADDRESS', + 'BLOXWAP_TEST_SIGNER', + 'BLOXWAP_PRIVY_APP_ID', + 'BLOXWAP_PRIVY_APP_SECRET', + ]) + delete env[name]; + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL('../scripts/e2e-testnet.mjs', import.meta.url))], + { cwd, env, encoding: 'utf8' }, + ); expect(result.status).toBe(1); expect(result.stdout).not.toMatch(/SKIP|PASS/); - const report = JSON.parse(await readFile(join(cwd, 'artifacts/lifecycle-report.json'), 'utf8')); - expect(report).toMatchObject({ status: 'failed', failure: 'testnet-preflight', stages: [] }); - expect(report.missingSecrets).toEqual(['BLOXWAP_TEST_MASTER_KEY', 'BLOXWAP_PRIVY_APP_ID', 'BLOXWAP_PRIVY_APP_SECRET']); + const report = JSON.parse( + await readFile(join(cwd, 'artifacts/lifecycle-report.json'), 'utf8'), + ); + expect(() => verifyTestnetReport(report)).not.toThrow(); + expect(report).toMatchObject({ + status: 'failed', + failure: 'testnet-preflight', + stages: [], + }); + expect(report.missingSecrets).toEqual([ + 'BLOXWAP_TEST_MASTER_KEY', + 'BLOXWAP_PRIVY_APP_ID', + 'BLOXWAP_PRIVY_APP_SECRET', + ]); + expect(await readdir(join(cwd, 'artifacts'))).toEqual([ + 'lifecycle-report.json', + ]); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -139,6 +493,10 @@ test('credential absence fails the actual entrypoint with a failure artifact and test('CI-only test credentials and explicit test account acknowledgement are mandatory', () => { expect(() => requireTestnetEnvironment({})).toThrow('BLOXWAP_E2E'); - expect(() => requireTestnetEnvironment({ BLOXWAP_E2E: '1' })).toThrow('CI-only'); - expect(() => requireTestnetEnvironment({ BLOXWAP_E2E: '1', CI: 'true' })).toThrow('disposable'); + expect(() => requireTestnetEnvironment({ BLOXWAP_E2E: '1' })).toThrow( + 'CI-only', + ); + expect(() => + requireTestnetEnvironment({ BLOXWAP_E2E: '1', CI: 'true' }), + ).toThrow('disposable'); }); diff --git a/packages/agent-cli/test/testnet-cleanup.test.js b/packages/agent-cli/test/testnet-cleanup.test.js new file mode 100644 index 0000000..be92350 --- /dev/null +++ b/packages/agent-cli/test/testnet-cleanup.test.js @@ -0,0 +1,139 @@ +import { expect, test, vi } from 'vitest'; +import { cleanupTestnetAccount } from '../scripts/testnet-cleanup.mjs'; + +const owned = [ + { address: '0x' + '1'.repeat(40), name: 'bloxwap' }, + { address: '0x' + '2'.repeat(40), name: 'blox-next' }, +]; +function fixture({ approvals = owned, orders = [], positions = [] } = {}) { + let remaining = structuredClone(approvals); + let open = structuredClone(orders); + return { + masterAddress: 'test-master', + agents: owned, + info: vi.fn(async (type) => { + if (type === 'extraAgents') return remaining; + if (type === 'frontendOpenOrders') return open; + if (type === 'clearinghouseState') return { assetPositions: positions }; + throw new Error('Unexpected query'); + }), + cancel: vi.fn(async ({ oid }) => { + open = open.filter((order) => order.oid !== oid); + }), + revoke: vi.fn(async ({ address }) => { + remaining = remaining.filter((agent) => agent.address !== address); + }), + }; +} + +test.each([owned, [owned[0]], [owned[1]]].map((approvals) => [approvals]))( + 'partial init/rotation cleanup revokes every approved run-owned agent (%#)', + async (approvals) => { + const f = fixture({ approvals, orders: [{ oid: 12 }] }); + expect(await cleanupTestnetAccount(f)).toEqual({ + status: 'verified', + orders: 0, + agents: 0, + positions: 0, + }); + expect(f.revoke.mock.calls.map(([agent]) => agent)).toEqual(approvals); + expect(f.cancel.mock.invocationCallOrder[0]).toBeLessThan( + f.revoke.mock.invocationCallOrder[0], + ); + }, +); + +test('an unrecognized address or name is left untouched and prevents cleanup success', async () => { + const unknown = { address: '0x' + '3'.repeat(40), name: 'another-wallet' }; + const mismatched = { ...owned[0], name: 'other-name' }; + const f = fixture({ approvals: [unknown, mismatched, owned[1]] }); + expect(await cleanupTestnetAccount(f)).toEqual({ + status: 'failed', + orders: 0, + agents: 2, + positions: 0, + }); + expect(f.revoke.mock.calls.map(([agent]) => agent)).toEqual([owned[1]]); +}); + +test('a failed old-agent revocation still attempts the new-agent cleanup and final verification', async () => { + const f = fixture(); + const revoke = f.revoke; + f.revoke = vi.fn(async (agent) => { + if (agent.address === owned[0].address) throw new Error('Rejected'); + return revoke(agent); + }); + expect(await cleanupTestnetAccount(f)).toEqual({ + status: 'failed', + orders: 0, + agents: 1, + positions: 0, + }); + expect(f.revoke).toHaveBeenCalledTimes(2); + expect(f.info).toHaveBeenCalledWith('clearinghouseState', { + user: f.masterAddress, + }); +}); + +test('unavailable local cancellation never bypasses revocation and leaves explicit failure', async () => { + const f = fixture({ orders: [{ oid: 9 }] }); + f.cancel.mockRejectedValue(new Error('Agent already locally revoked')); + expect(await cleanupTestnetAccount(f)).toEqual({ + status: 'failed', + orders: 1, + agents: 0, + positions: 0, + }); + expect(f.revoke).toHaveBeenCalledTimes(2); +}); + +test('nonzero positions fail cleanup even after orders and approvals disappear', async () => { + const f = fixture({ + positions: [{ position: { szi: '-0.001' } }, { position: { szi: '0' } }], + }); + expect(await cleanupTestnetAccount(f)).toEqual({ + status: 'failed', + orders: 0, + agents: 0, + positions: 1, + }); +}); + +test('unreadable exchange state cannot be reported as empty and other cleanup continues', async () => { + const f = fixture(); + const info = f.info; + f.info = vi.fn(async (type, params) => { + if (type === 'frontendOpenOrders') throw new Error('Offline'); + return info(type, params); + }); + expect(await cleanupTestnetAccount(f)).toEqual({ + status: 'failed', + agents: 0, + positions: 0, + }); + expect(f.revoke).toHaveBeenCalledTimes(2); +}); + +test.each(['not-a-number', null, '', 0])( + 'malformed position state prevents verification (%s)', + async (szi) => { + const f = fixture({ positions: [{ position: { szi } }] }); + expect(await cleanupTestnetAccount(f)).toEqual({ + status: 'failed', + orders: 0, + agents: 0, + }); + }, +); + +test('tiny nonzero positions cannot underflow to zero during cleanup', async () => { + const f = fixture({ + positions: [{ position: { szi: '0.' + '0'.repeat(400) + '1' } }], + }); + expect(await cleanupTestnetAccount(f)).toEqual({ + status: 'failed', + orders: 0, + agents: 0, + positions: 1, + }); +}); diff --git a/packages/agent-cli/test/testnet-follower.test.js b/packages/agent-cli/test/testnet-follower.test.js new file mode 100644 index 0000000..04e4fb3 --- /dev/null +++ b/packages/agent-cli/test/testnet-follower.test.js @@ -0,0 +1,87 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { startEventFollower } from '../scripts/testnet-follower.mjs'; + +const followers = []; +afterEach(async () => { + await Promise.all( + followers.splice(0).map((follower) => follower.stop().catch(() => {})), + ); +}); +function start(source, options = {}) { + const follower = startEventFollower({ + command: process.execPath, + args: ['-e', source], + timeoutMs: 200, + ...options, + }); + followers.push(follower); + return follower; +} +async function ready(follower) { + await vi.waitFor(() => expect(follower.text).toContain('ready')); +} + +test('stopping drains trailing stdout through close and is idempotent', async () => { + const follower = start(` + process.on('SIGTERM', () => { process.stdout.write(JSON.stringify({tail:'x'.repeat(262144)})+'\\n', () => process.exit(0)); }); + console.log(JSON.stringify({ready:true})); + setInterval(() => {}, 1000); + `); + await ready(follower); + const stopped = follower.stop(); + expect(follower.stop()).toBe(stopped); + const events = await stopped; + expect(events).toHaveLength(2); + expect(events[1].tail).toHaveLength(262144); +}); + +test('a child already terminated by signal rejects promptly rather than waiting for another exit', async () => { + const follower = start( + `console.log(JSON.stringify({ready:true})); process.kill(process.pid, 'SIGKILL');`, + ); + await ready(follower); + // The signal has been delivered before stop is requested. + await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(follower.stop()).rejects.toThrow('unexpectedly'); +}); + +test('an unrequested successful early exit is still a follower failure', async () => { + const follower = start(`console.log(JSON.stringify({ready:true}));`); + await ready(follower); + await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(follower.stop()).rejects.toThrow('unexpectedly'); +}); + +test('malformed streamed JSON fails after a graceful shutdown', async () => { + const follower = start(` + process.on('SIGTERM', () => process.exit(0)); + console.log('ready but not JSON'); setInterval(() => {}, 1000); + `); + await ready(follower); + await expect(follower.stop()).rejects.toThrow('malformed JSON'); +}); + +test('output overflow terminates the follower without retaining unbounded output', async () => { + const follower = start( + `process.on('SIGTERM', () => process.stdout.write('x'.repeat(100000))); console.log('ready'); setInterval(() => {}, 1000);`, + { maxBytes: 32 }, + ); + await ready(follower); + await expect(follower.stop()).rejects.toThrow('output limit'); + expect(Buffer.byteLength(follower.text)).toBeLessThanOrEqual(32); +}); + +test('a follower ignoring SIGTERM is killed within the bounded stop budget', async () => { + const follower = start( + `process.on('SIGTERM', () => {}); console.log(JSON.stringify({ready:true})); setInterval(() => {}, 1000);`, + ); + await ready(follower); + await expect(follower.stop()).rejects.toThrow('time limit'); +}); + +test('spawn errors settle without waiting forever', async () => { + const follower = start('', { + command: '/not-a-real-bloxwap-test-executable', + }); + await expect(follower.stop()).rejects.toThrow('could not start'); +}); diff --git a/packages/agent-cli/test/testnet-report.test.js b/packages/agent-cli/test/testnet-report.test.js new file mode 100644 index 0000000..06e7775 --- /dev/null +++ b/packages/agent-cli/test/testnet-report.test.js @@ -0,0 +1,309 @@ +import { spawnSync } from 'node:child_process'; +import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from 'vitest'; +import { + formatTestnetSummary, + lifecycleStages, + verifyTestnetReport, +} from '../scripts/testnet-report.mjs'; + +const complete = () => ({ + schemaVersion: 2, + status: 'passed', + network: 'testnet', + coverage: { + hyperliquid: { mode: 'live testnet', verified: true }, + privy: { mode: 'live dedicated test app', verified: true }, + relay: { + mode: 'mocked zero-value route; pre-funded collateral', + verified: true, + }, + }, + stages: lifecycleStages.flatMap((name) => [ + { name, status: 'running' }, + { name, status: 'passed' }, + ]), + cleanup: { status: 'verified', orders: 0, agents: 0, positions: 0 }, + agentSession: { status: 'not-run', reason: 'Deterministic lifecycle only' }, +}); + +const failed = () => ({ + ...complete(), + status: 'failed', + stages: [], + coverage: { + hyperliquid: { verified: false }, + privy: { verified: false }, + relay: { verified: false }, + }, + cleanup: { status: 'not-started' }, +}); + +test('a complete report validates without treating the script as an independent agent session', () => { + const report = complete(); + expect(lifecycleStages).toHaveLength(15); + expect(new Set(lifecycleStages).size).toBe(15); + expect(verifyTestnetReport(report)).toBe(report); + const summary = formatTestnetSummary(report); + expect(summary).toContain('Deterministic testnet lifecycle: **passed**'); + expect(summary).toContain( + 'Independent agent session: **not-run (unverified)**', + ); + expect(summary).toContain('live Relay remains unverified'); + expect(summary).toContain('verified (0 orders, 0 agents, 0 positions)'); + for (const name of lifecycleStages) + expect(summary).toContain(`| ${name} | passed |`); +}); + +test.each(lifecycleStages)( + 'a passed report cannot omit required stage %s', + (name) => { + const report = complete(); + report.stages = report.stages.filter((stage) => stage.name !== name); + expect(() => verifyTestnetReport(report)).toThrow( + 'missing a required stage', + ); + }, +); + +test('a passed report cannot hide a failed stage with a later passed event', () => { + const report = complete(); + report.stages.splice(1, 0, { name: lifecycleStages[0], status: 'failed' }); + expect(() => verifyTestnetReport(report)).toThrow('failed stage'); +}); + +test('a passed report cannot leave a stage running', () => { + const report = complete(); + report.stages.pop(); + expect(() => verifyTestnetReport(report)).toThrow('incomplete stage'); +}); + +test.each(['hyperliquid', 'privy', 'relay'])( + 'a passed report requires verified %s coverage', + (name) => { + for (const value of [false, undefined, 'true', 1]) { + const report = complete(); + report.coverage[name].verified = value; + expect(() => verifyTestnetReport(report)).toThrow(); + } + }, +); + +test.each(['orders', 'agents', 'positions'])( + 'verified cleanup requires a zero %s count', + (name) => { + for (const value of [1, -1, undefined, null, '0', 0.5, Infinity]) { + const report = complete(); + report.cleanup[name] = value; + expect(() => verifyTestnetReport(report)).toThrow(); + } + }, +); + +test.each(['not-started', 'failed'])( + 'a passed report cannot use %s cleanup', + (status) => { + const report = complete(); + report.cleanup = { status }; + expect(() => verifyTestnetReport(report)).toThrow('unverified cleanup'); + }, +); + +test('a failed preflight remains a valid failed report with explicitly unverified coverage', () => { + const report = failed(); + 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('Privy dedicated test app: **unverified**'); + expect(summary).toContain('Relay zero-value mock: **unverified**'); + expect(summary).toContain('Cleanup: **not-started (unverified)**'); + expect(summary).not.toMatch(/\| .* \| passed \|/); +}); + +test('a failed report preserves partial verified coverage and failed stage outcomes', () => { + const report = failed(); + report.coverage.hyperliquid.verified = true; + report.stages = [ + { name: lifecycleStages[0], status: 'passed' }, + { name: lifecycleStages[1], status: 'failed' }, + ]; + report.cleanup = { status: 'failed' }; + const summary = formatTestnetSummary(report); + expect(summary).toContain('Hyperliquid testnet: **verified**'); + expect(summary).toContain('Privy dedicated test app: **unverified**'); + expect(summary).toContain(`| ${lifecycleStages[0]} | passed |`); + expect(summary).toContain(`| ${lifecycleStages[1]} | failed |`); +}); + +test('failed cleanup can record unreadable resource counts as null without claiming verification', () => { + const report = failed(); + report.cleanup = { + status: 'failed', + orders: null, + agents: null, + positions: null, + }; + expect(verifyTestnetReport(report)).toBe(report); + expect(formatTestnetSummary(report)).toContain( + 'Cleanup: **failed (unverified)**', + ); +}); + +test.each(['hyperliquid', 'privy', 'relay'])( + 'failed reports cannot claim verified %s without evidence', + (service) => { + const report = failed(); + report.coverage[service].verified = true; + expect(() => verifyTestnetReport(report)).toThrow('passed evidence stage'); + expect(formatTestnetSummary(report)).toContain('lifecycle: **unproven**'); + }, +); + +test('invalid metadata never produces a passed summary', () => { + const cases = [ + undefined, + null, + [], + {}, + { ...complete(), schemaVersion: 1 }, + { ...complete(), network: 'mainnet' }, + { ...complete(), status: 'secret-raw-error' }, + { ...complete(), stages: {} }, + { ...complete(), coverage: null }, + { ...complete(), cleanup: 'verified' }, + { ...complete(), cleanup: { status: 'secret-raw-error' } }, + { ...complete(), agentSession: { status: 'passed' } }, + { ...complete(), agentSession: null }, + { ...complete(), stages: [{ name: 'secret-raw-error', status: 'passed' }] }, + { + ...complete(), + stages: [{ name: lifecycleStages[0], status: 'secret-raw-error' }], + }, + ]; + for (const report of cases) { + expect(() => verifyTestnetReport(report)).toThrow(); + const summary = formatTestnetSummary(report); + expect(summary).toContain('lifecycle: **unproven**'); + expect(summary).not.toContain('secret-raw-error'); + expect(summary).not.toContain('**passed**'); + } +}); + +test('freeform metadata and reasons never reach CI output', () => { + const report = complete(); + const secret = 'sentinel-secret-never-output'; + report.failure = secret; + report.accountOwnership = secret; + report.commit = secret; + report.missingSecrets = [secret]; + report.agentSession.reason = secret; + report.coverage.hyperliquid.mode = secret; + report.stages[0].error = secret; + expect(formatTestnetSummary(report)).not.toContain(secret); +}); + +test('failed summaries expose only whitelisted lifecycle and finalization failure names', () => { + for (const failure of [ + ...lifecycleStages, + 'testnet-preflight', + 'testnet-cleanup', + 'testnet-artifacts', + 'testnet-runner-shutdown', + 'testnet-report-verification', + ]) { + expect(formatTestnetSummary({ ...failed(), failure })).toContain( + `Failure: **${failure}**`, + ); + } + const summary = formatTestnetSummary({ + ...failed(), + failure: 'sentinel-secret-raw-sdk-error', + }); + expect(summary).toContain('Failure: **unclassified**'); + expect(summary).not.toContain('sentinel-secret'); +}); + +const summaryCli = fileURLToPath( + new URL('../scripts/testnet-summary.mjs', import.meta.url), +); +async function withCli(action) { + const directory = await mkdtemp(join(tmpdir(), 'bloxwap-testnet-summary-')); + try { + await copyFile(summaryCli, join(directory, 'testnet-summary.mjs')); + await copyFile( + fileURLToPath(new URL('../scripts/testnet-report.mjs', import.meta.url)), + join(directory, 'testnet-report.mjs'), + ); + await action(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} +function runCli(path, env = {}) { + const childEnv = { ...process.env, ...env }; + if (!('GITHUB_STEP_SUMMARY' in env)) delete childEnv.GITHUB_STEP_SUMMARY; + return spawnSync( + process.execPath, + [join(dirname(path), 'testnet-summary.mjs'), path], + { + cwd: dirname(path), + encoding: 'utf8', + env: childEnv, + }, + ); +} + +test('summary CLI appends a verified pass and succeeds without installed dependencies', async () => { + await withCli(async (directory) => { + const path = join(directory, 'report.json'); + const summary = join(directory, 'summary.md'); + await writeFile(path, JSON.stringify(complete())); + await writeFile(summary, 'Earlier summary\n'); + const result = runCli(path, { GITHUB_STEP_SUMMARY: summary }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + expect(await readFile(summary, 'utf8')).toBe( + 'Earlier summary\n' + formatTestnetSummary(complete()), + ); + }); +}); + +test('summary CLI fails closed for missing, malformed and falsely passed reports', async () => { + await withCli(async (directory) => { + const path = join(directory, 'sentinel-secret-report.json'); + for (const text of [ + undefined, + 'sentinel-secret-not-json', + JSON.stringify({ ...complete(), stages: [] }), + JSON.stringify(failed()), + ]) { + if (text !== undefined) await writeFile(path, text); + const result = runCli(path); + expect(result.status).toBe(1); + expect(result.stdout).toContain( + text === JSON.stringify(failed()) + ? 'lifecycle: **failed**' + : 'lifecycle: **unproven**', + ); + expect(result.stdout + result.stderr).not.toContain('sentinel-secret'); + } + }); +}); + +test('summary CLI rejects an unwritable summary destination without leaking its path', async () => { + await withCli(async (directory) => { + const path = join(directory, 'report.json'); + await writeFile(path, JSON.stringify(complete())); + const result = runCli(path, { + GITHUB_STEP_SUMMARY: join(directory, 'sentinel-secret', 'summary.md'), + }); + expect(result.status).toBe(1); + expect(result.stdout).toContain('lifecycle: **passed**'); + expect(result.stderr + result.stdout).not.toContain('sentinel-secret'); + }); +}); diff --git a/packages/agent-cli/test/testnet-runner.test.js b/packages/agent-cli/test/testnet-runner.test.js new file mode 100644 index 0000000..6f9ee7e --- /dev/null +++ b/packages/agent-cli/test/testnet-runner.test.js @@ -0,0 +1,284 @@ +import { createServer } from 'node:net'; +import { + mkdtemp, + readFile, + rm, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { expect, test, vi } from 'vitest'; +import { stopTestnetRunner } from '../scripts/testnet-runner.mjs'; + +const fakePid = process.pid + 100_000; +const runner = () => ({ pid: fakePid, network: 'testnet', startedAt: 1 }); +async function fixture(action) { + const directory = await mkdtemp(join(tmpdir(), 'bloxwap-runner-stop-')); + const clients = new Set(); + let server; + const pidPath = join(directory, 'runner.pid'); + const socketPath = join(directory, 'run.sock'); + async function listen(handler) { + server = createServer((socket) => { + clients.add(socket); + socket.once('close', () => clients.delete(socket)); + socket.on('error', () => {}); + socket.setEncoding('utf8'); + let text = ''; + socket.on('data', (chunk) => { + text += chunk; + if (text.includes('\n')) + handler(socket, JSON.parse(text.split('\n')[0])); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + return server; + } + try { + await action({ directory, pidPath, socketPath, listen }); + } finally { + for (const client of clients) client.destroy(); + if (server) await new Promise((resolve) => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); + } +} + +test('no runner PID or socket returns without inspecting an unknown process', async () => { + await fixture(async ({ directory }) => { + const isAlive = vi.fn(); + await expect( + stopTestnetRunner({ directory, timeoutMs: 100, isAlive }), + ).resolves.toBeUndefined(); + expect(isAlive).not.toHaveBeenCalled(); + }); +}); + +test('normal RPC shutdown verifies both state paths and original process disappearance', async () => { + await fixture(async ({ directory, pidPath, listen }) => { + await writeFile(pidPath, JSON.stringify(runner())); + let live = true; + let shutdown; + const server = await listen((socket, request) => { + shutdown = request; + socket.end( + JSON.stringify({ jsonrpc: '2.0', id: 1, result: { stopping: true } }) + + '\n', + ); + void unlink(pidPath).then(() => { + live = false; + server.close(); + }); + }); + const isAlive = vi.fn(() => live); + await expect( + stopTestnetRunner({ directory, timeoutMs: 1000, isAlive }), + ).resolves.toBeUndefined(); + expect(shutdown).toEqual({ + jsonrpc: '2.0', + id: 1, + method: 'shutdown', + params: {}, + }); + expect(isAlive.mock.calls.every(([pid]) => pid === fakePid)).toBe(true); + await expect(readFile(pidPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); + +test('an expected PID that remains live cannot pass when both metadata paths are already gone', async () => { + await fixture(async ({ directory }) => { + const isAlive = vi.fn(() => true); + await expect( + stopTestnetRunner({ + directory, + expectedPid: fakePid, + timeoutMs: 100, + isAlive, + }), + ).rejects.toThrow('time limit'); + expect(isAlive.mock.calls.every(([pid]) => pid === fakePid)).toBe(true); + }); +}); + +test('an expected PID that is dead passes when both metadata paths are gone', async () => { + await fixture(async ({ directory }) => { + const isAlive = vi.fn(() => false); + await expect( + stopTestnetRunner({ + directory, + expectedPid: fakePid, + timeoutMs: 100, + isAlive, + }), + ).resolves.toBeUndefined(); + expect(isAlive).toHaveBeenCalledWith(fakePid); + }); +}); + +test('PID metadata that differs from the expected PID is rejected before probing either process', async () => { + await fixture(async ({ directory, pidPath }) => { + await writeFile(pidPath, JSON.stringify({ ...runner(), pid: fakePid + 1 })); + const isAlive = vi.fn(); + await expect( + stopTestnetRunner({ directory, expectedPid: fakePid, isAlive }), + ).rejects.toThrow('does not match the expected process'); + expect(isAlive).not.toHaveBeenCalled(); + }); +}); + +test.each([0, -1, 1.5, null, '123', process.pid])( + 'an invalid expected PID never reaches a process probe (%s)', + async (expectedPid) => { + await fixture(async ({ directory }) => { + const isAlive = vi.fn(); + await expect( + stopTestnetRunner({ directory, expectedPid, isAlive }), + ).rejects.toThrow('Expected runner PID'); + expect(isAlive).not.toHaveBeenCalled(); + }); + }, +); + +test('a dead original PID with a stale file fails without deleting that file', async () => { + await fixture(async ({ directory, pidPath }) => { + const data = JSON.stringify(runner()); + await writeFile(pidPath, data); + await expect( + stopTestnetRunner({ directory, timeoutMs: 100, isAlive: () => false }), + ).rejects.toThrow('Stale runner state'); + expect(await readFile(pidPath, 'utf8')).toBe(data); + }); +}); + +test('a socket without PID metadata is never sent a shutdown request', async () => { + await fixture(async ({ directory, listen }) => { + const request = vi.fn(); + await listen(request); + const isAlive = vi.fn(); + await expect( + stopTestnetRunner({ directory, timeoutMs: 100, isAlive }), + ).rejects.toThrow('no trusted PID'); + expect(request).not.toHaveBeenCalled(); + expect(isAlive).not.toHaveBeenCalled(); + }); +}); + +test('an unresponsive runner is bounded and does not remove state or force-kill', async () => { + await fixture(async ({ directory, pidPath, listen }) => { + await writeFile(pidPath, JSON.stringify(runner())); + const request = vi.fn(); + await listen(request); + const start = performance.now(); + await expect( + stopTestnetRunner({ directory, timeoutMs: 100, isAlive: () => true }), + ).rejects.toThrow('time limit'); + expect(performance.now() - start).toBeLessThan(1000); + expect(request).toHaveBeenCalledOnce(); + expect(JSON.parse(await readFile(pidPath, 'utf8'))).toEqual(runner()); + }); +}); + +test('RPC acknowledgement and removed paths cannot hide a still-live original PID', async () => { + await fixture(async ({ directory, pidPath, listen }) => { + await writeFile(pidPath, JSON.stringify(runner())); + const server = await listen((socket) => { + socket.end('{"jsonrpc":"2.0","id":1,"result":{"stopping":true}}\n'); + void unlink(pidPath).then(() => server.close()); + }); + await expect( + stopTestnetRunner({ directory, timeoutMs: 100, isAlive: () => true }), + ).rejects.toThrow('time limit'); + }); +}); + +test('a changed tracked PID stops verification without probing the replacement process', async () => { + await fixture(async ({ directory, pidPath, listen }) => { + await writeFile(pidPath, JSON.stringify(runner())); + const changed = { ...runner(), pid: fakePid + 1 }; + await listen((socket) => { + void writeFile(pidPath, JSON.stringify(changed)).then(() => + socket.end('{}\n'), + ); + }); + const isAlive = vi.fn(() => true); + await expect( + stopTestnetRunner({ directory, timeoutMs: 1000, isAlive }), + ).rejects.toThrow('changed during shutdown'); + expect(isAlive.mock.calls.every(([pid]) => pid === fakePid)).toBe(true); + }); +}); + +test.each([ + 'not JSON', + 'null', + '[]', + '{}', + JSON.stringify({ ...runner(), pid: 0 }), + JSON.stringify({ ...runner(), pid: -1 }), + JSON.stringify({ ...runner(), pid: String(fakePid) }), + JSON.stringify({ ...runner(), pid: 1.5 }), + JSON.stringify({ ...runner(), pid: process.pid }), + JSON.stringify({ ...runner(), network: 'mainnet' }), + JSON.stringify({ ...runner(), startedAt: null }), +])( + 'malformed or untrusted PID metadata is rejected before probing a process (%#)', + async (metadata) => { + await fixture(async ({ directory, pidPath }) => { + await writeFile(pidPath, metadata); + const isAlive = vi.fn(); + await expect( + stopTestnetRunner({ directory, timeoutMs: 100, isAlive }), + ).rejects.toThrow('Runner PID metadata'); + expect(isAlive).not.toHaveBeenCalled(); + }); + }, +); + +test('PID symlinks and regular files at the socket path are rejected', async () => { + await fixture(async ({ directory, pidPath, socketPath }) => { + const target = join(directory, 'metadata.json'); + await writeFile(target, JSON.stringify(runner())); + await symlink(target, pidPath); + await expect( + stopTestnetRunner({ directory, isAlive: () => true }), + ).rejects.toThrow('regular bounded file'); + await unlink(pidPath); + await writeFile(pidPath, JSON.stringify(runner())); + await writeFile(socketPath, 'not a socket'); + await expect( + stopTestnetRunner({ directory, isAlive: () => true }), + ).rejects.toThrow('not a Unix socket'); + }); +}); + +test('unreadable or unknown process liveness never counts as a stopped runner', async () => { + await fixture(async ({ directory, pidPath }) => { + await writeFile(pidPath, JSON.stringify(runner())); + await expect( + stopTestnetRunner({ + directory, + isAlive: () => { + throw new Error('sentinel-secret'); + }, + }), + ).rejects.toThrow('could not be inspected'); + await expect( + stopTestnetRunner({ directory, isAlive: () => undefined }), + ).rejects.toThrow('liveness is unproven'); + }); +}); + +test.each([0, -1, Infinity, NaN, 1.5, 60_001])( + 'invalid shutdown timeout fails immediately (%s)', + async (timeoutMs) => { + await fixture(async ({ directory }) => { + await expect(stopTestnetRunner({ directory, timeoutMs })).rejects.toThrow( + 'bounded positive timeout', + ); + }); + }, +); diff --git a/packages/agent-cli/test/testnet-signers.test.js b/packages/agent-cli/test/testnet-signers.test.js new file mode 100644 index 0000000..bd03fee --- /dev/null +++ b/packages/agent-cli/test/testnet-signers.test.js @@ -0,0 +1,399 @@ +import { afterEach, expect, test } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { recoverTypedDataAddress } from 'viem'; +import { ApproveBuilderFeeTypes } from '@bloxwap/hyperliquid/api/exchange/approveBuilderFee'; +import { approval as productionApproval } from '../src/wallet/approval.ts'; +import { + createTestnetRevocation, + createTestnetSigner, + missingTestnetConfiguration, + requiredTestnetEnvironmentNames, + requiredTestnetSecrets, + requireTestnetEnvironment, +} from '../scripts/testnet-signers.mjs'; + +const directories = []; +afterEach(async () => { + for (const directory of directories.splice(0)) + await rm(directory, { recursive: true, force: true }); +}); +async function fixture(source) { + const directory = await mkdtemp(join(tmpdir(), 'bloxwap-wallet-adapter-')); + directories.push(directory); + const executable = join(directory, 'user-wallet.mjs'); + await writeFile(executable, `#!${process.execPath}\n${source}\n`, { + mode: 0o700, + }); + return { directory, executable }; +} +function baseEnv(overrides = {}) { + return { + BLOXWAP_E2E: '1', + BLOXWAP_TEST_ACCOUNT_ONLY: '1', + BLOXWAP_PRIVY_APP_ID: 'qa-owned-app', + BLOXWAP_PRIVY_APP_SECRET: 'qa-owned-secret', + ...overrides, + }; +} +function externalEnv(account, executable, overrides = {}) { + return baseEnv({ + BLOXWAP_TEST_MASTER_ADDRESS: account.address, + BLOXWAP_TEST_SIGNER: executable, + ...overrides, + }); +} +function approval() { + const result = createTestnetRevocation('qa-agent', Date.now()); + result.action.agentAddress = '0x' + 'a'.repeat(40); + result.typedData.message.agentAddress = result.action.agentAddress; + return result; +} +function builderApproval() { + const result = approval(); + result.action = { + type: 'approveBuilderFee', + signatureChainId: '0xa4b1', + hyperliquidChain: 'Testnet', + maxFeeRate: '0.01%', + builder: '0x' + 'b'.repeat(40), + nonce: Date.now(), + }; + result.typedData.types = structuredClone(ApproveBuilderFeeTypes); + result.typedData.primaryType = 'HyperliquidTransaction:ApproveBuilderFee'; + result.typedData.message = { + hyperliquidChain: 'Testnet', + maxFeeRate: '0.01%', + builder: result.action.builder, + nonce: result.action.nonce, + }; + return result; +} + +test('preflight preserves legacy missing-name order and selects explicit external configuration', () => { + expect(requiredTestnetSecrets).toEqual([ + 'BLOXWAP_TEST_MASTER_KEY', + 'BLOXWAP_PRIVY_APP_ID', + 'BLOXWAP_PRIVY_APP_SECRET', + ]); + expect(missingTestnetConfiguration({})).toEqual(requiredTestnetSecrets); + expect( + requiredTestnetEnvironmentNames({ BLOXWAP_TEST_MASTER_ADDRESS: 'public' }), + ).toEqual([ + 'BLOXWAP_TEST_MASTER_ADDRESS', + 'BLOXWAP_TEST_SIGNER', + 'BLOXWAP_PRIVY_APP_ID', + 'BLOXWAP_PRIVY_APP_SECRET', + ]); + expect( + missingTestnetConfiguration({ BLOXWAP_TEST_SIGNER: '/wallet' }), + ).toEqual([ + 'BLOXWAP_TEST_MASTER_ADDRESS', + 'BLOXWAP_PRIVY_APP_ID', + 'BLOXWAP_PRIVY_APP_SECRET', + ]); + expect(() => requireTestnetEnvironment({})).toThrow('BLOXWAP_E2E'); + expect(() => requireTestnetEnvironment({ BLOXWAP_E2E: '1' })).toThrow( + 'CI-only', + ); + expect(() => + requireTestnetEnvironment({ BLOXWAP_E2E: '1', CI: 'true' }), + ).toThrow('disposable'); +}); + +test.each([ + ['agent', approval], + ['builder', builderApproval], +])( + 'external wallet signs the public %s approval without QA app secrets in its environment', + async (_kind, createApproval) => { + const account = privateKeyToAccount(generatePrivateKey()); + const pending = createApproval(); + const signature = await account.signTypedData(pending.typedData); + const { executable, directory } = await fixture(` +import {readFileSync, writeFileSync} from 'node:fs'; +const request = JSON.parse(readFileSync(0, 'utf8')); +writeFileSync(new URL('./request.json', import.meta.url), JSON.stringify({request, + hasPrivySecret: Object.hasOwn(process.env, 'BLOXWAP_PRIVY_APP_SECRET'), + hasMasterKey: Object.hasOwn(process.env, 'BLOXWAP_TEST_MASTER_KEY')})); +process.stdout.write(JSON.stringify({signature: ${JSON.stringify(signature)}})); +`); + const env = externalEnv(account, executable); + const config = requireTestnetEnvironment(env); + expect(config).toEqual({ + mode: 'external', + address: account.address, + executable, + }); + const signer = await createTestnetSigner(config, env); + expect(signer.mode).toBe('external'); + expect(await signer.sign(pending)).toBe(signature); + expect( + JSON.parse(await readFile(join(directory, 'request.json'), 'utf8')), + ).toEqual({ + request: { + schemaVersion: 1, + network: 'testnet', + address: account.address, + ...pending, + }, + hasPrivySecret: false, + hasMasterKey: false, + }); + }, +); + +test('CI-only company-disposable adapter produces real recoverable signatures for both approval types', async () => { + const key = generatePrivateKey(); + const env = baseEnv({ CI: 'true', BLOXWAP_TEST_MASTER_KEY: key }); + const signer = await createTestnetSigner(requireTestnetEnvironment(env), env); + expect(signer.mode).toBe('ci-master-key'); + expect(signer.address).toBe(privateKeyToAccount(key).address); + for (const pending of [ + approval(), + builderApproval(), + createTestnetRevocation('qa-agent', Date.now()), + ]) { + const signature = await signer.sign(pending); + expect( + await recoverTypedDataAddress({ ...pending.typedData, signature }), + ).toBe(signer.address); + } + delete env.CI; + await expect( + createTestnetSigner({ mode: 'ci-master-key' }, env), + ).rejects.toThrow('CI-only'); +}); + +test('signer accepts production approvals after the CLI JSON envelope roundtrip', async () => { + const env = baseEnv({ + CI: 'true', + BLOXWAP_TEST_MASTER_KEY: generatePrivateKey(), + }); + const signer = await createTestnetSigner(requireTestnetEnvironment(env), env); + const nonce = Date.now(); + const payloads = [ + productionApproval( + 'testnet', + 'approveAgent', + { + agentAddress: '0x' + 'a'.repeat(40), + agentName: `qa-agent valid_until ${nonce + 179 * 86_400_000}`, + }, + nonce, + ), + productionApproval( + 'testnet', + 'approveBuilderFee', + { + builder: '0x71b09A08257078A4d642F7Dd7315e656c837329a', + maxFeeRate: '0.01%', + }, + nonce + 1, + ), + productionApproval( + 'testnet', + 'approveAgent', + { + agentAddress: '0x' + '0'.repeat(40), + agentName: 'qa-agent', + }, + nonce + 2, + ), + ]; + for (const payload of payloads) { + const pending = JSON.parse(JSON.stringify(payload)); + const signature = await signer.sign(pending); + expect( + await recoverTypedDataAddress({ ...pending.typedData, signature }), + ).toBe(signer.address); + } +}); + +test('external mode requires both opt-ins, its public address, executable and user-owned Privy app', async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const { executable } = await fixture('process.stdout.write("{}");'); + const env = externalEnv(account, executable); + for (const name of [ + 'BLOXWAP_E2E', + 'BLOXWAP_TEST_ACCOUNT_ONLY', + 'BLOXWAP_TEST_MASTER_ADDRESS', + 'BLOXWAP_TEST_SIGNER', + 'BLOXWAP_PRIVY_APP_ID', + 'BLOXWAP_PRIVY_APP_SECRET', + ]) { + const missing = { ...env }; + delete missing[name]; + expect(() => requireTestnetEnvironment(missing)).toThrow(name); + } + expect(() => + requireTestnetEnvironment({ + ...env, + BLOXWAP_TEST_MASTER_KEY: generatePrivateKey(), + }), + ).toThrow('cannot be combined'); + expect(() => + requireTestnetEnvironment({ + ...env, + BLOXWAP_TEST_MASTER_ADDRESS: 'not-a-wallet', + }), + ).toThrow('public Ethereum address'); + expect(() => + requireTestnetEnvironment({ ...env, BLOXWAP_TEST_SIGNER: 'wallet --sign' }), + ).toThrow('absolute executable path'); + expect(() => + requireTestnetEnvironment({ + ...env, + BLOXWAP_TEST_SIGNER: '/missing/qa-wallet', + }), + ).toThrow('accessible executable'); +}); + +test('invalid testnet envelopes are refused before an external executable can run', async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const { executable, directory } = await fixture(` +import {writeFileSync} from 'node:fs'; +writeFileSync(new URL('./invoked', import.meta.url), 'unexpected'); +`); + const env = externalEnv(account, executable); + const signer = await createTestnetSigner(requireTestnetEnvironment(env), env); + const mutations = [ + (p) => { + p.action.type = 'withdraw3'; + }, + (p) => { + p.action.hyperliquidChain = 'Mainnet'; + p.typedData.message.hyperliquidChain = 'Mainnet'; + }, + (p) => { + p.typedData.domain.name = 'Exchange'; + }, + (p) => { + p.typedData.domain.version = '2'; + }, + (p) => { + p.typedData.domain.chainId = 1; + }, + (p) => { + p.typedData.domain.verifyingContract = '0x' + 'f'.repeat(40); + }, + (p) => { + p.action.signatureChainId = '0x1'; + }, + (p) => { + p.action.nonce = 1.5; + p.typedData.message.nonce = 1.5; + }, + (p) => { + p.typedData.message.agentAddress = '0x' + 'f'.repeat(40); + }, + (p) => { + p.typedData.types['HyperliquidTransaction:ApproveAgent'][0].type = + 'bytes32'; + }, + (p) => { + p.typedData.primaryType = 'HyperliquidTransaction:Withdraw'; + }, + (p) => { + p.action.extra = 'unapproved'; + p.typedData.message.extra = 'unapproved'; + }, + (p) => { + p.action.agentName = 'x'.repeat(17); + p.typedData.message.agentName = p.action.agentName; + }, + ]; + for (const mutate of mutations) { + const pending = approval(); + mutate(pending); + await expect(signer.sign(pending)).rejects.toThrow( + 'canonical Hyperliquid testnet approvals', + ); + } + for (const malformed of [null, {}, { action: null, typedData: {} }]) + await expect(signer.sign(malformed)).rejects.toThrow( + 'canonical Hyperliquid testnet approvals', + ); + await expect(readFile(join(directory, 'invoked'))).rejects.toMatchObject({ + code: 'ENOENT', + }); +}); + +test('a valid signature from another wallet cannot authorize the configured master address', async () => { + const expected = privateKeyToAccount(generatePrivateKey()); + const other = privateKeyToAccount(generatePrivateKey()); + const pending = approval(); + const signature = await other.signTypedData(pending.typedData); + const { executable } = await fixture( + `process.stdout.write(JSON.stringify({signature:${JSON.stringify(signature)}}));`, + ); + const env = externalEnv(expected, executable); + const signer = await createTestnetSigner(requireTestnetEnvironment(env), env); + await expect(signer.sign(pending)).rejects.toThrow( + 'does not match the test master address', + ); +}); + +test.each([ + 'process.stdout.write("not JSON");', + 'process.stdout.write("{}{}");', + 'process.stdout.write(JSON.stringify({signature:"0x1234"}));', + 'process.stdout.write(JSON.stringify({signature:"0x1234", extra:true}));', + 'process.stderr.write("sensitive-upstream-error"); process.exit(2);', +])( + 'malformed or failed signer output is rejected without exposing child errors (%#)', + async (source) => { + const account = privateKeyToAccount(generatePrivateKey()); + const { executable } = await fixture(source); + const env = externalEnv(account, executable); + const signer = await createTestnetSigner( + requireTestnetEnvironment(env), + env, + ); + const error = await signer.sign(approval()).catch((failure) => failure); + expect(error).toBeInstanceOf(Error); + expect(error.message).not.toContain('sensitive-upstream-error'); + expect(error.message).toMatch( + /External test signer|invalid EIP-712 signature/, + ); + }, +); + +test.each([ + ['setInterval(() => {}, 1000);', { timeoutMs: 50 }], + ['process.stdout.write("x".repeat(10000));', { maxBuffer: 128 }], +])( + 'external execution has bounded time and output (%#)', + async (source, options) => { + const account = privateKeyToAccount(generatePrivateKey()); + const { executable } = await fixture(source); + const env = externalEnv(account, executable); + const signer = await createTestnetSigner( + requireTestnetEnvironment(env), + env, + options, + ); + await expect(signer.sign(approval())).rejects.toThrow('time/output limit'); + }, +); + +test('cleanup constructs a zero-agent testnet approval with the exact requested name and nonce', () => { + const revoke = createTestnetRevocation('qa-agent', 123456789); + expect(revoke.action).toMatchObject({ + type: 'approveAgent', + agentAddress: '0x' + '0'.repeat(40), + agentName: 'qa-agent', + nonce: 123456789, + hyperliquidChain: 'Testnet', + signatureChainId: '0xa4b1', + }); + expect(revoke.typedData.message).toEqual({ + hyperliquidChain: 'Testnet', + agentAddress: '0x' + '0'.repeat(40), + agentName: 'qa-agent', + nonce: 123456789, + }); + expect(() => createTestnetRevocation('qa-agent', -1)).toThrow('canonical'); +});