diff --git a/packages/evm-wallet-experiment/docker/.dockerignore b/packages/evm-wallet-experiment/docker/.dockerignore new file mode 100644 index 000000000..6f84426b3 --- /dev/null +++ b/packages/evm-wallet-experiment/docker/.dockerignore @@ -0,0 +1,6 @@ +**/node_modules +**/.git +**/dist +**/coverage +**/.turbo +**/logs diff --git a/packages/evm-wallet-experiment/docker/Dockerfile.evm b/packages/evm-wallet-experiment/docker/Dockerfile.evm new file mode 100644 index 000000000..8009c2d5a --- /dev/null +++ b/packages/evm-wallet-experiment/docker/Dockerfile.evm @@ -0,0 +1,24 @@ +FROM ghcr.io/foundry-rs/foundry:latest AS foundry + +FROM node:22-slim + +WORKDIR /app + +# Copy anvil + cast from the foundry image +COPY --from=foundry /usr/local/bin/anvil /usr/local/bin/anvil +COPY --from=foundry /usr/local/bin/cast /usr/local/bin/cast + +# Pinned to match yarn.lock in the monorepo (@ocap/evm-wallet-experiment). +RUN npm init -y > /dev/null 2>&1 && \ + npm install viem@2.46.2 @metamask/smart-accounts-kit@0.3.0 2>&1 | tail -1 + +COPY packages/evm-wallet-experiment/docker/deploy-contracts.mjs /app/deploy-contracts.mjs +COPY packages/evm-wallet-experiment/docker/entrypoint-evm.sh /app/entrypoint-evm.sh + +RUN mkdir -p /logs /run/ocap + +EXPOSE 8545 + +# Health is defined in docker-compose.yml (contracts.json after deploy). + +ENTRYPOINT ["/bin/sh", "/app/entrypoint-evm.sh"] diff --git a/packages/evm-wallet-experiment/docker/Dockerfile.kernel-base b/packages/evm-wallet-experiment/docker/Dockerfile.kernel-base new file mode 100644 index 000000000..5cda19a2e --- /dev/null +++ b/packages/evm-wallet-experiment/docker/Dockerfile.kernel-base @@ -0,0 +1,70 @@ +FROM node:22 AS builder + +WORKDIR /build + +RUN corepack enable && apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* + +# Copy root workspace config first for layer caching +COPY package.json yarn.lock .yarnrc.yml tsconfig*.json ./ + +# Copy all packages (needed for workspace resolution) +COPY packages/ packages/ + +# Strip ALL postinstall scripts from root and every workspace package. +# These (playwright, git-hooks, native rebuilds) fail in Docker and aren't needed. +RUN node -e " \ + const fs = require('fs'); \ + const path = require('path'); \ + function stripScripts(p) { \ + const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); \ + let changed = false; \ + if (pkg.scripts?.postinstall) { delete pkg.scripts.postinstall; changed = true; } \ + if (pkg.scripts?.install) { delete pkg.scripts.install; changed = true; } \ + if (pkg.scripts?.['rebuild:native']) { delete pkg.scripts['rebuild:native']; changed = true; } \ + if (pkg.lavamoat?.allowScripts) { \ + for (const k of Object.keys(pkg.lavamoat.allowScripts)) pkg.lavamoat.allowScripts[k] = false; \ + changed = true; \ + } \ + if (changed) fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); \ + } \ + stripScripts('package.json'); \ + for (const dir of fs.readdirSync('packages')) { \ + const p = path.join('packages', dir, 'package.json'); \ + if (fs.existsSync(p)) stripScripts(p); \ + }" + +RUN yarn install --immutable + +# Rebuild native addons required at runtime (QUIC / SQLite / WebRTC). Fail the image +# build if compilation does not succeed — do not mask errors with || true. +RUN (cd node_modules/@ipshipyard/node-datachannel && npm run install --ignore-scripts=false) || \ + npm rebuild @ipshipyard/node-datachannel + +# libp2p/webrtc pulls `node-datachannel` (distinct from @ipshipyard); install +# scripts were stripped above, so compile the N-API addon before kernel-cli build. +RUN npm rebuild node-datachannel + +RUN npm rebuild better-sqlite3 + +# Build the kernel CLI and wallet bundles +RUN yarn workspace @metamask/kernel-cli build && \ + yarn workspace @ocap/evm-wallet-experiment build + +# --------------------------------------------------------------------------- +# Target: kernel — minimal kernel runtime (used by tests) +# --------------------------------------------------------------------------- +FROM node:22-slim AS kernel + +WORKDIR /app + +COPY --from=builder /build /app + +RUN mkdir -p /logs /run/ocap + +# --------------------------------------------------------------------------- +# Target: interactive — kernel + OpenClaw + wallet plugin (used interactively) +# --------------------------------------------------------------------------- +FROM kernel AS interactive + +# OpenClaw loads local plugins as TypeScript via jiti (no extra TS runner in the image). +RUN npm install -g openclaw@2026.4.1 diff --git a/packages/evm-wallet-experiment/docker/MAINTAINERS.md b/packages/evm-wallet-experiment/docker/MAINTAINERS.md new file mode 100644 index 000000000..459cb5b32 --- /dev/null +++ b/packages/evm-wallet-experiment/docker/MAINTAINERS.md @@ -0,0 +1,69 @@ +# Docker stack — maintainer notes + +Local E2E stack for `@ocap/evm-wallet-experiment`: Anvil + deployed contracts, Pimlico Alto, and two kernel containers (`home`, `away`). See `package.json` scripts (`docker:compose`, `test:e2e:docker`, etc.). + +## Startup order + +Compose encodes this dependency chain: + +1. **`evm`** becomes healthy when `/run/ocap/contracts.json` exists (written only after `deploy-contracts.mjs` finishes). +2. **`bundler`** waits on that file, reads `EntryPoint`, then starts Alto. +3. **`home` and `away`** wait on **both** `evm` and **`bundler` healthy** so wallet setup does not race Alto boot. + +If you add a service that kernels need before they are ready, extend `depends_on` and healthchecks accordingly. + +## Pinned images and versions + +### Alto (bundler) + +The bundler image uses a **multi-arch OCI index digest**, not `:latest`, so CI and local builds stay aligned. + +To **upgrade Alto**: + +```sh +docker buildx imagetools inspect ghcr.io/pimlicolabs/alto:latest +``` + +Copy the top-level **Digest** (index), then set in `docker-compose.yml`: + +`image: ghcr.io/pimlicolabs/alto@sha256:` + +Keep the comment above that line in sync with the command you used. + +### OpenClaw (interactive image only) + +`Dockerfile.kernel-base` installs a **fixed** global CLI version (`openclaw@…`). The gateway loads **`openclaw-plugin/index.ts`** via **jiti**; nothing in the image invokes `tsx`. Bump OpenClaw deliberately when you want new gateway behavior; avoid `@latest` here. + +Host-side scripts (e.g. `yarn docker:setup:wallets`) use the workspace **`tsx`** devDependency on your machine, not the container. + +### EVM deploy image (`Dockerfile.evm`) + +`viem` and `@metamask/smart-accounts-kit` are installed with **exact versions** that should match **`yarn.lock`** for `@ocap/evm-wallet-experiment`. When you bump those dependencies in the workspace, update the `npm install …@version` line in `Dockerfile.evm` in the same change (or CI/docker builds may diverge from monorepo behavior). + +### Foundry base (`Dockerfile.evm`) + +`foundry:latest` is still a floating tag. If Anvil/cast behavior breaks the stack, consider pinning that image by digest the same way as Alto. + +## Healthchecks + +- **`evm`**: File-based (`contracts.json`). The image itself does not define `HEALTHCHECK`; Compose is the source of truth. +- **`bundler`**: JSON-RPC `eth_supportedEntryPoints` must return a **non-empty** array. If Alto changes RPC surface, adjust the probe in `docker-compose.yml`. +- **`llm`**: HTTP GET `/` on the proxy; **5xx** (e.g. upstream unreachable) marks the service unhealthy. + +## Kernel image build (`Dockerfile.kernel-base`) + +- Postinstall scripts are stripped workspace-wide so `yarn install` succeeds in Docker; **native addons are rebuilt explicitly** afterward. +- **`node-datachannel`** and **`better-sqlite3`** rebuilds **must succeed**; the Dockerfile does not swallow failures. If the image fails to build, fix the toolchain (compilers, libc) rather than reintroducing `|| true`. + +## Security (local dev only) + +`docker-compose.yml` embeds **well-known Anvil private keys** for Alto. That is intentional for an isolated local chain. **Do not reuse this pattern** for any network that is exposed or shared. + +## Interactive profile + +- **`llm`** defaults `LLM_UPSTREAM` to `http://host.docker.internal:8080`. On **Linux**, `host.docker.internal` may be missing unless you add `extra_hosts` or another reachability strategy; document any project-standard workaround here when you add one. +- **`docker-compose.interactive.yml`** overrides `away` (OpenClaw + LLM). Ensure the **`interactive`** profile is used when you expect those services. + +## Ports and conflicts + +Published ports include **8545**, **4337**, **11434** (profile), and **UDP 4001/4002**. They can clash with other stacks on the host; use Compose [profiles](https://docs.docker.com/compose/profiles/) or alternate port mappings if needed. diff --git a/packages/evm-wallet-experiment/docker/create-delegation.mjs b/packages/evm-wallet-experiment/docker/create-delegation.mjs new file mode 100644 index 000000000..1eac3b073 --- /dev/null +++ b/packages/evm-wallet-experiment/docker/create-delegation.mjs @@ -0,0 +1,74 @@ +/* eslint-disable */ +/** + * Create a delegation on the home kernel and push it to the away node over CapTP. + * + * Connects to the home daemon socket only — the delegation flows to the + * away node through the existing peer (OCAP) connection, exercising the real + * cross-kernel path. + * + * Usage: + * node --conditions development /app/packages/evm-wallet-experiment/docker/create-delegation.mjs + * + * Options (env vars): + * CAVEAT_ETH_LIMIT — total native-token transfer limit in ETH (default: unlimited) + */ + +import '@metamask/kernel-shims/endoify-node'; + +import { readFileSync } from 'node:fs'; + +import { makeDaemonClient } from '../test/e2e/docker/helpers/daemon-client.mjs'; +import { + buildCaveatsFromEnv, + createDelegationForDockerStack, + pushDelegationOverPeer, + resolveDelegateForAway, +} from '../test/e2e/docker/helpers/delegation-transfer.mjs'; + +const HOME_INFO = '/run/ocap/home-info.json'; +const AWAY_INFO = '/run/ocap/away-info.json'; +const HOME_SOCKET = '/run/ocap/home.sock'; + +async function main() { + const homeInfo = JSON.parse(readFileSync(HOME_INFO, 'utf8')); + const awayInfo = JSON.parse(readFileSync(AWAY_INFO, 'utf8')); + + const home = makeDaemonClient(HOME_SOCKET); + + const callHome = (method, args) => + home.callVat(homeInfo.coordinatorKref, method, args); + + const delegate = resolveDelegateForAway(awayInfo); + console.log(`[delegation] home coordinator: ${homeInfo.coordinatorKref}`); + console.log( + `[delegation] away delegate: ${delegate}${awayInfo.smartAccountAddress ? ' (smart account)' : ' (EOA)'}`, + ); + + const caveats = buildCaveatsFromEnv(); + const ethLimit = process.env.CAVEAT_ETH_LIMIT; + if (ethLimit) { + console.log( + `[delegation] caveat: nativeTokenTransferAmount <= ${ethLimit} ETH`, + ); + } + + console.log('[delegation] creating on home...'); + const delegation = await createDelegationForDockerStack({ + callHome, + awayInfo, + caveats, + }); + console.log(`[delegation] id: ${delegation.id}`); + console.log(`[delegation] status: ${delegation.status}`); + + console.log('[delegation] pushing to away over CapTP...'); + await pushDelegationOverPeer(callHome, delegation); + console.log( + '[delegation] done — away received the delegation over the peer connection.', + ); +} + +main().catch((err) => { + console.error('[delegation] FATAL:', err); + process.exit(1); +}); diff --git a/packages/evm-wallet-experiment/docker/deploy-contracts.mjs b/packages/evm-wallet-experiment/docker/deploy-contracts.mjs new file mode 100644 index 000000000..b427dbb4e --- /dev/null +++ b/packages/evm-wallet-experiment/docker/deploy-contracts.mjs @@ -0,0 +1,128 @@ +/* eslint-disable n/no-process-exit, n/no-process-env, n/no-sync, import-x/no-unresolved, jsdoc/require-jsdoc, id-denylist */ +/** + * Deploy ERC-4337 + MetaMask delegation contracts to the local Anvil chain. + * + * 1. Deploys the deterministic deployer (Nick's Factory) — required by Alto + * bundler for deploying its simulation contracts via CREATE2. + * 2. Uses `deploySmartAccountsEnvironment()` from @metamask/smart-accounts-kit + * to deploy EntryPoint, DelegationManager, enforcers, and factory. + * + * Writes the deployed addresses to /run/ocap/contracts.json for other + * services to consume. + * + * Usage: + * node packages/evm-wallet-experiment/docker/deploy-contracts.mjs + * + * Env vars: + * EVM_RPC_URL — JSON-RPC endpoint (default: http://evm:8545) + */ + +import { deploySmartAccountsEnvironment } from '@metamask/smart-accounts-kit/utils'; +import { writeFileSync } from 'node:fs'; +import { createPublicClient, createWalletClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { foundry } from 'viem/chains'; + +const RPC_URL = process.env.EVM_RPC_URL || 'http://evm:8545'; +const OUTPUT_PATH = '/run/ocap/contracts.json'; + +// Anvil account #18 (index 18 from test mnemonic) — reserved for contract +// deployment so it doesn't collide with home (0) or away throwaway accounts. +const DEPLOYER_KEY = + '0xde9be858da4a475276426320d5e9262ecfc3ba460bfac56360bfa6c4c28b4ee0'; + +// Nick's deterministic deployer — the standard CREATE2 factory used by ERC-4337 +// and Alto bundler. Must be at this exact address for deterministic deployment. +// See: https://github.com/Arachnid/deterministic-deployment-proxy +const NICK_FACTORY_ADDRESS = '0x4e59b44847b379578588920cA78FbF26c0B4956C'; +const NICK_FACTORY_DEPLOYER = '0x3fab184622dc19b6109349b94811493bf2a45362'; +// Pre-signed deployment transaction (chain-agnostic, works on any EVM chain) +const NICK_FACTORY_TX = + '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222'; + +async function deployNickFactory(publicClient, transport) { + const code = await publicClient.getCode({ address: NICK_FACTORY_ADDRESS }); + if (code && code !== '0x') { + console.log('[deploy] Nick factory already deployed.'); + return; + } + + console.log('[deploy] Deploying deterministic deployer (Nick factory)...'); + + // Fund the deployer address (it needs ETH for gas) + const funder = privateKeyToAccount(DEPLOYER_KEY); + const funderClient = createWalletClient({ + account: funder, + chain: foundry, + transport, + }); + await funderClient.sendTransaction({ + to: NICK_FACTORY_DEPLOYER, + value: 100000000000000000n, // 0.1 ETH + }); + + // Send the pre-signed deployment transaction via raw RPC + const response = await fetch(RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_sendRawTransaction', + params: [NICK_FACTORY_TX], + }), + }); + const result = await response.json(); + if (result.error) { + throw new Error( + `Failed to deploy Nick factory: ${JSON.stringify(result.error)}`, + ); + } + console.log(`[deploy] Nick factory deployed at ${NICK_FACTORY_ADDRESS}`); +} + +async function main() { + console.log(`[deploy] Deploying contracts to ${RPC_URL}...`); + + const account = privateKeyToAccount(DEPLOYER_KEY); + const transport = http(RPC_URL); + + const publicClient = createPublicClient({ + chain: foundry, + transport, + }); + + const walletClient = createWalletClient({ + account, + chain: foundry, + transport, + }); + + // Step 1: Deploy the deterministic deployer (needed by Alto bundler) + await deployNickFactory(publicClient, transport); + + // Step 2: Deploy ERC-4337 + delegation contracts + const env = await deploySmartAccountsEnvironment( + walletClient, + publicClient, + foundry, + ); + + console.log(`[deploy] EntryPoint: ${env.EntryPoint}`); + console.log(`[deploy] DelegationManager: ${env.DelegationManager}`); + console.log(`[deploy] SimpleFactory: ${env.SimpleFactory}`); + console.log( + `[deploy] Implementations: ${JSON.stringify(env.implementations)}`, + ); + console.log( + `[deploy] CaveatEnforcers: ${JSON.stringify(env.caveatEnforcers)}`, + ); + + writeFileSync(OUTPUT_PATH, JSON.stringify(env, null, 2)); + console.log(`[deploy] Addresses written to ${OUTPUT_PATH}`); +} + +main().catch((err) => { + console.error('[deploy] FATAL:', err); + process.exit(1); +}); diff --git a/packages/evm-wallet-experiment/docker/docker-compose.interactive.yml b/packages/evm-wallet-experiment/docker/docker-compose.interactive.yml new file mode 100644 index 000000000..f84413ccb --- /dev/null +++ b/packages/evm-wallet-experiment/docker/docker-compose.interactive.yml @@ -0,0 +1,18 @@ +# Compose override for interactive use (OpenClaw + LLM). +# +# Usage: +# yarn docker:interactive:up +# +# Then run wallet + OpenClaw setup: +# yarn docker:interactive:setup +services: + away: + build: + target: interactive + environment: + - LLM_BASE_URL=${LLM_BASE_URL:-http://llm:11434/v1} + - LLM_MODEL=${LLM_MODEL:-glm-4.7-flash} + - LLM_API_TYPE=${LLM_API_TYPE:-openai-completions} + depends_on: + llm: + condition: service_healthy diff --git a/packages/evm-wallet-experiment/docker/docker-compose.yml b/packages/evm-wallet-experiment/docker/docker-compose.yml new file mode 100644 index 000000000..860d1ba1a --- /dev/null +++ b/packages/evm-wallet-experiment/docker/docker-compose.yml @@ -0,0 +1,167 @@ +services: + evm: + build: + context: ../../../ + dockerfile: packages/evm-wallet-experiment/docker/Dockerfile.evm + networks: [ocap-e2e] + ports: ['8545:8545'] + volumes: + - ocap-run:/run/ocap + - ocap-logs:/logs + healthcheck: + test: ['CMD', 'test', '-f', '/run/ocap/contracts.json'] + interval: 3s + timeout: 5s + retries: 30 + + bundler: + # Multi-arch index digest — refresh: docker buildx imagetools inspect ghcr.io/pimlicolabs/alto:latest + image: ghcr.io/pimlicolabs/alto@sha256:8420c602c1b4618d4e244e693f8d4cfd28fc86fd5808b74fdd185730f934e29e + networks: [ocap-e2e] + ports: ['4337:4337'] + volumes: + - ocap-run:/run/ocap + entrypoint: + - /bin/sh + - -c + - | + echo "Waiting for contracts..." + while [ ! -f /run/ocap/contracts.json ]; do sleep 1; done + ENTRYPOINT=$$(node -e "console.log(JSON.parse(require('fs').readFileSync('/run/ocap/contracts.json','utf8')).EntryPoint)") + echo "EntryPoint: $$ENTRYPOINT" + exec npm start -- \ + --port 4337 \ + --rpc-url http://evm:8545 \ + --entrypoints "$$ENTRYPOINT" \ + --executor-private-keys "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" \ + --utility-private-key "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" \ + --safe-mode false \ + --balance-override true + depends_on: + evm: + condition: service_healthy + healthcheck: + test: + - CMD + - node + - -e + - >- + fetch("http://localhost:4337",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:"eth_supportedEntryPoints",params:[]})}).then(async(r)=>{if(!r.ok)return process.exit(1);const j=await r.json();process.exit(!j.error&&Array.isArray(j.result)&&j.result.length>0?0:1)}).catch(()=>process.exit(1)) + interval: 3s + timeout: 5s + retries: 20 + + llm: + profiles: [interactive] + build: + context: ../../../ + dockerfile: packages/evm-wallet-experiment/docker/Dockerfile.kernel-base + target: kernel + environment: + - LLM_UPSTREAM=${LLM_UPSTREAM:-http://host.docker.internal:8080} + networks: [ocap-e2e] + ports: ['11434:11434'] + entrypoint: + - node + - /app/packages/evm-wallet-experiment/docker/entrypoint-llm-proxy.mjs + healthcheck: + test: + - CMD + - node + - -e + - >- + fetch("http://127.0.0.1:11434/").then((r)=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1)) + interval: 3s + timeout: 5s + retries: 10 + + ollama: + profiles: [ollama] + image: ollama/ollama + networks: [ocap-e2e] + volumes: + - ollama-models:/root/.ollama + - ocap-logs:/logs + - ./entrypoint-llm.sh:/entrypoint-llm.sh:ro + entrypoint: ['/bin/sh', '/entrypoint-llm.sh'] + healthcheck: + test: ['CMD', 'test', '-f', '/tmp/llm-ready'] + interval: 5s + timeout: 5s + retries: 120 + start_period: 300s + + home: + build: + context: ../../../ + dockerfile: packages/evm-wallet-experiment/docker/Dockerfile.kernel-base + target: kernel + environment: + - SERVICE_NAME=home + - HOME=/run/ocap/home + - SOCKET_PATH=/run/ocap/home/.ocap/daemon.sock + - QUIC_LISTEN_ADDRESS=/ip4/0.0.0.0/udp/4001/quic-v1 + - READY_FILE=/run/ocap/home-ready.json + networks: [ocap-e2e] + ports: ['4001:4001/udp'] + volumes: + - ocap-run:/run/ocap + - ocap-logs:/logs + entrypoint: + - node + - '--conditions' + - development + - /app/packages/evm-wallet-experiment/docker/entrypoint-kernel.mjs + depends_on: + evm: + condition: service_healthy + bundler: + condition: service_healthy + healthcheck: + test: ['CMD', 'test', '-f', '/run/ocap/home-ready.json'] + interval: 3s + timeout: 5s + retries: 60 + start_period: 30s + + away: + build: + context: ../../../ + dockerfile: packages/evm-wallet-experiment/docker/Dockerfile.kernel-base + target: kernel + environment: + - SERVICE_NAME=away + - HOME=/run/ocap/away + - SOCKET_PATH=/run/ocap/away/.ocap/daemon.sock + - QUIC_LISTEN_ADDRESS=/ip4/0.0.0.0/udp/4002/quic-v1 + - READY_FILE=/run/ocap/away-ready.json + networks: [ocap-e2e] + ports: ['4002:4002/udp'] + volumes: + - ocap-run:/run/ocap + - ocap-logs:/logs + entrypoint: + - node + - '--conditions' + - development + - /app/packages/evm-wallet-experiment/docker/entrypoint-kernel.mjs + depends_on: + evm: + condition: service_healthy + bundler: + condition: service_healthy + healthcheck: + test: ['CMD', 'test', '-f', '/run/ocap/away-ready.json'] + interval: 3s + timeout: 5s + retries: 60 + start_period: 60s + +networks: + ocap-e2e: + driver: bridge + +volumes: + ocap-run: + ocap-logs: + ollama-models: diff --git a/packages/evm-wallet-experiment/docker/docker-e2e-stack-constants.json b/packages/evm-wallet-experiment/docker/docker-e2e-stack-constants.json new file mode 100644 index 000000000..ecd9a4388 --- /dev/null +++ b/packages/evm-wallet-experiment/docker/docker-e2e-stack-constants.json @@ -0,0 +1,10 @@ +{ + "llm": { + "upstreamOllama": "http://ollama:11434", + "baseUrl": "http://llm:11434", + "model": "qwen2.5:0.5b", + "apiType": "ollama" + }, + "anvilChainId": 31337, + "openclawPluginPathContainer": "/app/packages/evm-wallet-experiment/openclaw-plugin" +} diff --git a/packages/evm-wallet-experiment/docker/docker-e2e-stack-constants.mjs b/packages/evm-wallet-experiment/docker/docker-e2e-stack-constants.mjs new file mode 100644 index 000000000..3c3bb6a64 --- /dev/null +++ b/packages/evm-wallet-experiment/docker/docker-e2e-stack-constants.mjs @@ -0,0 +1,22 @@ +/* eslint-disable n/no-sync */ +/** + * Loads `docker-e2e-stack-constants.json` and exports `dockerConfig`. + * Edit the JSON file to change stack defaults; keep this module as the single import path. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const jsonPath = join(dir, 'docker-e2e-stack-constants.json'); + +/** + * @typedef {object} DockerStackConfig + * @property {{ upstreamOllama: string, baseUrl: string, model: string, apiType: string }} llm - Defaults for LLM proxy and OpenClaw provider. + * @property {number} anvilChainId - Chain ID for the local Anvil stack in Docker E2E. + * @property {string} openclawPluginPathContainer - Wallet plugin path inside the kernel container. + */ + +/** @type {DockerStackConfig} */ +export const dockerConfig = JSON.parse(readFileSync(jsonPath, 'utf8')); diff --git a/packages/evm-wallet-experiment/docker/entrypoint-evm.sh b/packages/evm-wallet-experiment/docker/entrypoint-evm.sh new file mode 100755 index 000000000..1b8cc0a1c --- /dev/null +++ b/packages/evm-wallet-experiment/docker/entrypoint-evm.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# EVM container entrypoint — starts Anvil (Prague hardfork), deploys +# delegation framework contracts, then keeps Anvil running. +set -e + +echo "[evm] Starting Anvil (Prague hardfork)..." +anvil \ + --host 0.0.0.0 \ + --port 8545 \ + --hardfork prague \ + --accounts 20 \ + --balance 10000 \ + --mnemonic 'test test test test test test test test test test test junk' \ + 2>&1 | tee /logs/evm.log & + +ANVIL_PID=$! + +# Wait for Anvil to be ready +echo "[evm] Waiting for Anvil..." +until cast bn --rpc-url http://localhost:8545 > /dev/null 2>&1; do + sleep 0.5 +done +echo "[evm] Anvil ready." + +# Deploy delegation framework contracts +echo "[evm] Deploying contracts..." +EVM_RPC_URL=http://localhost:8545 node /app/deploy-contracts.mjs + +echo "[evm] Chain ready with contracts deployed." + +# Keep Anvil in foreground +wait $ANVIL_PID diff --git a/packages/evm-wallet-experiment/docker/entrypoint-kernel.mjs b/packages/evm-wallet-experiment/docker/entrypoint-kernel.mjs new file mode 100644 index 000000000..aca16dacb --- /dev/null +++ b/packages/evm-wallet-experiment/docker/entrypoint-kernel.mjs @@ -0,0 +1,107 @@ +/* eslint-disable n/no-process-env, n/no-process-exit, n/no-sync, import-x/no-unresolved */ +/** + * Generic kernel entrypoint for Docker E2E containers. + * + * Boots a kernel daemon with optional QUIC transport, starts the RPC socket + * server, and writes a readiness file. All wallet-specific configuration + * (subclusters, keyrings, providers, etc.) is driven from the host. + * + * Env vars: + * SERVICE_NAME — log prefix (e.g. "home", "away") + * SOCKET_PATH — Unix socket path for the RPC server + * QUIC_LISTEN_ADDRESS — optional QUIC multiaddr (omit to skip transport) + * READY_FILE — path to write readiness JSON + */ + +import '@metamask/kernel-shims/endoify-node'; + +import { NodejsPlatformServices } from '@metamask/kernel-node-runtime'; +import { startRpcSocketServer } from '@metamask/kernel-node-runtime/daemon'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { Kernel } from '@metamask/ocap-kernel'; +import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const NAME = process.env.SERVICE_NAME ?? 'kernel'; +const { SOCKET_PATH } = process.env; +const QUIC_ADDR = process.env.QUIC_LISTEN_ADDRESS; +const { READY_FILE } = process.env; + +if (!SOCKET_PATH) { + console.error(`[${NAME}] FATAL: SOCKET_PATH is required`); + process.exit(1); +} +if (!READY_FILE) { + console.error(`[${NAME}] FATAL: READY_FILE is required`); + process.exit(1); +} + +/** + * Boot the kernel and start the RPC socket server. + */ +async function main() { + // Clean stale files from previous runs + mkdirSync(dirname(SOCKET_PATH), { recursive: true }); + try { + unlinkSync(SOCKET_PATH); + } catch { + /* ok */ + } + try { + unlinkSync(READY_FILE); + } catch { + /* ok */ + } + + console.log(`[${NAME}] Booting kernel...`); + const db = await makeSQLKernelDatabase({ dbFilename: ':memory:' }); + const kernel = await Kernel.make(new NodejsPlatformServices({}), db, { + resetStorage: true, + }); + await kernel.initIdentity(); + + let peerId; + let listenAddresses; + + if (QUIC_ADDR) { + console.log(`[${NAME}] Initializing QUIC transport on ${QUIC_ADDR}...`); + await kernel.initRemoteComms({ + directListenAddresses: [QUIC_ADDR], + }); + + const deadline = Date.now() + 30_000; + let status = await kernel.getStatus(); + while (status.remoteComms?.state !== 'connected' && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 500)); + status = await kernel.getStatus(); + } + if (status.remoteComms?.state !== 'connected') { + console.error(`[${NAME}] FATAL: Remote comms failed to connect`); + process.exit(1); + } + ({ peerId, listenAddresses } = status.remoteComms); + console.log(`[${NAME}] Peer ID: ${peerId.slice(0, 16)}...`); + } + + console.log(`[${NAME}] Starting RPC socket server at ${SOCKET_PATH}...`); + await startRpcSocketServer({ + socketPath: SOCKET_PATH, + kernel, + kernelDatabase: db, + }); + + const info = { + socketPath: SOCKET_PATH, + ...(peerId ? { peerId, listenAddresses } : {}), + }; + writeFileSync(READY_FILE, JSON.stringify(info, null, 2)); + console.log(`[${NAME}] Ready.`); + + // Keep alive + setInterval(() => undefined, 60_000); +} + +main().catch((error) => { + console.error(`[${NAME}] FATAL:`, error); + process.exit(1); +}); diff --git a/packages/evm-wallet-experiment/docker/entrypoint-llm-proxy.mjs b/packages/evm-wallet-experiment/docker/entrypoint-llm-proxy.mjs new file mode 100644 index 000000000..068773b72 --- /dev/null +++ b/packages/evm-wallet-experiment/docker/entrypoint-llm-proxy.mjs @@ -0,0 +1,54 @@ +/* eslint-disable n/no-process-env, id-denylist */ +/** + * Lightweight LLM reverse proxy. + * + * Forwards all requests from http://0.0.0.0:11434 to an upstream LLM. + * This lets the away node always talk to http://llm:11434 regardless of + * whether the LLM is Ollama in a container or llama.cpp on the host. + * + * Env vars: + * LLM_UPSTREAM — upstream URL (default: http://host.docker.internal:8080) + */ + +import { createServer, request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; + +const upstream = new URL( + process.env.LLM_UPSTREAM || 'http://host.docker.internal:8080', +); +const PORT = 11434; + +const server = createServer((req, res) => { + const target = new URL(req.url, upstream); + const requester = target.protocol === 'https:' ? httpsRequest : httpRequest; + + const proxyReq = requester( + target, + { + method: req.method, + headers: { ...req.headers, host: target.host }, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }, + ); + + proxyReq.on('error', (err) => { + console.error( + `[llm-proxy] ${req.method} ${req.url} -> ${target}: ${err.message}`, + ); + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ error: `upstream unreachable: ${err.message}` }), + ); + } + }); + + req.pipe(proxyReq); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`[llm-proxy] forwarding :${PORT} -> ${upstream.origin}`); +}); diff --git a/packages/evm-wallet-experiment/docker/entrypoint-llm.sh b/packages/evm-wallet-experiment/docker/entrypoint-llm.sh new file mode 100755 index 000000000..8049ef1e3 --- /dev/null +++ b/packages/evm-wallet-experiment/docker/entrypoint-llm.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e +# Start Ollama server in background +ollama serve > /logs/llm.log 2>&1 & + +# Wait for Ollama to be ready (no curl in ollama image, use ollama CLI) +echo "Waiting for Ollama to start..." +until ollama list > /dev/null 2>&1; do + sleep 1 +done +echo "Ollama is ready." + +# Pull the model (skipped if already cached in the ollama-models volume) +echo "Pulling qwen2.5:0.5b..." +ollama pull qwen2.5:0.5b 2>&1 | tee -a /logs/llm.log + +echo "Model ready. Ollama running." +# Write readiness marker for Docker healthcheck +touch /tmp/llm-ready + +# Keep container alive by following the log +tail -f /logs/llm.log diff --git a/packages/evm-wallet-experiment/docker/setup-openclaw.mjs b/packages/evm-wallet-experiment/docker/setup-openclaw.mjs new file mode 100644 index 000000000..89c51492b --- /dev/null +++ b/packages/evm-wallet-experiment/docker/setup-openclaw.mjs @@ -0,0 +1,141 @@ +/* eslint-disable n/no-sync, n/no-process-env */ +/** + * Configure OpenClaw for the away node container. + * + * Writes the complete openclaw.json, auth profiles, and workspace + * directories in a single pass — no `openclaw` CLI calls needed. + * This replaces the fragile sequence of `openclaw onboard` + + * `openclaw config set` calls that broke on every OpenClaw release. + * + * Env vars (all optional, with Docker-friendly defaults): + * LLM_BASE_URL — LLM provider base URL (default: http://llm:11434) + * LLM_MODEL — Model ID (default: qwen2.5:0.5b) + * LLM_API_TYPE — OpenClaw API type: ollama | openai-completions | … (default: ollama) + * + * Usage: + * node /app/packages/evm-wallet-experiment/docker/setup-openclaw.mjs + */ + +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { dockerConfig } from './docker-e2e-stack-constants.mjs'; + +const home = process.env.HOME || '/run/ocap/away'; +const ocDir = resolve(home, '.openclaw'); + +const llmBaseUrl = process.env.LLM_BASE_URL || dockerConfig.llm.baseUrl; +const llmModel = process.env.LLM_MODEL || dockerConfig.llm.model; +const llmApiType = process.env.LLM_API_TYPE || dockerConfig.llm.apiType; +const providerId = 'llm'; +const pluginPath = dockerConfig.openclawPluginPathContainer; + +// -- Directories (what `openclaw onboard` scaffolds) -- +const dirs = [ + resolve(ocDir, 'workspace'), + resolve(ocDir, 'agents/main/agent'), + resolve(ocDir, 'agents/main/sessions'), + resolve(ocDir, 'canvas'), + resolve(ocDir, 'cron'), + resolve(ocDir, 'extensions'), +]; +for (const dir of dirs) { + mkdirSync(dir, { recursive: true }); +} + +// -- Generate a gateway auth token -- +const gatewayToken = randomBytes(24).toString('hex'); + +// -- Write openclaw.json (complete config, no patching) -- +const config = { + meta: { + lastTouchedVersion: 'docker-setup', + lastTouchedAt: new Date().toISOString(), + }, + models: { + mode: 'merge', + providers: { + [providerId]: { + baseUrl: llmBaseUrl, + api: llmApiType, + models: [ + { + id: llmModel, + name: llmModel, + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32768, + maxTokens: 8192, + }, + ], + }, + }, + }, + agents: { + defaults: { + model: { primary: `${providerId}/${llmModel}` }, + workspace: resolve(ocDir, 'workspace'), + compaction: { mode: 'safeguard' }, + }, + }, + // Match non-Docker setup (`setup-away.sh` / setup guide): allow all tools from the wallet plugin. + tools: { + allow: ['wallet'], + }, + commands: { + native: 'auto', + nativeSkills: 'auto', + restart: true, + ownerDisplay: 'raw', + }, + session: { dmScope: 'per-channel-peer' }, + gateway: { + port: 18789, + mode: 'local', + bind: 'loopback', + auth: { mode: 'token', token: gatewayToken }, + tailscale: { mode: 'off', resetOnExit: false }, + }, + skills: { install: { nodeManager: 'npm' } }, + plugins: { + allow: ['wallet'], + load: { paths: [pluginPath] }, + entries: { wallet: { enabled: true } }, + }, +}; + +const cfgPath = resolve(ocDir, 'openclaw.json'); +writeFileSync(cfgPath, JSON.stringify(config, null, 2)); +console.log( + `[openclaw-setup] config written: ${providerId}/${llmModel} (api: ${llmApiType})`, +); + +// -- Write auth profiles for the provider -- +const authDir = resolve(ocDir, 'agents/main/agent'); +const profiles = { + version: 1, + profiles: { + [providerId]: { + type: 'api_key', + key: 'dummy', + provider: providerId, + }, + }, +}; +writeFileSync( + resolve(authDir, 'auth-profiles.json'), + JSON.stringify(profiles, null, 2), +); +console.log(`[openclaw-setup] auth profile written for: ${providerId}`); + +// -- Verify plugin exists -- +const pluginEntry = resolve(pluginPath, 'index.ts'); +if (!existsSync(pluginEntry)) { + console.error( + `[openclaw-setup] WARN: plugin not found at ${pluginEntry} — wallet tools will not load`, + ); +} + +console.log('[openclaw-setup] done'); diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index f0a2f9d02..865367da7 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -55,7 +55,19 @@ "test:node:sepolia": "yarn build && node --conditions development test/e2e/run-sepolia-e2e.mjs", "test:node:sepolia-7702-direct": "yarn build && node --conditions development test/e2e/run-sepolia-7702-direct-e2e.mjs", "test:node:peer-e2e": "yarn build && node --conditions development test/e2e/run-peer-e2e.mjs", - "test:node:spending-limits": "yarn build && node --conditions development test/e2e/run-spending-limits-e2e.mjs" + "test:node:spending-limits": "yarn build && node --conditions development test/e2e/run-spending-limits-e2e.mjs", + "docker:compose": "docker compose -f docker/docker-compose.yml", + "docker:compose:interactive": "docker compose -f docker/docker-compose.yml -f docker/docker-compose.interactive.yml --profile interactive", + "docker:build": "yarn docker:compose build", + "docker:up": "yarn docker:compose up", + "docker:down": "yarn docker:compose down", + "docker:interactive:up": "yarn docker:compose:interactive up", + "docker:interactive:down": "yarn docker:compose:interactive down", + "docker:setup:wallets": "yarn tsx test/e2e/docker/setup-wallets.ts", + "docker:interactive:setup": "yarn docker:setup:wallets && yarn docker:compose:interactive exec away node /app/packages/evm-wallet-experiment/docker/setup-openclaw.mjs && yarn docker:compose:interactive exec -d away openclaw gateway && echo 'OpenClaw configured + gateway started. Shell in with: yarn docker:compose:interactive exec away bash'", + "docker:delegate": "yarn docker:compose cp docker/create-delegation.mjs home:/app/packages/evm-wallet-experiment/docker/create-delegation.mjs && yarn docker:compose exec home node --conditions development /app/packages/evm-wallet-experiment/docker/create-delegation.mjs", + "docker:logs": "yarn docker:compose logs -f --tail=100", + "test:e2e:docker": "vitest run --config vitest.config.docker.ts" }, "dependencies": { "@endo/eventual-send": "^1.3.4", @@ -97,6 +109,7 @@ "eslint-plugin-promise": "^7.2.1", "prettier": "^3.5.3", "rimraf": "^6.0.1", + "tsx": "^4.20.6", "turbo": "^2.9.1", "typedoc": "^0.28.1", "typescript": "~5.8.2", diff --git a/packages/evm-wallet-experiment/scripts/home-interactive.mjs b/packages/evm-wallet-experiment/scripts/home-interactive.mjs index 65aebc066..6281dc19d 100644 --- a/packages/evm-wallet-experiment/scripts/home-interactive.mjs +++ b/packages/evm-wallet-experiment/scripts/home-interactive.mjs @@ -673,7 +673,7 @@ ${GREEN}${BOLD}============================================== ${DIM}RPC URL :${RESET} ${RPC_URL} ${DIM}Account :${RESET} ${accounts[0]}${smartAccountAddress ? `\n ${DIM}Smart Account :${RESET} ${smartAccountAddress}` : ''} -${YELLOW}${BOLD} Run this on the away device (VPS):${RESET} +${YELLOW}${BOLD} Run this on the away device:${RESET} ${BOLD} ./packages/evm-wallet-experiment/scripts/setup-away.sh \\ --ocap-url "${ocapUrl}" \\ diff --git a/packages/evm-wallet-experiment/scripts/resolve-chain.sh b/packages/evm-wallet-experiment/scripts/resolve-chain.sh index 952a6f1f4..6b612630e 100755 --- a/packages/evm-wallet-experiment/scripts/resolve-chain.sh +++ b/packages/evm-wallet-experiment/scripts/resolve-chain.sh @@ -25,13 +25,15 @@ resolve_chain() { arbitrum|arb) CHAIN_ID=42161 ;; linea) CHAIN_ID=59144 ;; sepolia) CHAIN_ID=11155111 ;; + # Local 31337: Anvil in Docker E2E; `hardhat` alias kept for older scripts. + anvil|localhost|hardhat) CHAIN_ID=31337 ;; *) # Try as numeric ID if echo "$input" | grep -qE '^[0-9]+$'; then CHAIN_ID="$input" # Validate it's a supported chain case "$CHAIN_ID" in - 1|10|56|137|8453|42161|59144|11155111) ;; # ok + 1|10|56|137|8453|42161|59144|11155111|31337) ;; # ok *) echo "Error: Chain ID $CHAIN_ID is not supported." >&2 print_supported_chains return 1 ;; @@ -55,6 +57,7 @@ chain_name() { 42161) echo "Arbitrum One" ;; 59144) echo "Linea" ;; 11155111) echo "Sepolia" ;; + 31337) echo "Anvil" ;; *) echo "unknown" ;; esac } @@ -124,4 +127,5 @@ print_supported_chains() { echo " arbitrum 42161 arb" >&2 echo " linea 59144" >&2 echo " sepolia 11155111" >&2 + echo " anvil 31337 localhost" >&2 } diff --git a/packages/evm-wallet-experiment/scripts/setup-away.sh b/packages/evm-wallet-experiment/scripts/setup-away.sh index 2adc192c7..c3cf4c5f8 100755 --- a/packages/evm-wallet-experiment/scripts/setup-away.sh +++ b/packages/evm-wallet-experiment/scripts/setup-away.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Setup script for the AWAY wallet device (VPS / agent machine). +# Setup script for the AWAY wallet device (agent machine). # # Starts the daemon, launches the wallet subcluster, initialises a throwaway # keyring, connects to the home wallet via the provided OCAP URL, and verifies @@ -40,6 +40,7 @@ SKIP_BUILD=false QUIC_PORT=4002 DELEGATION_MANAGER="0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3" CUSTOM_RPC_URL="" +NON_INTERACTIVE=false # --------------------------------------------------------------------------- # Parse arguments @@ -62,6 +63,7 @@ Optional: --rpc-url Custom RPC URL (overrides Infura URL derivation) --quic-port UDP port for QUIC transport (default: $QUIC_PORT) --no-build Skip yarn build + --non-interactive Skip interactive prompts (for Docker/CI) EOF print_supported_chains exit 1 @@ -97,6 +99,7 @@ while [[ $# -gt 0 ]]; do [[ $# -lt 2 ]] && { echo "Error: --quic-port requires a value" >&2; usage; } QUIC_PORT="$2"; shift 2 ;; --no-build) SKIP_BUILD=true; shift ;; + --non-interactive) NON_INTERACTIVE=true; shift ;; -h|--help) usage ;; *) echo "Unknown option: $1" >&2; usage ;; esac @@ -337,29 +340,27 @@ ok "Location hints registered for peer $HOME_PEER_ID" info "Launching wallet subcluster..." -# Extract RPC hostname for allowedHosts (if provider will be configured) +# Extract RPC host (including port) for allowedHosts. +# new URL(url).host includes the port for non-default ports (e.g. "evm:8545"). AWAY_RPC_HOST="" -if [[ -n "$INFURA_KEY" ]]; then - if [[ -n "$CUSTOM_RPC_URL" ]]; then - AWAY_RPC_HOST=$(echo "$CUSTOM_RPC_URL" | node -e " - const u = require('fs').readFileSync('/dev/stdin','utf8').trim(); - const m = u.match(/^https?:\\/\\/([^/:]+)/); - if (m) process.stdout.write(m[1]); - ") - else - AWAY_RPC_HOST=$(echo "$(infura_rpc_url "$CHAIN_ID" "x")" | node -e " - const u = require('fs').readFileSync('/dev/stdin','utf8').trim(); - const m = u.match(/^https?:\\/\\/([^/:]+)/); - if (m) process.stdout.write(m[1]); - ") - fi +if [[ -n "$CUSTOM_RPC_URL" ]]; then + AWAY_RPC_HOST=$(echo "$CUSTOM_RPC_URL" | node -e " + const u = require('fs').readFileSync('/dev/stdin','utf8').trim(); + try { process.stdout.write(new URL(u).host); } catch {} + ") +elif [[ -n "$INFURA_KEY" ]]; then + AWAY_RPC_HOST=$(echo "$(infura_rpc_url "$CHAIN_ID" "x")" | node -e " + const u = require('fs').readFileSync('/dev/stdin','utf8').trim(); + try { process.stdout.write(new URL(u).host); } catch {} + ") fi CONFIG=$(BUNDLE_DIR="$BUNDLE_DIR" DM="$DELEGATION_MANAGER" RPC_HOST="$AWAY_RPC_HOST" node -e " const bd = process.env.BUNDLE_DIR; const dm = process.env.DM; const rpcHost = process.env.RPC_HOST; - const hosts = [rpcHost, 'api.pimlico.io', 'swap.api.cx.metamask.io'].filter(Boolean); + const extra = (process.env.EXTRA_ALLOWED_HOSTS || '').split(',').filter(Boolean); + const hosts = [rpcHost, 'api.pimlico.io', 'swap.api.cx.metamask.io', ...extra].filter(Boolean); const config = { config: { bootstrap: 'coordinator', @@ -424,13 +425,16 @@ ok "Local throwaway account: $ACCOUNTS" # 7. Configure provider (optional — only if Infura key provided) # --------------------------------------------------------------------------- -if [[ -n "$INFURA_KEY" ]]; then - if [[ -n "$CUSTOM_RPC_URL" ]]; then - RPC_URL="$CUSTOM_RPC_URL" - else - RPC_URL=$(infura_rpc_url "$CHAIN_ID" "$INFURA_KEY") || exit 1 - fi - info "Configuring provider ($(chain_name "$CHAIN_ID"), chain $CHAIN_ID)..." +# Resolve the RPC URL: --rpc-url takes precedence, then Infura derivation +RPC_URL="" +if [[ -n "$CUSTOM_RPC_URL" ]]; then + RPC_URL="$CUSTOM_RPC_URL" +elif [[ -n "$INFURA_KEY" ]]; then + RPC_URL=$(infura_rpc_url "$CHAIN_ID" "$INFURA_KEY") || exit 1 +fi + +if [[ -n "$RPC_URL" ]]; then + info "Configuring provider (chain $CHAIN_ID)..." PROVIDER_ARGS=$(CID="$CHAIN_ID" URL="$RPC_URL" node -e " process.stdout.write(JSON.stringify([{ chainId: Number(process.env.CID), rpcUrl: process.env.URL }])); @@ -564,40 +568,47 @@ $(echo -e "${GREEN}${BOLD}")═════════════════ EOF -info "Waiting for delegation from home device (up to 10 min)..." -echo -e " ${DIM}Press Enter to skip waiting and paste manually.${RESET}" >&2 - -DEL_COUNT="0" -POLL_FAILURES=0 -MANUAL_SKIP=false -for i in $(seq 1 300); do - CAPS_RESULT=$(daemon_qm --quiet "$ROOT_KREF" getCapabilities 2>/dev/null) || CAPS_RESULT="" - if [[ -n "$CAPS_RESULT" ]]; then - POLL_FAILURES=0 - DEL_COUNT=$(echo "$CAPS_RESULT" | node -e " - const v = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8').trim()); - process.stdout.write(String(v.delegationCount)); - " 2>/dev/null || echo "0") - if [[ "$DEL_COUNT" != "0" ]]; then - ok "Delegation received (auto-pushed from home device)" +if [[ "$NON_INTERACTIVE" == true ]]; then + info "Non-interactive mode — skipping delegation wait" + DEL_COUNT="0" + MANUAL_SKIP=false +else + info "Waiting for delegation from home device (up to 10 min)..." + echo -e " ${DIM}Press Enter to skip waiting and paste manually.${RESET}" >&2 + + DEL_COUNT="0" + POLL_FAILURES=0 + MANUAL_SKIP=false + for i in $(seq 1 300); do + CAPS_RAW=$(daemon_exec --quiet queueMessage "[\"$ROOT_KREF\", \"getCapabilities\", []]" 2>/dev/null) || CAPS_RAW="" + if [[ -n "$CAPS_RAW" ]]; then + POLL_FAILURES=0 + DEL_COUNT=$(echo "$CAPS_RAW" | node -e " + const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8').trim()); + const v = JSON.parse(d.body.slice(1)); + process.stdout.write(String(v.delegationCount)); + " 2>/dev/null || echo "0") + if [[ "$DEL_COUNT" != "0" ]]; then + ok "Delegation received (auto-pushed from home device)" + break + fi + else + POLL_FAILURES=$((POLL_FAILURES + 1)) + if [[ "$POLL_FAILURES" -ge 5 ]]; then + fail "Daemon appears to be down (5 consecutive failed polls). Check: tail -f ${OCAP_HOME:-~/.ocap}/daemon.log" + fi + fi + if [[ "$i" -eq 300 ]]; then + MANUAL_SKIP=true break fi - else - POLL_FAILURES=$((POLL_FAILURES + 1)) - if [[ "$POLL_FAILURES" -ge 5 ]]; then - fail "Daemon appears to be down (5 consecutive failed polls). Check: tail -f ${OCAP_HOME:-~/.ocap}/daemon.log" + # read -t 2 doubles as the sleep — pressing Enter skips to manual paste + if read -t 2 -r _ 2>/dev/null; then + MANUAL_SKIP=true + break fi - fi - if [[ "$i" -eq 300 ]]; then - MANUAL_SKIP=true - break - fi - # read -t 2 doubles as the sleep — pressing Enter skips to manual paste - if read -t 2 -r _ 2>/dev/null; then - MANUAL_SKIP=true - break - fi -done + done +fi if [[ "$MANUAL_SKIP" == true && "$DEL_COUNT" == "0" ]]; then echo "" >&2 @@ -676,9 +687,14 @@ EOF # --------------------------------------------------------------------------- if command -v openclaw &>/dev/null; then - echo "" >&2 - echo -ne "${CYAN}→${RESET} Install the OpenClaw wallet plugin? [y/N] " >&2 - read -r INSTALL_PLUGIN + INSTALL_PLUGIN="n" + if [[ "$NON_INTERACTIVE" == true ]]; then + INSTALL_PLUGIN="y" + else + echo "" >&2 + echo -ne "${CYAN}→${RESET} Install the OpenClaw wallet plugin? [y/N] " >&2 + read -r INSTALL_PLUGIN + fi if [[ "$INSTALL_PLUGIN" =~ ^[Yy]$ ]]; then info "Installing OpenClaw wallet plugin..." (cd "$REPO_ROOT" && openclaw plugins install -l ./packages/evm-wallet-experiment/openclaw-plugin) >&2 diff --git a/packages/evm-wallet-experiment/scripts/setup-home.sh b/packages/evm-wallet-experiment/scripts/setup-home.sh index f36600804..e22074781 100755 --- a/packages/evm-wallet-experiment/scripts/setup-home.sh +++ b/packages/evm-wallet-experiment/scripts/setup-home.sh @@ -311,7 +311,7 @@ if [[ -n "$RELAY_ADDR" ]]; then if [[ "$RELAY_OK" == "true" ]]; then ok "Relay reservation active" else - fail "Relay reservation not established after 30s. Is the relay running? Check: ssh VPS 'sudo systemctl status ocap-relay.service'" + fail "Relay reservation not established after 30s. Is the relay running? Check: ssh away 'sudo systemctl status ocap-relay.service'" fi fi @@ -525,7 +525,7 @@ $(echo -e "${GREEN}${BOLD}")═════════════════ $(echo -e "${DIM}")RPC URL :$(echo -e "${RESET}") $RPC_URL $(echo -e "${DIM}")Accounts :$(echo -e "${RESET}") $ACCOUNTS -$(echo -e "${YELLOW}${BOLD}") Run this on the away device (VPS):$(echo -e "${RESET}") +$(echo -e "${YELLOW}${BOLD}") Run this on the away device:$(echo -e "${RESET}") $(echo -e "${BOLD}") ./packages/evm-wallet-experiment/scripts/setup-away.sh \\ --ocap-url "$OCAP_URL" \\ diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index 1924fab0e..d5f78d577 100644 --- a/packages/evm-wallet-experiment/src/index.ts +++ b/packages/evm-wallet-experiment/src/index.ts @@ -140,6 +140,7 @@ export type { // SDK adapter export { + registerEnvironment, resolveEnvironment, getDelegationManagerAddress, getEnforcerAddresses, diff --git a/packages/evm-wallet-experiment/src/lib/sdk.ts b/packages/evm-wallet-experiment/src/lib/sdk.ts index 12d8970da..df415e669 100644 --- a/packages/evm-wallet-experiment/src/lib/sdk.ts +++ b/packages/evm-wallet-experiment/src/lib/sdk.ts @@ -64,13 +64,61 @@ export type { SmartAccountsEnvironment, SdkDelegation }; // Environment resolution // --------------------------------------------------------------------------- +// Plain object registry — Maps may not work correctly under SES lockdown. +let environmentOverrides: Record = {}; + +// Lightweight log callback — set via setSdkLogger() from the coordinator vat +// so we get visibility without importing @metamask/logger into a bundled module. +type SdkLogFn = ( + level: string, + message: string, + data?: Record, +) => void; +let sdkLog: SdkLogFn | undefined; + +/** + * Set a logging callback for SDK operations. + * Called by the coordinator vat after its logger is initialized. + * + * @param fn - The log callback. + */ +export function setSdkLogger(fn: SdkLogFn): void { + sdkLog = fn; +} + +/** + * Register a custom environment for a chain ID that the SDK doesn't natively + * support (e.g. local Anvil at chain 31337). Overrides take precedence over + * the SDK's built-in registry. + * + * @param chainId - The chain ID. + * @param env - The deployed environment addresses. + */ +export function registerEnvironment( + chainId: number, + env: SmartAccountsEnvironment, +): void { + environmentOverrides = { ...environmentOverrides, [chainId]: env }; + sdkLog?.('info', 'registerEnvironment', { + chainId, + keys: Object.keys(env), + } as Record); +} + /** * Resolve the SDK environment for a chain ID. + * Checks local overrides first, then falls back to the SDK. * * @param chainId - The chain ID. * @returns The SDK environment. */ export function resolveEnvironment(chainId: number): SmartAccountsEnvironment { + const override = environmentOverrides[chainId]; + if (override) { + sdkLog?.('debug', 'resolveEnvironment: using override', { chainId }); + return override; + } + sdkLog?.('debug', 'resolveEnvironment: using SDK', { chainId }); return getSmartAccountsEnvironment(chainId); } @@ -135,7 +183,7 @@ function wrapInDelegationManagerExecute( innerCallData: Hex, chainId: number, ): Hex { - const env = getSmartAccountsEnvironment(chainId); + const env = resolveEnvironment(chainId); return contracts.DeleGatorCore.encode.execute({ execution: sdkCreateExecution({ target: env.DelegationManager, diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index cd3d2bcd6..512cf435f 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -24,7 +24,9 @@ import { computeSmartAccountAddress, isEip7702Delegated, prepareUserOpTypedData, + registerEnvironment, resolveEnvironment, + setSdkLogger, } from '../lib/sdk.ts'; import { ENTRY_POINT_V07 } from '../lib/userop.ts'; import type { @@ -349,6 +351,15 @@ export function buildRootObject( tags: ['coordinator-vat'], }); + // Wire SDK logger so resolveEnvironment/registerEnvironment are visible + setSdkLogger((level, message, data) => { + if (level === 'info') { + logger.info(message, data); + } else { + logger.debug(message, data); + } + }); + // References to other vats (set during bootstrap) let keyringVat: KeyringFacet | undefined; let providerVat: ProviderFacet | undefined; @@ -1444,6 +1455,11 @@ export function buildRootObject( if (delegationVat) { persistBaggage('delegationVat', delegationVat); } + logger.info('bootstrap complete', { + hasKeyring: Boolean(keyringVat), + hasProvider: Boolean(providerVat), + hasDelegation: Boolean(delegationVat), + }); }, // ------------------------------------------------------------------ @@ -1525,6 +1541,13 @@ export function buildRootObject( chainId: number; usePaymaster?: boolean; sponsorshipPolicyId?: string; + environment?: { + EntryPoint: Hex; + DelegationManager: Hex; + SimpleFactory: Hex; + implementations: Record; + caveatEnforcers: Record; + }; }): Promise { // Validate bundler URL (regex — URL constructor unavailable under SES) if (!/^https?:\/\/.+/u.test(config.bundlerUrl)) { @@ -1539,6 +1562,12 @@ export function buildRootObject( ); } + // Register a custom SDK environment for chains not in the SDK's built-in + // registry (e.g. local Anvil at chain 31337). + if (config.environment) { + registerEnvironment(config.chainId, config.environment); + } + bundlerConfig = harden({ bundlerUrl: config.bundlerUrl, entryPoint: config.entryPoint ?? ENTRY_POINT_V07, @@ -1547,6 +1576,12 @@ export function buildRootObject( sponsorshipPolicyId: config.sponsorshipPolicyId, }); persistBaggage('bundlerConfig', bundlerConfig); + logger.info('bundler configured', { + bundlerUrl: config.bundlerUrl, + chainId: config.chainId, + entryPoint: bundlerConfig.entryPoint, + hasEnvironment: Boolean(config.environment), + }); if (!providerVat) { throw new Error( @@ -1682,6 +1717,13 @@ export function buildRootObject( if (!providerVat) { throw new Error('Provider not configured'); } + logger.debug('sendTransaction', { + from: tx.from, + to: tx.to, + value: tx.value, + hasDelegationVat: Boolean(delegationVat), + hasBundlerConfig: Boolean(bundlerConfig), + }); // Enforce delegations whenever the delegation vat exists (bundler optional // for 7702). Delegations are a security boundary — if we cannot resolve the @@ -1701,6 +1743,12 @@ export function buildRootObject( ); if (delegation) { + logger.debug('delegation matched', { + delegationId: delegation.id, + delegate: delegation.delegate, + sender: smartAccountConfig?.address, + status: delegation.status, + }); if (delegation.status !== 'signed') { throw new Error( `Found delegation ${delegation.id} but its status is '${delegation.status}' (expected 'signed'). ` + @@ -1720,6 +1768,7 @@ export function buildRootObject( } // No delegation matched — explain why before falling through + logger.debug('no delegation matched, checking explanations'); const explanations = await E(delegationVat).explainActionMatch( action, walletChainId, @@ -1738,6 +1787,7 @@ export function buildRootObject( } } + logger.debug('sendTransaction: no delegation path, using direct send'); // Estimate missing gas fields for direct (non-delegation) sends const filledTx = { ...tx }; @@ -2022,6 +2072,12 @@ export function buildRootObject( const signature = await signTypedDataFn(typedData); await E(delegationVat).storeSigned(delegation.id, signature); + logger.info('delegation created', { + id: delegation.id, + delegator, + delegate: opts.delegate, + caveats: opts.caveats?.length ?? 0, + }); return E(delegationVat).getDelegation(delegation.id); }, @@ -2666,6 +2722,11 @@ export function buildRootObject( delegation: Delegation, revokeIds?: string[], ): Promise { + logger.info('pushDelegationToAway', { + delegationId: delegation.id, + revokeCount: revokeIds?.length ?? 0, + hasAwayWallet: Boolean(awayWallet), + }); if (!awayWallet) { throw new Error( 'No away wallet registered. The away device must connect first.', diff --git a/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts b/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts new file mode 100644 index 000000000..8ad7152fa --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts @@ -0,0 +1,221 @@ +/* eslint-disable n/no-process-env */ +import { beforeAll, describe, expect, it } from 'vitest'; + +import { + callVat, + evmRpc, + isStackHealthy, + readContracts, +} from './helpers/docker-exec.ts'; +import type { ContractAddresses } from './helpers/docker-exec.ts'; +import { + setup7702Away, + setupHome, + setupHybridAway, + setupPeerRelayAway, +} from './helpers/scenarios.ts'; +import type { AwayResult, HomeResult } from './helpers/scenarios.ts'; + +const EXPECTED_HOME_ADDRESS = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'; +const BURN_ADDRESS = '0x000000000000000000000000000000000000dEaD'; + +type Delegation = { + id: string; + delegate: string; + delegator: string; + status: string; +}; + +type Capabilities = { + hasLocalKeys: boolean; + hasPeerWallet: boolean; + hasBundlerConfig: boolean; + delegationCount: number; + smartAccountAddress?: string; + localAccounts?: string[]; + autonomy?: string; +}; + +const DELEGATION_MODE = process.env.DELEGATION_MODE ?? 'bundler-7702'; + +const awaySetupFns: Record< + string, + (c: ContractAddresses, h: HomeResult) => AwayResult | Promise +> = { + 'bundler-7702': setup7702Away, + 'bundler-hybrid': setupHybridAway, + 'peer-relay': setupPeerRelayAway, +}; + +describe('Docker E2E', () => { + let homeResult: HomeResult; + let awayResult: AwayResult; + + beforeAll(async () => { + if (!isStackHealthy()) { + throw new Error( + 'Docker stack is not running. Start it with: yarn docker:up', + ); + } + + const contracts = readContracts(); + homeResult = setupHome(contracts); + + const setupAway = awaySetupFns[DELEGATION_MODE]; + if (!setupAway) { + throw new Error(`Unknown DELEGATION_MODE: ${DELEGATION_MODE}`); + } + awayResult = await setupAway(contracts, homeResult); + }, 180_000); + + const callHome = (method: string, args: unknown[] = []) => + callVat('home', homeResult.kref, method, args); + + const callAway = (method: string, args: unknown[] = []) => + callVat('away', awayResult.kref, method, args); + + // --------------------------------------------------------------------------- + // Home wallet tests + // --------------------------------------------------------------------------- + + describe('home wallet', () => { + it('returns accounts', () => { + const accounts = callHome('getAccounts') as string[]; + expect(accounts).toHaveLength(1); + expect(accounts[0]?.toLowerCase()).toBe(EXPECTED_HOME_ADDRESS); + }); + + it('has balance from Anvil', async () => { + const balanceHex = (await evmRpc('eth_getBalance', [ + EXPECTED_HOME_ADDRESS, + 'latest', + ])) as string; + const balanceEth = Number(BigInt(balanceHex)) / 1e18; + expect(balanceEth).toBeGreaterThan(9000); + }); + + it('signs a message', () => { + const signature = callHome('signMessage', ['Docker E2E test']) as string; + expect(signature).toMatch(/^0x[\da-f]{130}$/iu); + }); + + it('signs typed data (EIP-712)', () => { + const typedData = { + domain: { + name: 'Test', + version: '1', + chainId: 31337, + verifyingContract: '0x0000000000000000000000000000000000000001', + }, + types: { + Mail: [ + { name: 'from', type: 'string' }, + { name: 'to', type: 'string' }, + ], + }, + primaryType: 'Mail', + message: { from: 'Alice', to: 'Bob' }, + }; + const signature = callHome('signTypedData', [typedData]) as string; + expect(signature).toMatch(/^0x[\da-f]{130}$/iu); + }); + + it('queries eth_blockNumber', async () => { + const blockNum = (await evmRpc('eth_blockNumber')) as string; + expect(blockNum).toMatch(/^0x[\da-f]+$/iu); + }); + }); + + // --------------------------------------------------------------------------- + // away wallet tests + // --------------------------------------------------------------------------- + + describe('away wallet', () => { + it('has local keys', () => { + const caps = callAway('getCapabilities') as Capabilities; + expect(caps.hasLocalKeys).toBe(true); + }); + + it('signs a message', () => { + const signature = callAway('signMessage', ['Away test']) as string; + expect(signature).toMatch(/^0x[\da-f]{130}$/iu); + }); + + it('queries eth_blockNumber', () => { + const blockNum = callAway('request', ['eth_blockNumber', []]) as string; + expect(blockNum).toMatch(/^0x[\da-f]+$/iu); + }); + }); + + // --------------------------------------------------------------------------- + // Delegation redemption + // --------------------------------------------------------------------------- + + describe('delegation redemption', () => { + let delegation: Delegation; + + beforeAll(() => { + const delegate = + awayResult.smartAccountAddress ?? awayResult.delegateAddress; + + delegation = callHome('createDelegation', [ + { delegate, caveats: [], chainId: 31337 }, + ]) as Delegation; + + callAway('receiveDelegation', [delegation]); + }); + + it('creates a signed delegation', () => { + expect(delegation.status).toBe('signed'); + }); + + it('lists delegation on away', () => { + const delegations = callAway('listDelegations') as Delegation[]; + expect(delegations.length).toBeGreaterThanOrEqual(1); + expect(delegations[0]?.id).toBe(delegation.id); + }); + + it('sends ETH via delegated authority', async () => { + const homeSA = homeResult.smartAccountAddress; + expect(homeSA).toBeDefined(); + + const balanceBefore = BigInt( + (await evmRpc('eth_getBalance', [BURN_ADDRESS, 'latest'])) as string, + ); + + const txHash = callAway('sendTransaction', [ + { from: homeSA, to: BURN_ADDRESS, value: '0xDE0B6B3A7640000' }, + ]) as string; + + expect(txHash).toMatch(/^0x[\da-f]{64}$/iu); + + const balanceAfter = BigInt( + (await evmRpc('eth_getBalance', [BURN_ADDRESS, 'latest'])) as string, + ); + expect(balanceAfter).toBeGreaterThan(balanceBefore); + }); + + it('reports capabilities consistent with delegation mode', () => { + const caps = callAway('getCapabilities') as Capabilities; + + expect(caps.delegationCount).toBeGreaterThanOrEqual(1); + + const expectations: Record void> = { + 'bundler-7702': () => { + expect(caps.hasBundlerConfig).toBe(true); + expect(caps.smartAccountAddress).toBeDefined(); + }, + 'bundler-hybrid': () => { + expect(caps.hasBundlerConfig).toBe(true); + expect(caps.smartAccountAddress).toBeDefined(); + }, + 'peer-relay': () => { + expect(caps.hasBundlerConfig).toBe(false); + expect(caps.hasPeerWallet).toBe(true); + }, + }; + + expectations[DELEGATION_MODE]?.(); + }); + }); +}); diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs b/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs new file mode 100644 index 000000000..85e27b750 --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs @@ -0,0 +1,64 @@ +/* eslint-disable */ +import { readLine, writeLine } from '@metamask/kernel-node-runtime/daemon'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { kunser } from '@metamask/ocap-kernel'; +import * as net from 'node:net'; + +let rpcId = 0; + +function connectToSocket(socketPath) { + return new Promise((resolve, reject) => { + const client = net.createConnection(socketPath, () => { + client.removeListener('error', reject); + resolve(client); + }); + client.on('error', reject); + }); +} + +async function rpc(socketPath, method, params) { + const socket = await connectToSocket(socketPath); + try { + rpcId++; + const request = { + jsonrpc: '2.0', + id: String(rpcId), + method, + ...(params === undefined ? {} : { params }), + }; + await writeLine(socket, JSON.stringify(request)); + const responseLine = await readLine(socket); + return JSON.parse(responseLine); + } finally { + socket.destroy(); + } +} + +async function callVat(socketPath, target, method, args = []) { + const response = await rpc(socketPath, 'queueMessage', [target, method, args]); + if (response.error) { + throw new Error(`RPC error: ${response.error.message || JSON.stringify(response.error)}`); + } + await waitUntilQuiescent(); + return kunser(response.result); +} + +async function callVatExpectError(socketPath, target, method, args = []) { + const response = await rpc(socketPath, 'queueMessage', [target, method, args]); + if (response.error) { + return JSON.stringify(response.error); + } + await waitUntilQuiescent(); + return response.result.body; +} + +/** + * Create a daemon client bound to a socket path. + */ +export function makeDaemonClient(socketPath) { + return { + rpc: (method, params) => rpc(socketPath, method, params), + callVat: (target, method, args) => callVat(socketPath, target, method, args), + callVatExpectError: (target, method, args) => callVatExpectError(socketPath, target, method, args), + }; +} diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/delegation-transfer.mjs b/packages/evm-wallet-experiment/test/e2e/docker/helpers/delegation-transfer.mjs new file mode 100644 index 000000000..631fb4966 --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/delegation-transfer.mjs @@ -0,0 +1,69 @@ +/* eslint-disable n/no-process-env */ +/** + * Shared delegation flow for the Docker stack: create on home, push to away over CapTP. + * Used by `docker/create-delegation.mjs`. + */ + +import { dockerConfig } from '../../../../docker/docker-e2e-stack-constants.mjs'; + +const NATIVE_TRANSFER_ENFORCER = '0xF71af580b9c3078fbc2BBF16FbB8EEd82b330320'; + +/** + * Prefer smart-account delegate when present, else EOA delegate from away info. + * + * @param {Record} awayInfo - Parsed `away-info.json`. + * @returns {string} On-chain delegate address for the away wallet. + */ +export function resolveDelegateForAway(awayInfo) { + return awayInfo.smartAccountAddress || awayInfo.delegateAddress; +} + +/** + * Build caveat list from env. When `CAVEAT_ETH_LIMIT` is set, caps native-token spend. + * + * @returns {Array>} Caveat objects for `createDelegation`. + */ +export function buildCaveatsFromEnv() { + const caveats = []; + const ethLimit = process.env.CAVEAT_ETH_LIMIT; + if (ethLimit) { + const wei = BigInt(Math.floor(Number(ethLimit) * 1e18)); + const terms = `0x${wei.toString(16).padStart(64, '0')}`; + caveats.push({ + type: 'nativeTokenTransferAmount', + enforcer: NATIVE_TRANSFER_ENFORCER, + terms, + }); + } + return caveats; +} + +/** + * Create a signed delegation on the home coordinator for the away delegate. + * + * @param {object} options - Arguments. + * @param {(method: string, args: unknown[]) => Promise} options.callHome - Home coordinator `callVat` wrapper. + * @param {Record} options.awayInfo - Parsed `away-info.json`. + * @param {Array>} [options.caveats] - Optional caveats (default none). + * @returns {Promise>} Created delegation record from the coordinator. + */ +export async function createDelegationForDockerStack({ + callHome, + awayInfo, + caveats = [], +}) { + const delegate = resolveDelegateForAway(awayInfo); + return callHome('createDelegation', [ + { delegate, caveats, chainId: dockerConfig.anvilChainId }, + ]); +} + +/** + * Push a signed delegation to the peer away wallet (CapTP), from the home coordinator. + * + * @param {(method: string, args: unknown[]) => Promise} callHome - Home coordinator `callVat` wrapper. + * @param {Record} delegation - Delegation object from `createDelegation`. + */ +export async function pushDelegationOverPeer(callHome, delegation) { + await callHome('pushDelegationToAway', [delegation]); +} diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts new file mode 100644 index 000000000..9a3191bbe --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts @@ -0,0 +1,212 @@ +/* eslint-disable n/no-sync */ +/** + * Helpers for communicating with Docker containers from the host. + * + * Uses `docker compose exec` to run commands inside containers and + * `fetch` against exposed ports for direct RPC. + */ +import { execSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +const COMPOSE_FILE = resolve( + currentDir, + '../../../../docker/docker-compose.yml', +); +const CLI = 'node /app/packages/kernel-cli/dist/app.mjs'; + +const EVM_RPC = 'http://localhost:8545'; + +/** + * Single-quote a string for use inside a POSIX shell single-quoted word. + * + * @param value - Raw string to wrap. + * @returns The string wrapped in single quotes with internal quotes escaped. + */ +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/gu, "'\\''")}'`; +} + +/** + * Run a command inside a Docker container via `docker compose exec`. + * + * @param service - The compose service name. + * @param command - The command to run. + * @returns stdout as a string. + */ +export function dockerExec(service: string, command: string): string { + return execSync( + `docker compose -f ${COMPOSE_FILE} exec -T ${service} ${command}`, + { encoding: 'utf-8', timeout: 60_000 }, + ).trim(); +} + +/** + * Read a JSON file from inside a container. + * + * @param service - The compose service name. + * @param filePath - Absolute path inside the container. + * @returns Parsed JSON. + */ +export function readContainerJson( + service: string, + filePath: string, +): T { + const raw = dockerExec(service, `cat ${filePath}`); + return JSON.parse(raw) as T; +} + +/** + * Call a coordinator vat method via the kernel CLI inside a container. + * + * @param service - The compose service name ('home' or 'away'). + * @param kref - The coordinator kref (e.g. 'ko4'). + * @param method - The method name. + * @param args - Method arguments. + * @returns The deserialized result. + */ +export function callVat( + service: string, + kref: string, + method: string, + args: unknown[] = [], +): unknown { + const argsJson = JSON.stringify(args); + const raw = execSync( + `docker compose -f ${COMPOSE_FILE} exec -T ${service} ${CLI} daemon queueMessage ${shellSingleQuote(kref)} ${shellSingleQuote(method)} ${shellSingleQuote(argsJson)} --timeout 60`, + { encoding: 'utf-8', timeout: 90_000 }, + ).trim(); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`callVat ${method}: daemon returned non-JSON output`); + } + + if (typeof parsed === 'string' && /^\[.+: /u.test(parsed)) { + throw new Error(parsed); + } + + return parsed; +} + +/** + * Send a JSON-RPC request to the EVM node (via exposed port). + * + * @param method - The RPC method. + * @param params - The RPC params. + * @returns The result field from the JSON-RPC response. + */ +export async function evmRpc( + method: string, + params: unknown[] = [], +): Promise { + const response = await fetch(EVM_RPC, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const json = (await response.json()) as { + result?: unknown; + error?: { message: string }; + }; + if (json.error) { + throw new Error(`EVM RPC error: ${json.error.message}`); + } + return json.result; +} + +/** + * Check if the Docker stack is running and healthy. + * + * @returns true if all required services are healthy. + */ +export function isStackHealthy(): boolean { + try { + const output = execSync( + `docker compose -f ${COMPOSE_FILE} ps --format json`, + { encoding: 'utf-8', timeout: 10_000 }, + ); + const services = output + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as { Service: string; Health: string }); + const required = ['evm', 'bundler', 'home', 'away']; + return required.every((name) => + services.some((svc) => svc.Service === name && svc.Health === 'healthy'), + ); + } catch { + return false; + } +} + +/** + * Execute a kernel-level daemon RPC method inside a container. + * + * Unlike {@link callVat} which sends a vat message via `queueMessage`, + * this calls a top-level daemon RPC method (e.g. `launchSubcluster`, + * `registerLocationHints`, `getStatus`). + * + * @param service - The compose service name. + * @param method - The daemon RPC method name. + * @param params - Optional parameters object. + * @returns The deserialized result. + */ +export function daemonExec( + service: string, + method: string, + params?: unknown, +): unknown { + const args = + params === undefined + ? method + : `${method} '${JSON.stringify(params).replace(/'/gu, "'\\''")}'`; + const raw = execSync( + `docker compose -f ${COMPOSE_FILE} exec -T ${service} ${CLI} daemon exec ${args} --timeout 60`, + { encoding: 'utf-8', timeout: 90_000 }, + ).trim(); + return JSON.parse(raw) as unknown; +} + +export type ServiceInfo = { + socketPath: string; + peerId?: string; + listenAddresses?: string[]; +}; + +/** + * Read the readiness file for a kernel service. + * + * @param service - The compose service name (e.g. 'home', 'away'). + * @returns The parsed readiness info. + */ +export function getServiceInfo(service: string): ServiceInfo { + return readContainerJson( + service, + `/run/ocap/${service}-ready.json`, + ); +} + +export type ContractAddresses = { + EntryPoint: string; + DelegationManager: string; + SimpleFactory: string; + implementations: Record; + caveatEnforcers: Record; +}; + +/** + * Read the deployed contract addresses from the EVM container. + * + * @returns The parsed contract addresses. + */ +export function readContracts(): ContractAddresses { + return readContainerJson( + 'evm', + '/run/ocap/contracts.json', + ); +} diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/scenarios.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/scenarios.ts new file mode 100644 index 000000000..cf27c2e67 --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/scenarios.ts @@ -0,0 +1,199 @@ +/** + * Named scenario compositions for Docker E2E tests. + * + * Each function is a flat sequence of wallet-setup primitives. The caller + * chooses which scenario to run — no branching happens here. + */ + +import { + configureBundler, + configureProvider, + connectToPeer, + createSmartAccount, + fundAddress, + getServiceInfo, + initKeyring, + issueOcapUrl, + launchWalletSubcluster, + preDeploySmartAccount, + registerLocationHints, +} from './wallet-setup.ts'; +import type { ContractAddresses } from './wallet-setup.ts'; + +const TEST_MNEMONIC = + 'test test test test test test test test test test test junk'; + +const CHAIN_ID = 31337; +const EVM_RPC_URL = 'http://evm:8545'; +const BUNDLER_URL = 'http://bundler:4337'; +const ALLOWED_HOSTS = ['evm:8545', 'bundler:4337']; + +export type HomeResult = { + kref: string; + peerId: string; + listenAddresses: string[]; + ocapUrl: string; + address: string; + smartAccountAddress: string; +}; + +export type AwayResult = { + kref: string; + delegateAddress: string; + smartAccountAddress?: string; +}; + +/** + * Set up the home kernel with a full wallet: SRP keyring, provider, + * bundler, 7702 smart account, and an OCAP URL for peer connection. + * + * @param contracts - Deployed contract addresses from the EVM container. + * @returns Home wallet info needed by away setup and tests. + */ +export function setupHome(contracts: ContractAddresses): HomeResult { + const info = getServiceInfo('home'); + + const kref = launchWalletSubcluster('home', { + contracts, + allowedHosts: ALLOWED_HOSTS, + }); + + const address = initKeyring('home', kref, { + type: 'srp', + mnemonic: TEST_MNEMONIC, + }); + + configureProvider('home', kref, { + chainId: CHAIN_ID, + rpcUrl: EVM_RPC_URL, + }); + + configureBundler('home', kref, { + bundlerUrl: BUNDLER_URL, + chainId: CHAIN_ID, + entryPoint: contracts.EntryPoint, + environment: contracts, + }); + + const { address: smartAccountAddress } = createSmartAccount('home', kref, { + chainId: CHAIN_ID, + implementation: 'stateless7702', + }); + + const ocapUrl = issueOcapUrl('home', kref); + + return { + kref, + peerId: info.peerId as string, + listenAddresses: info.listenAddresses as string[], + ocapUrl, + address, + smartAccountAddress, + }; +} + +/** + * Common away setup steps shared across all delegation modes: + * launch subcluster, init throwaway keyring, configure provider, + * register home peer hints, connect to home. + * + * @param contracts - Deployed contract addresses. + * @param home - Home setup result (for peer connection). + * @returns The coordinator kref and delegate address. + */ +function setupAwayBase( + contracts: ContractAddresses, + home: HomeResult, +): { kref: string; delegateAddress: string } { + const kref = launchWalletSubcluster('away', { + contracts, + allowedHosts: ALLOWED_HOSTS, + }); + + const delegateAddress = initKeyring('away', kref, { type: 'throwaway' }); + + configureProvider('away', kref, { + chainId: CHAIN_ID, + rpcUrl: EVM_RPC_URL, + }); + + registerLocationHints('away', home.peerId, home.listenAddresses); + connectToPeer('away', kref, home.ocapUrl); + + return { kref, delegateAddress }; +} + +/** + * Set up away with bundler + EIP-7702 stateless smart account. + * + * @param contracts - Deployed contract addresses. + * @param home - Home setup result (for peer connection). + * @returns Away wallet info. + */ +export async function setup7702Away( + contracts: ContractAddresses, + home: HomeResult, +): Promise { + const { kref, delegateAddress } = setupAwayBase(contracts, home); + + configureBundler('away', kref, { + bundlerUrl: BUNDLER_URL, + chainId: CHAIN_ID, + entryPoint: contracts.EntryPoint, + environment: contracts, + }); + + await fundAddress(delegateAddress, 10); + + const { address: smartAccountAddress } = createSmartAccount('away', kref, { + chainId: CHAIN_ID, + implementation: 'stateless7702', + }); + + return { kref, delegateAddress, smartAccountAddress }; +} + +/** + * Set up away with bundler + factory-deployed HybridDeleGator. + * + * @param contracts - Deployed contract addresses. + * @param home - Home setup result (for peer connection). + * @returns Away wallet info. + */ +export async function setupHybridAway( + contracts: ContractAddresses, + home: HomeResult, +): Promise { + const { kref, delegateAddress } = setupAwayBase(contracts, home); + + configureBundler('away', kref, { + bundlerUrl: BUNDLER_URL, + chainId: CHAIN_ID, + entryPoint: contracts.EntryPoint, + environment: contracts, + }); + + const sa = createSmartAccount('away', kref, { chainId: CHAIN_ID }); + + if (sa.factory && sa.factoryData) { + await preDeploySmartAccount(sa.factory, sa.factoryData); + await fundAddress(sa.address, 10); + } + + return { kref, delegateAddress, smartAccountAddress: sa.address }; +} + +/** + * Set up away with peer-relay mode (no bundler, no smart account). + * + * @param contracts - Deployed contract addresses. + * @param home - Home setup result (for peer connection). + * @returns Away wallet info. + */ +export function setupPeerRelayAway( + contracts: ContractAddresses, + home: HomeResult, +): AwayResult { + const { kref, delegateAddress } = setupAwayBase(contracts, home); + return { kref, delegateAddress }; +} diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts new file mode 100644 index 000000000..530f9e0ee --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts @@ -0,0 +1,233 @@ +/** + * Composable wallet-setup primitives for Docker E2E tests. + * + * Each function performs exactly one configuration step by calling into + * a running kernel container via the CLI. All configuration decisions + * are made by the caller — these helpers never branch or self-discover. + */ + +import { randomBytes } from 'node:crypto'; + +import { callVat, daemonExec, evmRpc, getServiceInfo } from './docker-exec.ts'; +import type { ContractAddresses } from './docker-exec.ts'; + +const BUNDLE_BASE = 'file:///app/packages/evm-wallet-experiment/src/vats'; + +const ANVIL_FUNDER = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + +/** + * Launch the wallet subcluster on a kernel service. + * + * @param service - The compose service name. + * @param options - Subcluster configuration. + * @param options.contracts - Deployed contract addresses. + * @param options.allowedHosts - Hostnames the provider vat may fetch from. + * @returns The root coordinator kref (e.g. 'ko4'). + */ +export function launchWalletSubcluster( + service: string, + options: { + contracts: ContractAddresses; + allowedHosts: string[]; + }, +): string { + const { contracts, allowedHosts } = options; + const config = { + bootstrap: 'coordinator', + forceReset: false, + services: ['ocapURLIssuerService', 'ocapURLRedemptionService'], + vats: { + coordinator: { + bundleSpec: `${BUNDLE_BASE}/coordinator-vat.bundle`, + globals: ['TextEncoder', 'TextDecoder', 'Date', 'setTimeout'], + }, + keyring: { + bundleSpec: `${BUNDLE_BASE}/keyring-vat.bundle`, + globals: ['TextEncoder', 'TextDecoder'], + }, + provider: { + bundleSpec: `${BUNDLE_BASE}/provider-vat.bundle`, + globals: ['TextEncoder', 'TextDecoder'], + platformConfig: { fetch: { allowedHosts } }, + }, + delegation: { + bundleSpec: `${BUNDLE_BASE}/delegation-vat.bundle`, + globals: ['TextEncoder', 'TextDecoder'], + ...(contracts.DelegationManager + ? { + parameters: { + delegationManagerAddress: contracts.DelegationManager, + }, + } + : {}), + }, + }, + }; + + const result = daemonExec(service, 'launchSubcluster', { config }) as { + rootKref: string; + }; + return result.rootKref; +} + +/** + * Initialize the keyring vat. + * + * @param service - The compose service name. + * @param kref - The coordinator kref. + * @param options - Keyring type and seed material. + * @returns The first account address. + */ +export function initKeyring( + service: string, + kref: string, + options: { type: 'srp'; mnemonic: string } | { type: 'throwaway' }, +): string { + const keyringOpts = + options.type === 'srp' + ? { type: 'srp', mnemonic: options.mnemonic } + : { type: 'throwaway', entropy: `0x${randomBytes(32).toString('hex')}` }; + + callVat(service, kref, 'initializeKeyring', [keyringOpts]); + + const accounts = callVat(service, kref, 'getAccounts') as string[]; + return accounts[0] as string; +} + +/** + * Configure the EVM provider. + * + * @param service - The compose service name. + * @param kref - The coordinator kref. + * @param options - Chain ID and RPC URL. + * @param options.chainId - The EVM chain ID. + * @param options.rpcUrl - The JSON-RPC endpoint URL. + */ +export function configureProvider( + service: string, + kref: string, + options: { chainId: number; rpcUrl: string }, +): void { + callVat(service, kref, 'configureProvider', [options]); +} + +/** + * Configure the ERC-4337 bundler. + * + * @param service - The compose service name. + * @param kref - The coordinator kref. + * @param options - Bundler URL, chain ID, entry point, and contract environment. + * @param options.bundlerUrl - The ERC-4337 bundler endpoint. + * @param options.chainId - The EVM chain ID. + * @param options.entryPoint - The EntryPoint contract address. + * @param options.environment - The full deployed contract addresses. + */ +export function configureBundler( + service: string, + kref: string, + options: { + bundlerUrl: string; + chainId: number; + entryPoint: string; + environment: ContractAddresses; + }, +): void { + callVat(service, kref, 'configureBundler', [options]); +} + +/** + * Create a smart account (7702 or hybrid). + * + * @param service - The compose service name. + * @param kref - The coordinator kref. + * @param options - Chain ID and optional implementation type. + * @param options.chainId - The EVM chain ID. + * @param options.implementation - The smart account implementation (e.g. 'stateless7702'). + * @returns The smart account details. + */ +export function createSmartAccount( + service: string, + kref: string, + options: { chainId: number; implementation?: string }, +): { address: string; factory?: string; factoryData?: string } { + return callVat(service, kref, 'createSmartAccount', [options]) as { + address: string; + factory?: string; + factoryData?: string; + }; +} + +/** + * Issue an OCAP URL for peer connection. + * + * @param service - The compose service name. + * @param kref - The coordinator kref. + * @returns The OCAP URL string. + */ +export function issueOcapUrl(service: string, kref: string): string { + return callVat(service, kref, 'issueOcapUrl', []) as string; +} + +/** + * Connect to a peer via OCAP URL. + * + * @param service - The compose service name. + * @param kref - The coordinator kref. + * @param ocapUrl - The OCAP URL to connect to. + */ +export function connectToPeer( + service: string, + kref: string, + ocapUrl: string, +): void { + callVat(service, kref, 'connectToPeer', [ocapUrl]); +} + +/** + * Register location hints for a remote peer on a kernel service. + * + * @param service - The compose service name. + * @param peerId - The remote peer's ID. + * @param hints - Multiaddr strings for the remote peer. + */ +export function registerLocationHints( + service: string, + peerId: string, + hints: string[], +): void { + daemonExec(service, 'registerLocationHints', { peerId, hints }); +} + +/** + * Fund an address from Anvil's pre-funded account #0. + * + * @param address - The address to fund. + * @param ethAmount - Amount in ETH. + */ +export async function fundAddress( + address: string, + ethAmount: number, +): Promise { + const weiHex = `0x${(BigInt(Math.floor(ethAmount)) * 10n ** 18n).toString(16)}`; + await evmRpc('eth_sendTransaction', [ + { from: ANVIL_FUNDER, to: address, value: weiHex }, + ]); +} + +/** + * Pre-deploy a smart account via its factory contract. + * + * @param factory - The factory contract address. + * @param factoryData - The encoded factory call data. + */ +export async function preDeploySmartAccount( + factory: string, + factoryData: string, +): Promise { + await evmRpc('eth_sendTransaction', [ + { from: ANVIL_FUNDER, to: factory, data: factoryData, gas: '0x1000000' }, + ]); +} + +export { getServiceInfo }; +export type { ContractAddresses }; diff --git a/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts b/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts new file mode 100644 index 000000000..b62cbb567 --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts @@ -0,0 +1,90 @@ +/* eslint-disable n/no-process-env, n/no-process-exit */ +/** + * Host-side script to set up wallet subclusters on running Docker containers. + * + * Usage: + * yarn tsx test/e2e/docker/setup-wallets.ts [delegation-mode] + * + * Delegation modes: bundler-7702 (default), bundler-hybrid, peer-relay + */ + +import { callVat, readContracts } from './helpers/docker-exec.ts'; +import { + setup7702Away, + setupHome, + setupHybridAway, + setupPeerRelayAway, +} from './helpers/scenarios.ts'; +import type { AwayResult } from './helpers/scenarios.ts'; + +const mode = process.argv[2] ?? process.env.DELEGATION_MODE ?? 'bundler-7702'; + +const awaySetupFns: Record< + string, + ( + ...args: Parameters + ) => AwayResult | Promise +> = { + 'bundler-7702': setup7702Away, + 'bundler-hybrid': setupHybridAway, + 'peer-relay': setupPeerRelayAway, +}; + +async function main() { + const setupAway = awaySetupFns[mode]; + if (!setupAway) { + console.error(`Unknown delegation mode: ${mode}`); + console.error('Valid modes: bundler-7702, bundler-hybrid, peer-relay'); + process.exit(1); + } + + console.log(`Setting up wallets (mode: ${mode})...`); + + const contracts = readContracts(); + console.log(`Contracts: EntryPoint=${contracts.EntryPoint.slice(0, 10)}...`); + + const home = setupHome(contracts); + console.log( + `Home: kref=${home.kref} SA=${home.smartAccountAddress.slice(0, 10)}...`, + ); + + const away = await setupAway(contracts, home); + console.log( + `Away: kref=${away.kref} delegate=${away.delegateAddress.slice(0, 10)}...`, + ); + if (away.smartAccountAddress) { + console.log(`Away SA: ${away.smartAccountAddress}`); + } + + // Create delegation from home → away so away can spend home's funds + const delegate = away.smartAccountAddress ?? away.delegateAddress; + console.log(`Creating delegation: home → ${delegate.slice(0, 10)}...`); + + // 1000 ETH max spend caveat via the deployed NativeTokenTransferAmountEnforcer + const maxSpendWei = 1000n * 10n ** 18n; + const caveats = [ + { + type: 'nativeTokenTransferAmount', + enforcer: contracts.caveatEnforcers.NativeTokenTransferAmountEnforcer, + terms: `0x${maxSpendWei.toString(16).padStart(64, '0')}`, + }, + ]; + console.log('Caveat: nativeTokenTransferAmount <= 1000 ETH'); + + const delegation = callVat('home', home.kref, 'createDelegation', [ + { delegate, caveats, chainId: 31337 }, + ]); + console.log( + `Delegation created: ${(delegation as { id: string }).id.slice(0, 20)}...`, + ); + + callVat('away', away.kref, 'receiveDelegation', [delegation]); + console.log('Delegation received by away.'); + + console.log('Wallet setup complete.'); +} + +main().catch((error) => { + console.error('FATAL:', error); + process.exit(1); +}); diff --git a/packages/evm-wallet-experiment/tsconfig.json b/packages/evm-wallet-experiment/tsconfig.json index 79693de04..4d4a24e21 100644 --- a/packages/evm-wallet-experiment/tsconfig.json +++ b/packages/evm-wallet-experiment/tsconfig.json @@ -18,6 +18,7 @@ "./src", "./test", "./vitest.config.ts", - "./vitest.config.integration.ts" + "./vitest.config.integration.ts", + "./vitest.config.docker.ts" ] } diff --git a/packages/evm-wallet-experiment/vitest.config.docker.ts b/packages/evm-wallet-experiment/vitest.config.docker.ts new file mode 100644 index 000000000..0cd9373da --- /dev/null +++ b/packages/evm-wallet-experiment/vitest.config.docker.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'evm-wallet:docker-e2e', + pool: 'forks', + include: ['./test/e2e/docker/**/*.test.ts'], + hookTimeout: 120_000, + testTimeout: 120_000, + // No setupFiles — we need real fetch (not mocked) and no lockdown shims. + setupFiles: [], + }, +}); diff --git a/packages/evm-wallet-experiment/vitest.config.ts b/packages/evm-wallet-experiment/vitest.config.ts index cc95ac811..36786aaae 100644 --- a/packages/evm-wallet-experiment/vitest.config.ts +++ b/packages/evm-wallet-experiment/vitest.config.ts @@ -16,7 +16,12 @@ export default defineConfig((args) => { import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), ), ], - exclude: ['test/integration/**'], + exclude: [ + 'test/integration/**', + // Real `fetch` to localhost (Anvil); incompatible with root vitest-fetch-mock. + // Run: yarn test:e2e:docker + 'test/e2e/docker/**', + ], }, }), ); diff --git a/yarn.lock b/yarn.lock index 0091cf92b..917daae3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3790,6 +3790,7 @@ __metadata: eslint-plugin-promise: "npm:^7.2.1" prettier: "npm:^3.5.3" rimraf: "npm:^6.0.1" + tsx: "npm:^4.20.6" turbo: "npm:^2.9.1" typedoc: "npm:^0.28.1" typescript: "npm:~5.8.2"