From b42d728daa322647b199e8be2dd09790a6b32ad9 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:10:51 -0700 Subject: [PATCH 1/2] test: share the CLI bundle build --- test/build-bundles.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ test/global-setup.ts | 36 ++++++------------------------------ 2 files changed, 48 insertions(+), 30 deletions(-) create mode 100644 test/build-bundles.ts diff --git a/test/build-bundles.ts b/test/build-bundles.ts new file mode 100644 index 00000000..a43b5e4d --- /dev/null +++ b/test/build-bundles.ts @@ -0,0 +1,42 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { build } from "esbuild"; + +const root = resolve(import.meta.dirname, ".."); + +/** + * Bundles both CLIs into a temporary directory, with the options of the release bundle in + * scripts/package.ts, so subprocess tests run one prebuilt bundle instead of transpiling the + * sources with tsx on every spawn. + */ +export async function buildCliBundles() { + const outdir = await mkdtemp(join(tmpdir(), "wallet-cli-bundle-")); + const { version, dependencies } = JSON.parse( + await readFile(join(root, "package.json"), "utf8"), + ) as { version: string; dependencies: Record }; + await build({ + entryPoints: { + "tempo-request": join(root, "src/request-cli.ts"), + "tempo-wallet": join(root, "src/cli.ts"), + }, + bundle: true, + platform: "node", + format: "cjs", + target: "node22", + outdir, + outExtension: { ".js": ".cjs" }, + define: { + "process.env.TEMPO_WALLET_VERSION": JSON.stringify(version), + "process.env.TEMPO_WALLET_INCUR_VERSION": JSON.stringify(dependencies.incur), + "process.env.TEMPO_WALLET_ACCOUNTS_VERSION": JSON.stringify(dependencies.accounts), + }, + logLevel: "error", + }); + return { + outdir, + request: join(outdir, "tempo-request.cjs"), + wallet: join(outdir, "tempo-wallet.cjs"), + }; +} diff --git a/test/global-setup.ts b/test/global-setup.ts index 5f260ddb..88024759 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -1,11 +1,12 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { build } from "esbuild"; import type { TestProject } from "vitest/node"; +import { buildCliBundles } from "./build-bundles.js"; + const root = resolve(import.meta.dirname, ".."); declare module "vitest" { @@ -14,8 +15,7 @@ declare module "vitest" { } } -// Subprocess tests run the CLIs from one prebuilt bundle instead of transpiling the -// sources with tsx on every spawn. Options mirror the release bundle in scripts/package.ts. +// Subprocess tests run the CLIs from one prebuilt bundle; see buildCliBundles. export default async function setup(project: TestProject) { // incur keeps skill sync state under XDG_DATA_HOME; an empty directory keeps the developer's // installed skills from adding a "Skills are out of date" notice to test output. @@ -34,32 +34,8 @@ export default async function setup(project: TestProject) { process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, `--import=${guard}`] .filter(Boolean) .join(" "); - const outdir = await mkdtemp(join(tmpdir(), "wallet-cli-bundle-")); - const { version, dependencies } = JSON.parse( - await readFile(join(root, "package.json"), "utf8"), - ) as { version: string; dependencies: Record }; - await build({ - entryPoints: { - "tempo-request": join(root, "src/request-cli.ts"), - "tempo-wallet": join(root, "src/cli.ts"), - }, - bundle: true, - platform: "node", - format: "cjs", - target: "node22", - outdir, - outExtension: { ".js": ".cjs" }, - define: { - "process.env.TEMPO_WALLET_VERSION": JSON.stringify(version), - "process.env.TEMPO_WALLET_INCUR_VERSION": JSON.stringify(dependencies.incur), - "process.env.TEMPO_WALLET_ACCOUNTS_VERSION": JSON.stringify(dependencies.accounts), - }, - logLevel: "error", - }); - project.provide("cliBundles", { - request: join(outdir, "tempo-request.cjs"), - wallet: join(outdir, "tempo-wallet.cjs"), - }); + const { outdir, request, wallet } = await buildCliBundles(); + project.provide("cliBundles", { request, wallet }); return async () => { await rm(outdir, { force: true, recursive: true }); await rm(dataHome, { force: true, recursive: true }); From f7296cabdbcb2a019f0547dde111b74081332304 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:11:56 -0700 Subject: [PATCH 2/2] test(e2e): add a local Docker harness for pnpm test:e2e Runs the CLI bundles against anvil --network tempo (on testnet's chain ID, so the CLI's testnet network works through TEMPO_RPC_URL) and a plain anvil standing in for Base, adapted from machine-fetch's test/e2e. It deploys local OUSD and USDC with DEX books and fee AMM pools, EIP-3009 USDC on the EVM node, a wallet store with an unpublished access key, local MPP and x402 servers, and a stand-in relay that serves Relay's quote and status endpoints, settles deposits on Tempo, and releases USDC from inventory. The first scenarios pay an MPP charge in a held token through the access key, bridge through the stand-in relay, and settle an x402 authorization. --- .changelog/e2e-harness.md | 5 + package.json | 1 + test/e2e/README.md | 19 ++ test/e2e/cli.ts | 57 +++++ test/e2e/compose.yaml | 25 +++ test/e2e/contracts/LocalToken.json | 339 +++++++++++++++++++++++++++++ test/e2e/contracts/LocalToken.sol | 54 +++++ test/e2e/global-setup.ts | 45 ++++ test/e2e/harness.e2e.ts | 156 +++++++++++++ test/e2e/localnet.ts | 219 +++++++++++++++++++ test/e2e/relay.ts | 244 +++++++++++++++++++++ test/e2e/servers.ts | 149 +++++++++++++ test/e2e/setup.ts | 1 + test/e2e/vitest.config.ts | 18 ++ test/e2e/wallet.ts | 98 +++++++++ 15 files changed, 1430 insertions(+) create mode 100644 .changelog/e2e-harness.md create mode 100644 test/e2e/README.md create mode 100644 test/e2e/cli.ts create mode 100644 test/e2e/compose.yaml create mode 100644 test/e2e/contracts/LocalToken.json create mode 100644 test/e2e/contracts/LocalToken.sol create mode 100644 test/e2e/global-setup.ts create mode 100644 test/e2e/harness.e2e.ts create mode 100644 test/e2e/localnet.ts create mode 100644 test/e2e/relay.ts create mode 100644 test/e2e/servers.ts create mode 100644 test/e2e/setup.ts create mode 100644 test/e2e/vitest.config.ts create mode 100644 test/e2e/wallet.ts diff --git a/.changelog/e2e-harness.md b/.changelog/e2e-harness.md new file mode 100644 index 00000000..7fb17767 --- /dev/null +++ b/.changelog/e2e-harness.md @@ -0,0 +1,5 @@ +--- +wallet-cli: none +--- + +Add `pnpm test:e2e`, which runs the CLI against local Docker chains, a stand-in relay, and local paid servers. No user-facing change. diff --git a/package.json b/package.json index 50c66ce2..b571162e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "typecheck": "tsc -b --noEmit", "test:types": "tsc -p tsconfig.test.json --noEmit", "test": "vitest run", + "test:e2e": "vitest run --config test/e2e/vitest.config.ts", "vendor": "scripts/vendor.sh" }, "dependencies": { diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 00000000..65f5906d --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,19 @@ +# End-to-end tests + +`pnpm test:e2e` runs the bundled `tempo request` and `tempo wallet` against local chains. It needs +Docker; `pnpm test` never runs it. + +- `compose.yaml` starts `anvil --network tempo` with testnet's chain ID (42431), so the CLI's + `testnet` network runs against it through `TEMPO_RPC_URL`, and a plain anvil (31338) standing in + for Base. Adapted from machine-fetch's `test/e2e`. +- `localnet.ts` deploys local OUSD and USDC TIP-20s with DEX books and fee AMM pools on the Tempo + node, and EIP-3009 USDC (`contracts/LocalToken.sol`, compiled into `LocalToken.json`) on the EVM + node. +- `wallet.ts` writes a wallet store with an unpublished access key, as `tempo wallet login` leaves + it. +- `relay.ts` is a stand-in for Relay: `POST /quote/v2` and `GET /intents/status/v3` with Relay's + shapes. It takes OUSD at a single-use deposit address and releases USDC from inventory on the + EVM node, and can tamper with quotes or hold deliveries for tests. +- `servers.ts` serves MPP Tempo charges and EVM charges, including x402-only challenges. + +Everything runs on loopback, and the loopback guard from `test/fixtures` refuses anything else. diff --git a/test/e2e/cli.ts b/test/e2e/cli.ts new file mode 100644 index 00000000..43ae68ba --- /dev/null +++ b/test/e2e/cli.ts @@ -0,0 +1,57 @@ +import { spawn } from "node:child_process"; + +import { inject } from "vitest"; + +import type { Localnet } from "./localnet.js"; +import type { LocalWallet } from "./wallet.js"; + +export type CliRun = { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +}; + +/** The environment a CLI subprocess runs with: the wallet's HOME on the local Tempo chain. */ +export function cliEnv(net: Localnet, wallet: LocalWallet, env: Record = {}) { + return { + PATH: process.env.PATH ?? "", + HOME: wallet.home, + NODE_OPTIONS: process.env.NODE_OPTIONS ?? "", + XDG_DATA_HOME: process.env.XDG_DATA_HOME ?? "", + TEMPO_NO_BROWSER: "1", + TEMPO_AUTH_URL: "http://127.0.0.1:9", + TEMPO_RPC_URL: net.tempoUrl, + TEMPO_WALLET_NETWORK: "testnet", + ...env, + }; +} + +/** Starts `tempo request` or `tempo wallet` from the prebuilt bundle. */ +export function startCli( + name: "request" | "wallet", + args: readonly string[], + env: Record, +) { + const child = spawn(process.execPath, [inject("cliBundles")[name], ...args], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk)); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk)); + const done = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal, stdout, stderr })); + }); + return { child, done }; +} + +export function runCli( + name: "request" | "wallet", + args: readonly string[], + env: Record, +) { + return startCli(name, args, env).done; +} diff --git a/test/e2e/compose.yaml b/test/e2e/compose.yaml new file mode 100644 index 00000000..99b03ce5 --- /dev/null +++ b/test/e2e/compose.yaml @@ -0,0 +1,25 @@ +# Adapted from machine-fetch test/e2e/compose.yaml. The Tempo node uses testnet's chain ID, so the +# CLI's testnet network (--network testnet with TEMPO_RPC_URL) runs against it unchanged; the plain +# node stands in for Base. Anvil 1.8.1, which machine-fetch pins, never answers an access-key +# transaction over its spending limit, so this pins 1.8.4. +services: + tempo: + image: ghcr.io/foundry-rs/foundry:v1.8.4 + entrypoint: anvil + command: --network tempo --chain-id 42431 --host 0.0.0.0 --silent + ports: ["127.0.0.1::8545"] + healthcheck: + test: ["CMD", "cast", "chain-id", "--rpc-url", "http://127.0.0.1:8545"] + interval: 1s + timeout: 5s + retries: 30 + evm: + image: ghcr.io/foundry-rs/foundry:v1.8.4 + entrypoint: anvil + command: --chain-id 31338 --host 0.0.0.0 --silent + ports: ["127.0.0.1::8545"] + healthcheck: + test: ["CMD", "cast", "chain-id", "--rpc-url", "http://127.0.0.1:8545"] + interval: 1s + timeout: 5s + retries: 30 diff --git a/test/e2e/contracts/LocalToken.json b/test/e2e/contracts/LocalToken.json new file mode 100644 index 00000000..290e86ce --- /dev/null +++ b/test/e2e/contracts/LocalToken.json @@ -0,0 +1,339 @@ +{ + "compiler": "solc 0.8.30+commit.73712a01.Emscripten.clang", + "abi": [ + { + "inputs": [ + { + "internalType": "string", + "name": "name_", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol_", + "type": "string" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "authorizationState", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "validAfter", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "validBefore", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "nonce", + "type": "bytes32" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "transferWithAuthorization", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x60a060405234801561000f575f5ffd5b50604051610c84380380610c8483398101604081905261002e91610176565b5f610039838261025f565b506001610046828261025f565b508151602080840191909120604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f938101939093528201527fad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a560608201524660808201523060a082015260c00160408051601f198184030181529190528051602090910120608052506103199050565b634e487b7160e01b5f52604160045260245ffd5b5f82601f8301126100fc575f5ffd5b81516001600160401b03811115610115576101156100d9565b604051601f8201601f19908116603f011681016001600160401b0381118282101715610143576101436100d9565b60405281815283820160200185101561015a575f5ffd5b8160208501602083015e5f918101602001919091529392505050565b5f5f60408385031215610187575f5ffd5b82516001600160401b0381111561019c575f5ffd5b6101a8858286016100ed565b602085015190935090506001600160401b038111156101c5575f5ffd5b6101d1858286016100ed565b9150509250929050565b600181811c908216806101ef57607f821691505b60208210810361020d57634e487b7160e01b5f52602260045260245ffd5b50919050565b601f82111561025a57805f5260205f20601f840160051c810160208510156102385750805b601f840160051c820191505b81811015610257575f8155600101610244565b50505b505050565b81516001600160401b03811115610278576102786100d9565b61028c8161028684546101db565b84610213565b6020601f8211600181146102be575f83156102a75750848201515b5f19600385901b1c1916600184901b178455610257565b5f84815260208120601f198516915b828110156102ed57878501518255602094850194600190920191016102cd565b508482101561030a57868401515f19600387901b60f8161c191681555b50505050600190811b01905550565b60805161094c6103385f395f8181610128015261052e015261094c5ff3fe608060405234801561000f575f5ffd5b50600436106100b1575f3560e01c806370a082311161006e57806370a082311461016d57806395d89b411461018c578063a9059cbb14610194578063dd62ed3e146101a7578063e3ee160e146101d1578063e94a0102146101e4575f5ffd5b806306fdde03146100b5578063095ea7b3146100d357806323b872dd146100f6578063313ce567146101095780633644e5151461012357806340c10f1914610158575b5f5ffd5b6100bd610211565b6040516100ca919061071f565b60405180910390f35b6100e66100e136600461076f565b61029c565b60405190151581526020016100ca565b6100e6610104366004610797565b610308565b610111600681565b60405160ff90911681526020016100ca565b61014a7f000000000000000000000000000000000000000000000000000000000000000081565b6040519081526020016100ca565b61016b61016636600461076f565b610357565b005b61014a61017b3660046107d1565b60026020525f908152604090205481565b6100bd6103c7565b6100e66101a236600461076f565b6103d4565b61014a6101b53660046107f1565b600360209081525f928352604080842090915290825290205481565b61016b6101df366004610822565b6103e9565b6100e66101f236600461076f565b600460209081525f928352604080842090915290825290205460ff1681565b5f805461021d906108a4565b80601f0160208091040260200160405190810160405280929190818152602001828054610249906108a4565b80156102945780601f1061026b57610100808354040283529160200191610294565b820191905f5260205f20905b81548152906001019060200180831161027757829003601f168201915b505050505081565b335f8181526003602090815260408083206001600160a01b038716808552925280832085905551919290917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925906102f69086815260200190565b60405180910390a35060015b92915050565b6001600160a01b0383165f90815260036020908152604080832033845290915281208054839190839061033c9084906108f0565b9091555061034d9050848484610673565b5060019392505050565b6001600160a01b0382165f908152600260205260408120805483929061037e908490610903565b90915550506040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b6001805461021d906108a4565b5f6103e0338484610673565b50600192915050565b85421180156103f757508442105b61043d5760405162461bcd60e51b8152602060048201526012602482015271617574686f72697a6174696f6e2074696d6560701b60448201526064015b60405180910390fd5b6001600160a01b0389165f90815260046020908152604080832087845290915290205460ff16156104a55760405162461bcd60e51b8152602060048201526012602482015271185d5d1a1bdc9a5e985d1a5bdb881d5cd95960721b6044820152606401610434565b604080517f7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a22676020808301919091526001600160a01b038c8116838501819052908c166060840152608083018b905260a083018a905260c0830189905260e0808401899052845180850390910181526101008401855280519083012061190160f01b6101208501527f00000000000000000000000000000000000000000000000000000000000000006101228501526101428085019190915284518085039091018152610162909301909352815191012090158015906105ef5750604080515f81526020810180835283905260ff86169181019190915260608101849052608081018390526001600160a01b038b169060019060a0016020604051602081039080840390855afa1580156105da573d5f5f3e3d5ffd5b505050602060405103516001600160a01b0316145b61062f5760405162461bcd60e51b8152602060048201526011602482015270696e76616c6964207369676e617475726560781b6044820152606401610434565b6001600160a01b038a165f9081526004602090815260408083208884529091529020805460ff191660011790556106678a8a8a610673565b50505050505050505050565b6001600160a01b0383165f908152600260205260408120805483929061069a9084906108f0565b90915550506001600160a01b0382165f90815260026020526040812080548392906106c6908490610903565b92505081905550816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8360405161071291815260200190565b60405180910390a3505050565b602081525f82518060208401528060208501604085015e5f604082850101526040601f19601f83011684010191505092915050565b80356001600160a01b038116811461076a575f5ffd5b919050565b5f5f60408385031215610780575f5ffd5b61078983610754565b946020939093013593505050565b5f5f5f606084860312156107a9575f5ffd5b6107b284610754565b92506107c060208501610754565b929592945050506040919091013590565b5f602082840312156107e1575f5ffd5b6107ea82610754565b9392505050565b5f5f60408385031215610802575f5ffd5b61080b83610754565b915061081960208401610754565b90509250929050565b5f5f5f5f5f5f5f5f5f6101208a8c03121561083b575f5ffd5b6108448a610754565b985061085260208b01610754565b975060408a0135965060608a0135955060808a0135945060a08a0135935060c08a013560ff81168114610883575f5ffd5b989b979a50959894979396929550929360e081013593506101000135919050565b600181811c908216806108b857607f821691505b6020821081036108d657634e487b7160e01b5f52602260045260245ffd5b50919050565b634e487b7160e01b5f52601160045260245ffd5b81810381811115610302576103026108dc565b80820180821115610302576103026108dc56fea2646970667358221220c5be5c4c947622360bd8b271c9f5564625875c811527a612b684249fa969c17a64736f6c634300081e0033" +} diff --git a/test/e2e/contracts/LocalToken.sol b/test/e2e/contracts/LocalToken.sol new file mode 100644 index 00000000..927693f0 --- /dev/null +++ b/test/e2e/contracts/LocalToken.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +/// @notice Local-only mintable token with real EIP-3009 signature verification and replay protection. +contract LocalToken { + string public name; + string public symbol; + uint8 public constant decimals = 6; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + mapping(address => mapping(bytes32 => bool)) public authorizationState; + bytes32 public immutable DOMAIN_SEPARATOR; + bytes32 constant TRANSFER_TYPEHASH = keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"); + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory name_, string memory symbol_) { + name = name_; symbol = symbol_; + DOMAIN_SEPARATOR = keccak256(abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(name_)), keccak256("2"), block.chainid, address(this) + )); + } + function mint(address to, uint256 value) external { + balanceOf[to] += value; + emit Transfer(address(0), to, value); + } + function approve(address spender, uint256 value) external returns (bool) { + allowance[msg.sender][spender] = value; + emit Approval(msg.sender, spender, value); + return true; + } + function transfer(address to, uint256 value) external returns (bool) { + move(msg.sender, to, value); return true; + } + function transferFrom(address from, address to, uint256 value) external returns (bool) { + allowance[from][msg.sender] -= value; + move(from, to, value); return true; + } + function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external { + require(block.timestamp > validAfter && block.timestamp < validBefore, "authorization time"); + require(!authorizationState[from][nonce], "authorization used"); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, + keccak256(abi.encode(TRANSFER_TYPEHASH, from, to, value, validAfter, validBefore, nonce)))); + require(from != address(0) && ecrecover(digest, v, r, s) == from, "invalid signature"); + authorizationState[from][nonce] = true; + move(from, to, value); + } + function move(address from, address to, uint256 value) internal { + balanceOf[from] -= value; balanceOf[to] += value; + emit Transfer(from, to, value); + } +} + diff --git a/test/e2e/global-setup.ts b/test/e2e/global-setup.ts new file mode 100644 index 00000000..284c4382 --- /dev/null +++ b/test/e2e/global-setup.ts @@ -0,0 +1,45 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { TestProject } from "vitest/node"; + +import { buildCliBundles } from "../build-bundles.js"; +import { type Localnet, provision, startNodes } from "./localnet.js"; + +declare module "vitest" { + export interface ProvidedContext { + cliBundles: { request: string; wallet: string }; + localnet: Localnet; + } +} + +const root = resolve(import.meta.dirname, "../.."); + +export default async function setup(project: TestProject) { + const dataHome = await mkdtemp(join(tmpdir(), "wallet-cli-e2e-data-")); + process.env.XDG_DATA_HOME = dataHome; + process.env.TEMPO_NO_BROWSER = "1"; + // Everything the end-to-end run talks to is on loopback; the guard proves nothing else is reached. + const guard = pathToFileURL(join(root, "test/fixtures/loopback-only.mjs")).href; + process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, `--import=${guard}`] + .filter(Boolean) + .join(" "); + const [bundles, nodes] = await Promise.all([ + buildCliBundles(), + startNodes(`wallet-cli-e2e-${process.pid}`), + ]); + try { + project.provide("localnet", await provision(nodes)); + } catch (error) { + await nodes.close(); + throw error; + } + project.provide("cliBundles", { request: bundles.request, wallet: bundles.wallet }); + return async () => { + await nodes.close(); + await rm(bundles.outdir, { force: true, recursive: true }); + await rm(dataHome, { force: true, recursive: true }); + }; +} diff --git a/test/e2e/harness.e2e.ts b/test/e2e/harness.e2e.ts new file mode 100644 index 00000000..5b94c7b6 --- /dev/null +++ b/test/e2e/harness.e2e.ts @@ -0,0 +1,156 @@ +import { Mppx, evm } from "mppx/client"; +import { Assets } from "mppx/evm"; +import { erc20Abi } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { Actions, Addresses } from "viem/tempo"; +import { afterEach, describe, expect, inject, test } from "vitest"; + +import { cliEnv, runCli } from "./cli.js"; +import { evmBalance, evmClient, tempoBalance, tempoClient } from "./localnet.js"; +import { standInRelay } from "./relay.js"; +import { evmServer, type LocalServer, mppServer, serve } from "./servers.js"; +import { localWallet } from "./wallet.js"; + +const net = inject("localnet"); +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close(); +}); + +async function started(server: LocalServer) { + cleanup.push(() => server.close()); + return server; +} + +describe("local harness", () => { + test("tempo request pays an MPP charge on the local Tempo chain in a held token", async () => { + const wallet = await localWallet(net, { + funds: [ + { token: net.ousd, limit: 1_000_000n }, + { token: net.usdc, limit: 1_000_000n }, + ], + }); + const recipient = privateKeyToAccount(generatePrivateKey()).address; + const server = await started( + await serve(mppServer(net, { currency: net.usdc, recipient, amount: "0.01" })), + ); + + const result = await runCli("request", [`${server.origin}/paid`], cliEnv(net, wallet)); + + expect(result).toMatchObject({ + code: 0, + stdout: expect.stringContaining("paid on Tempo"), + stderr: expect.stringContaining("Paid 0.01"), + }); + expect(await tempoBalance(net, net.usdc, recipient)).toBe(10_000n); + // The payment's transaction pays its fee in the token it transfers. + const left = await tempoBalance(net, net.usdc, wallet.address); + expect(left).toBeLessThan(990_000n); + expect(left).toBeGreaterThan(980_000n); + }); + + test("the stand-in relay releases USDC for an OUSD deposit through Relay's HTTP API", async () => { + const relay = await standInRelay(net); + cleanup.push(() => relay.close()); + const sender = privateKeyToAccount(generatePrivateKey()); + const recipient = privateKeyToAccount(generatePrivateKey()).address; + await Actions.token.mintSync(tempoClient(net), { + token: net.ousd, + to: sender.address, + amount: 1_000_000n, + feeToken: Addresses.pathUsd, + }); + + const quote = (await ( + await fetch(`${relay.url}/quote/v2`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + amount: "100000", + originChainId: net.tempoChainId, + originCurrency: net.ousd, + destinationChainId: net.evmChainId, + destinationCurrency: net.evmUsdc, + recipient, + user: sender.address, + tradeType: "EXACT_OUTPUT", + useDepositAddress: true, + }), + }) + ).json()) as { + details: { currencyIn: { amount: string } }; + steps: { + requestId: `0x${string}`; + depositAddress: `0x${string}`; + items: { data: { to: `0x${string}`; data: `0x${string}` } }[]; + }[]; + }; + const [step] = quote.steps; + expect(quote.details.currencyIn.amount).toBe("101300"); + await Actions.token.transferSync(tempoClient(net, sender), { + token: net.ousd, + to: step!.depositAddress, + amount: 101_300n, + feeToken: net.ousd, + }); + let status = "waiting"; + while (status !== "success") { + status = ( + (await ( + await fetch(`${relay.url}/intents/status/v3?requestId=${step!.requestId}`) + ).json()) as { + status: string; + } + ).status; + expect(["waiting", "pending", "success"]).toContain(status); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(await evmBalance(net, net.evmUsdc, recipient)).toBe(100_000n); + }); + + test("the local x402 server settles an EIP-3009 authorization on the EVM chain", async () => { + const seller = privateKeyToAccount(generatePrivateKey()); + const payer = privateKeyToAccount(generatePrivateKey()); + const chain = evmClient(net); + await chain.waitForTransactionReceipt({ + hash: await chain.sendTransaction({ to: seller.address, value: 10n ** 18n }), + }); + await chain.waitForTransactionReceipt({ + hash: await chain.writeContract({ + address: net.evmUsdc, + abi: erc20Abi, + functionName: "transfer", + args: [payer.address, 1_000_000n], + }), + }); + const server = await started( + await serve(evmServer(net, { account: seller, amount: "0.10", x402Only: true })), + ); + const challenge = await fetch(`${server.origin}/paid`); + expect(challenge.status).toBe(402); + expect(challenge.headers.has("payment-required")).toBe(true); + expect(challenge.headers.has("www-authenticate")).toBe(false); + + const client = Mppx.create({ + methods: [ + evm.charge({ + account: payer, + networks: [net.evmChainId], + currencies: [ + Assets.define({ + address: net.evmUsdc, + decimals: 6, + network: `eip155:${net.evmChainId}`, + transfer: { type: "eip3009", name: "USDC", version: "2" }, + }), + ], + }), + ], + polyfill: false, + }); + const response = await client.fetch(`${server.origin}/paid`); + + expect(response.status).toBe(200); + expect(await evmBalance(net, net.evmUsdc, seller.address)).toBe(100_000n); + }); +}); diff --git a/test/e2e/localnet.ts b/test/e2e/localnet.ts new file mode 100644 index 00000000..618a3a7d --- /dev/null +++ b/test/e2e/localnet.ts @@ -0,0 +1,219 @@ +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { promisify } from "node:util"; + +import { + type Abi, + type Account, + type Address, + createClient, + defineChain, + erc20Abi, + type Hex, + http, + parseAbi, + publicActions, + walletActions, +} from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { foundry } from "viem/chains"; +import { Actions, Addresses, Chain } from "viem/tempo"; + +const exec = promisify(execFile); +const compose = new URL("./compose.yaml", import.meta.url).pathname; +const localToken = JSON.parse( + readFileSync(new URL("./contracts/LocalToken.json", import.meta.url), "utf8"), +) as { abi: Abi; bytecode: Hex }; +const mintAbi = parseAbi(["function mint(address to,uint256 value)"]); + +/** Anvil's first dev account: it issues every local token and holds the relay's inventory. */ +export const maker = mnemonicToAccount( + "test test test test test test test test test test test junk", +); + +/** Where the local chains run and which tokens they hold; plain data, shared with test workers. */ +export type Localnet = { + tempoUrl: string; + evmUrl: string; + tempoChainId: number; + evmChainId: number; + /** Local stand-in for OUSD: a USD TIP-20 the wallet funds payments from. */ + ousd: Address; + /** A USD TIP-20 the local MPP server charges in, bought from OUSD on the DEX. */ + usdc: Address; + /** EIP-3009 USDC on the stand-in for Base. */ + evmUsdc: Address; +}; + +/** Starts fresh containers with random loopback ports; `close` removes them. */ +export async function startNodes(project: string) { + const run = (...args: string[]) => + exec("docker", ["compose", "-f", compose, "-p", project, ...args]); + const close = async () => { + await run("down", "--volumes", "--timeout", "1"); + }; + const url = async (service: string) => + `http://${(await run("port", service, "8545")).stdout.trim()}`; + try { + await run("up", "-d", "--wait", "--wait-timeout", "90"); + return { tempoUrl: await url("tempo"), evmUrl: await url("evm"), close }; + } catch (error) { + await close().catch(() => undefined); + throw new Error( + `Unable to start local chains with Docker Compose: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export function tempoChain(net: Pick) { + return defineChain({ ...Chain.tempoModerato, rpcUrls: { default: { http: [net.tempoUrl] } } }); +} + +export function evmChain(net: Pick) { + return defineChain({ + ...foundry, + id: net.evmChainId, + name: "Local EVM", + rpcUrls: { default: { http: [net.evmUrl] } }, + }); +} + +export function tempoClient(net: Pick, account: Account = maker) { + return createClient({ + account, + chain: tempoChain(net), + pollingInterval: 50, + transport: http(net.tempoUrl), + }) + .extend(publicActions) + .extend(walletActions); +} + +export function evmClient(net: Pick, account: Account = maker) { + return createClient({ + account, + chain: evmChain(net), + pollingInterval: 50, + transport: http(net.evmUrl), + }) + .extend(publicActions) + .extend(walletActions); +} + +/** + * Deploys the local tokens and liquidity: OUSD and USDC TIP-20s with DEX books through pathUSD (so + * OUSD buys USDC in one multi-hop swap), fee AMM pools so accounts can pay fees in either, and + * EIP-3009 USDC on the EVM chain with relay inventory held by `maker`. + */ +export async function provision(nodes: { tempoUrl: string; evmUrl: string }): Promise { + const base = { + tempoUrl: nodes.tempoUrl, + evmUrl: nodes.evmUrl, + tempoChainId: Chain.tempoModerato.id, + evmChainId: 31338, + }; + const tempo = tempoClient(base); + const created: Address[] = []; + for (const symbol of ["OUSD", "USDC"]) { + const { token } = await Actions.token.createSync(tempo, { + name: symbol, + symbol, + currency: "USD", + quoteToken: Addresses.pathUsd, + feeToken: Addresses.pathUsd, + }); + await Actions.token.grantRolesSync(tempo, { + token, + to: maker.address, + roles: ["issuer"], + feeToken: Addresses.pathUsd, + }); + await Actions.token.mintSync(tempo, { + token, + to: maker.address, + amount: 1_000_000_000_000n, + feeToken: Addresses.pathUsd, + }); + await Actions.token.approveSync(tempo, { + token, + spender: Addresses.stablecoinDex, + amount: 1_000_000_000_000n, + feeToken: Addresses.pathUsd, + }); + await Actions.dex.createPairSync(tempo, { base: token, feeToken: Addresses.pathUsd }); + created.push(token); + } + const [ousd, usdc] = created as [Address, Address]; + await Actions.token.approveSync(tempo, { + token: Addresses.pathUsd, + spender: Addresses.stablecoinDex, + amount: 1_000_000_000_000n, + feeToken: Addresses.pathUsd, + }); + // The maker sells USDC for pathUSD and buys OUSD with pathUSD, both at parity. + await Actions.dex.placeSync(tempo, { + token: usdc, + amount: 100_000_000_000n, + type: "sell", + tick: 0, + feeToken: Addresses.pathUsd, + }); + await Actions.dex.placeSync(tempo, { + token: ousd, + amount: 100_000_000_000n, + type: "buy", + tick: 0, + feeToken: Addresses.pathUsd, + }); + // A transaction may pay its fee in any token it touches, so each can be swapped for pathUSD. + for (const token of created) + await Actions.amm.mintSync(tempo, { + userTokenAddress: token, + validatorTokenAddress: Addresses.pathUsd, + validatorTokenAmount: 100_000_000_000n, + to: maker.address, + feeToken: Addresses.pathUsd, + }); + + const evm = evmClient(base); + const hash = await evm.deployContract({ + abi: localToken.abi, + bytecode: localToken.bytecode, + args: ["USDC", "USDC"], + }); + const receipt = await evm.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success" || !receipt.contractAddress) + throw new Error("Local USDC deployment failed"); + const evmUsdc = receipt.contractAddress; + await confirmed( + evm, + await evm.writeContract({ + address: evmUsdc, + abi: mintAbi, + functionName: "mint", + args: [maker.address, 1_000_000_000_000n], + }), + ); + return { ...base, ousd, usdc, evmUsdc }; +} + +async function confirmed(client: ReturnType, hash: Hex) { + const receipt = await client.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error("Local setup transaction reverted"); + return receipt; +} + +/** A TIP-20 balance on the local Tempo chain, in atomic units. */ +export async function tempoBalance(net: Localnet, token: Address, account: Address) { + return (await Actions.token.getBalance(tempoClient(net), { token, account })).amount; +} + +/** An ERC-20 balance on the local EVM chain, in atomic units. */ +export function evmBalance(net: Localnet, token: Address, account: Address) { + return evmClient(net).readContract({ + address: token, + abi: erc20Abi, + functionName: "balanceOf", + args: [account], + }); +} diff --git a/test/e2e/relay.ts b/test/e2e/relay.ts new file mode 100644 index 00000000..49ac145b --- /dev/null +++ b/test/e2e/relay.ts @@ -0,0 +1,244 @@ +import { randomBytes } from "node:crypto"; +import { createServer, type IncomingMessage } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { type Address, encodeFunctionData, erc20Abi, getAddress, type Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +import { evmClient, type Localnet, tempoBalance } from "./localnet.js"; + +// Adapted from machine-fetch test/e2e/relay.ts (its inventory relay), behind Relay's HTTP API so +// the CLI's Relay route runs for real: it takes OUSD on the Tempo chain at a single-use deposit +// address and releases USDC from inventory on the EVM chain. + +/** Quote tampering that a safe client must refuse before it signs anything. */ +export type HostileQuote = + | "wrong-destination-recipient" + | "wrong-chain" + | "extra-step" + | "arbitrary-contract" + | "amount-above-bound" + | "expired" + | "non-canonical-amount" + | "wrong-deposit-recipient"; + +type Intent = { + requestId: Hex; + depositAddress: Address; + originToken: Address; + amountIn: bigint; + recipient: Address; + destinationToken: Address; + amountOut: bigint; + status: "waiting" | "pending" | "success" | "refund"; +}; + +/** Relay's fee on the stand-in: 0.3% of the output plus 0.001 of the origin token. */ +const feeBps = 30n; +const flatFee = 1_000n; +const quoteTtlSeconds = 60; + +export type StandInRelay = Awaited>; + +export async function standInRelay(net: Localnet) { + const intents = new Map(); + const quotes: Record[] = []; + let hostile: HostileQuote | undefined; + let holdRelease = false; + let refund = false; + const deposited: Hex[] = []; + const depositWaiters: (() => void)[] = []; + const inventory = evmClient(net); + + const quote = (body: Record) => { + const amountOut = BigInt(String(body.amount)); + const amountIn = amountOut + (amountOut * feeBps) / 10_000n + flatFee; + const requestId = `0x${randomBytes(32).toString("hex")}` as Hex; + const depositAddress = privateKeyToAccount(`0x${randomBytes(32).toString("hex")}`).address; + const originToken = getAddress(String(body.originCurrency)); + const recipient = getAddress(String(body.recipient)); + intents.set(requestId, { + requestId, + depositAddress, + originToken, + amountIn, + recipient, + destinationToken: getAddress(String(body.destinationCurrency)), + amountOut, + status: "waiting", + }); + const deposit = { + chainId: Number(body.originChainId), + from: String(body.user), + to: originToken as string, + value: "0", + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [depositAddress, amountIn], + }) as string, + }; + const details = { + operation: "bridge", + sender: String(body.user), + recipient: recipient as string, + currencyIn: { + amount: amountIn.toString(), + currency: { chainId: Number(body.originChainId), address: originToken as string }, + }, + currencyOut: { + amount: amountOut.toString(), + currency: { + chainId: Number(body.destinationChainId), + address: String(body.destinationCurrency), + }, + }, + }; + const steps: Record[] = [ + { + id: "deposit", + kind: "transaction", + requestId, + depositAddress, + items: [{ status: "incomplete", data: deposit }], + }, + ]; + let expiresAt = Math.floor(Date.now() / 1000) + quoteTtlSeconds; + switch (hostile) { + case "wrong-destination-recipient": + details.recipient = privateKeyToAccount(`0x${"11".repeat(32)}`).address; + break; + case "wrong-chain": + deposit.chainId = 4217; + break; + case "extra-step": + steps.push({ + id: "approve", + kind: "transaction", + requestId, + items: [{ status: "incomplete", data: { ...deposit } }], + }); + break; + case "arbitrary-contract": + deposit.to = "0x000000000000000000000000000000000000dEaD"; + break; + case "amount-above-bound": + deposit.data = encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [depositAddress, amountIn * 2n], + }); + break; + case "expired": + expiresAt = Math.floor(Date.now() / 1000) - 10; + break; + case "non-canonical-amount": + details.currencyIn.amount = `0${amountIn}`; + break; + case "wrong-deposit-recipient": + deposit.data = encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [privateKeyToAccount(`0x${"22".repeat(32)}`).address, amountIn], + }); + break; + } + return { details, steps, expiresAt, fees: {} }; + }; + + const settle = async (intent: Intent) => { + if (intent.status !== "waiting") return; + if ((await tempoBalance(net, intent.originToken, intent.depositAddress)) < intent.amountIn) + return; + intent.status = "pending"; + deposited.push(intent.requestId); + for (const wake of depositWaiters.splice(0)) wake(); + }; + const release = async (intent: Intent) => { + if (intent.status !== "pending" || holdRelease) return; + if (refund) { + intent.status = "refund"; + return; + } + const hash = await inventory.writeContract({ + address: net.evmUsdc, + abi: erc20Abi, + functionName: "transfer", + args: [intent.recipient, intent.amountOut], + }); + const receipt = await inventory.waitForTransactionReceipt({ hash }); + intent.status = receipt.status === "success" ? "success" : "refund"; + }; + let polling = true; + const poll = (async () => { + while (polling) { + for (const intent of intents.values()) { + await settle(intent).catch(() => undefined); + await release(intent).catch(() => undefined); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + })(); + + const server = createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://relay"); + const json = (status: number, body: unknown) => + response.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body)); + try { + if (request.method === "POST" && url.pathname === "/quote/v2") { + const body = JSON.parse(await read(request)) as Record; + quotes.push(body); + return json(200, quote(body)); + } + if (request.method === "GET" && url.pathname === "/intents/status/v3") { + const intent = intents.get(url.searchParams.get("requestId") as Hex); + if (!intent) return json(404, { message: "Unknown request" }); + return json(200, { + status: intent.status, + ...(intent.status === "success" ? { inTxHashes: [], txHashes: [] } : {}), + }); + } + return json(404, { message: "Not found" }); + } catch (error) { + return json(400, { message: error instanceof Error ? error.message : String(error) }); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + /** Quote request bodies, in order. */ + quotes, + /** Request IDs whose deposit arrived, in order. */ + deposited, + /** Tampers with every later quote. */ + hostile(mode: HostileQuote | undefined) { + hostile = mode; + }, + /** Holds deposits pending instead of releasing them, until called with false. */ + hold(value: boolean) { + holdRelease = value; + }, + /** Refunds deposits instead of releasing them. */ + refund(value: boolean) { + refund = value; + }, + /** Resolves once `count` deposits have arrived. */ + async waitForDeposits(count: number) { + while (deposited.length < count) await new Promise((wake) => depositWaiters.push(wake)); + }, + intent: (requestId: Hex) => intents.get(requestId), + async close() { + polling = false; + await poll; + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +async function read(request: IncomingMessage) { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/test/e2e/servers.ts b/test/e2e/servers.ts new file mode 100644 index 00000000..e6a419c9 --- /dev/null +++ b/test/e2e/servers.ts @@ -0,0 +1,149 @@ +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { evm, Mppx, NodeListener, Request as ServerRequest, tempo } from "mppx/server"; +import { type Address, getAddress, type Hex, parseAbi, parseSignature } from "viem"; +import type { PrivateKeyAccount } from "viem/accounts"; + +import { evmClient, type Localnet, tempoClient } from "./localnet.js"; + +// Adapted from machine-fetch test/e2e/servers. + +const authorizationAbi = parseAbi([ + "function transferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce,uint8 v,bytes32 r,bytes32 s)", +]); + +/** Reports a server-side payment failure on stderr, where vitest shows it with the failing test. */ +function logged(action: (...args: args) => Promise) { + return async (...args: args) => { + try { + return await action(...args); + } catch (error) { + console.error("local server payment failure:", error); + throw error; + } + }; +} + +export type LocalServer = { origin: string; close(): Promise }; + +/** Serves `app` on a random loopback port. */ +export async function serve(app: (request: Request) => Promise): Promise { + const server = createServer(async (request, response) => { + try { + await NodeListener.sendResponse( + response, + await app(ServerRequest.fromNodeListener(request, response)), + ); + } catch (error) { + response + .writeHead(500) + .end( + `Payment verification or settlement failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return { + origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + close: () => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +/** An MPP Tempo charge endpoint, `/paid`, priced at `amount` of `currency` on the local chain. */ +export function mppServer( + net: Localnet, + options: { currency: Address; recipient: Address; amount?: string }, +) { + const client = tempoClient(net); + const methods = tempo.charge({ + currency: options.currency, + recipient: options.recipient, + getClient: () => client, + }); + const payments = Mppx.create({ + methods: [ + methods.map((method) => ({ + ...method, + verify: logged(method.verify), + ...(method.broadcast ? { broadcast: logged(method.broadcast) } : {}), + ...(method.validate ? { validate: logged(method.validate) } : {}), + })), + ], + secretKey: randomBytes(32).toString("hex"), + }); + const paid = payments.tempo.charge({ + amount: options.amount ?? "0.01", + chainId: net.tempoChainId, + }); + return async (request: Request) => { + if (new URL(request.url).pathname !== "/paid") + return new Response("Not found", { status: 404 }); + const result = await paid(request); + if (result.status === 402) return result.challenge; + return result.withReceipt(Response.json({ data: "paid on Tempo" })); + }; +} + +/** + * An EVM charge endpoint, `/paid`, priced at `amount` of the local EIP-3009 USDC, settled by the + * server itself. With `x402Only` it answers with only the x402 v2 `PAYMENT-REQUIRED` header. + */ +export function evmServer( + net: Localnet, + options: { account: PrivateKeyAccount; amount?: string; x402Only?: boolean }, +) { + const wallet = evmClient(net, options.account); + const method = evm.charge({ + currency: net.evmUsdc, + chainId: net.evmChainId, + decimals: 6, + authorization: { name: "USDC", version: "2" }, + recipient: options.account.address, + async settle({ payload }) { + const signature = parseSignature(payload.signature as Hex); + const { request } = await wallet.simulateContract({ + address: net.evmUsdc, + abi: authorizationAbi, + functionName: "transferWithAuthorization", + args: [ + getAddress(payload.from), + getAddress(payload.to), + BigInt(payload.value), + BigInt(payload.validAfter), + BigInt(payload.validBefore), + payload.nonce as Hex, + Number(signature.v ?? BigInt(27 + signature.yParity)), + signature.r, + signature.s, + ], + }); + const hash = await wallet.writeContract(request); + const receipt = await wallet.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error("USDC settlement reverted"); + return { reference: hash }; + }, + }); + const payments = Mppx.create({ methods: [method], secretKey: randomBytes(32).toString("hex") }); + const paid = payments.evm.charge({ amount: options.amount ?? "0.10" }); + return async (request: Request) => { + if (new URL(request.url).pathname !== "/paid") + return new Response("Not found", { status: 404 }); + const result = await paid(request); + if (result.status === 402) { + if (!options.x402Only) return result.challenge; + const headers = new Headers(result.challenge.headers); + headers.delete("www-authenticate"); + return new Response(result.challenge.body, { status: 402, headers }); + } + return result.withReceipt(Response.json({ data: "paid on the EVM chain" })); + }; +} diff --git a/test/e2e/setup.ts b/test/e2e/setup.ts new file mode 100644 index 00000000..081fd00b --- /dev/null +++ b/test/e2e/setup.ts @@ -0,0 +1 @@ +import "../fixtures/loopback-only.mjs"; diff --git a/test/e2e/vitest.config.ts b/test/e2e/vitest.config.ts new file mode 100644 index 00000000..bfea1f0f --- /dev/null +++ b/test/e2e/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +// `pnpm test:e2e`: the CLI bundles against local Docker chains, a stand-in relay, and local paid +// servers. Opt-in, since it needs Docker; `pnpm test` never runs it. +export default defineConfig({ + test: { + environment: "node", + globalSetup: ["test/e2e/global-setup.ts"], + globals: false, + include: ["test/e2e/**/*.e2e.ts"], + pool: "forks", + // Every file shares the chains, so they run one at a time. + fileParallelism: false, + setupFiles: ["test/e2e/setup.ts"], + testTimeout: 120_000, + hookTimeout: 180_000, + }, +}); diff --git a/test/e2e/wallet.ts b/test/e2e/wallet.ts new file mode 100644 index 00000000..9e05d3b3 --- /dev/null +++ b/test/e2e/wallet.ts @@ -0,0 +1,98 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { Address } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { Account as TempoAccount, Actions, Addresses } from "viem/tempo"; + +import { type Localnet, tempoClient } from "./localnet.js"; + +/** A spending limit the wallet owner signs into the access key's authorization. */ +export type KeyLimit = { token: Address; limit: bigint }; + +export type LocalWallet = { + /** HOME for CLI subprocesses; holds the wallet store. */ + home: string; + /** The wallet (root) account the access key spends for. */ + address: Address; + accessKey: Address; +}; + +/** + * A logged-in wallet on the local Tempo chain, as `tempo wallet login` leaves it: a root account + * holding `funds` and `feeBalance` pathUSD, and an unpublished secp256k1 access key in the wallet + * store whose signed authorization carries `limits` and is published with its first transaction. + */ +export async function localWallet( + net: Localnet, + options: { funds?: KeyLimit[]; limits?: KeyLimit[]; feeBalance?: bigint } = {}, +): Promise { + const rootKey = generatePrivateKey(); + const accessKeyPrivate = generatePrivateKey(); + const root = privateKeyToAccount(rootKey); + const accessKey = privateKeyToAccount(accessKeyPrivate).address; + const funds = options.funds ?? [{ token: net.ousd, limit: 10_000_000n }]; + // Fees count against the key's limits too, so it may also spend the pathUSD it pays fees in. + const limits = options.limits ?? [ + { token: net.ousd, limit: 100_000_000n }, + { token: net.usdc, limit: 100_000_000n }, + { token: Addresses.pathUsd, limit: 1_000_000n }, + ]; + + const issuer = tempoClient(net); + for (const { token, limit } of funds) + await Actions.token.mintSync(issuer, { + token, + to: root.address, + amount: limit, + feeToken: Addresses.pathUsd, + }); + // The local node picks a fee token for a transaction that names none without the account's + // FeeManager preference, sometimes pathUSD, so the wallet holds a little pathUSD for fees. + await Actions.token.transferSync(issuer, { + token: Addresses.pathUsd, + to: root.address, + amount: options.feeBalance ?? 100_000n, + feeToken: Addresses.pathUsd, + }); + + const expiry = Math.floor(Date.now() / 1000) + 86_400; + const keyAuthorization = await TempoAccount.fromSecp256k1(rootKey).signKeyAuthorization( + { address: accessKey, type: "secp256k1" }, + { chainId: BigInt(net.tempoChainId), expiry, limits }, + ); + const home = await mkdtemp(join(tmpdir(), "wallet-cli-e2e-")); + await mkdir(join(home, ".tempo", "wallet"), { recursive: true }); + await writeFile( + join(home, ".tempo", "wallet", "store.json"), + `${JSON.stringify( + { + "tempo-cli.store": { + state: { + accounts: [{ address: root.address }], + accessKeys: [ + { + address: accessKey, + access: root.address, + chainId: net.tempoChainId, + expiry, + keyType: "secp256k1", + privateKey: accessKeyPrivate, + limits, + keyAuthorization, + }, + ], + activeAccount: 0, + chainId: net.tempoChainId, + }, + version: 0, + }, + }, + (_key, value: unknown) => (typeof value === "bigint" ? `${value}#__bigint` : value), + 2, + )}\n`, + { mode: 0o600 }, + ); + return { home, address: root.address, accessKey }; +}