Skip to content

Commit da4f95c

Browse files
authored
test(evm-wallet-experiment): Add local docker e2e test (#912)
Add a Docker Compose stack for isolated E2E testing with four services: - `evm`: Anvil chain with EIP-7702 support + contract deployment - `bundler`: Pimlico Alto ERC-4337 bundler - `home`: home kernel node (EOA + DeleGator smart account) - `away`: away kernel node (delegation recipient) Supports three delegation modes, narrowed via DELEGATION_MODE env var: 7702 (interactive default), 4337, and relay. `yarn test:e2e:docker` covers all three. Home and away containers are constructed as pairs, parametrized by delegation mode. A little language model `qwen3.5:4B-UD-Q4_K_XL` is provided by Docker Model Runner. ## Test Plan ### Interactive <img width="693" height="287" alt="image" src="https://github.com/user-attachments/assets/d8c93f2b-b80f-4178-afd1-3518a6ef625d" /> ### `yarn test:e2e:docker` <img width="680" height="835" alt="image" src="https://github.com/user-attachments/assets/bb1833e3-bfb6-4a28-a731-06a79f8aa34e" /> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it introduces substantial new Docker/test infrastructure and lightly touches key derivation and smart-accounts environment resolution used by the coordinator. Runtime behavior changes are mostly additive but could affect local signing/account selection and SDK contract-address resolution on nonstandard chains. > > **Overview** > Adds a full **Docker Compose E2E stack** (Anvil+contract deploy, Pimlico Alto bundler, and three parallel home/away kernel pairs behind profiles) plus new `yarn docker:*` scripts, interactive helpers, and docs for running both automated E2E and an OpenClaw-backed interactive simulation. > > Introduces a new **Vitest Docker E2E suite** (`test:e2e:docker`) that boots wallets inside containers, creates/transfers delegations across modes (`bundler-7702`, `bundler-hybrid`, `peer-relay`), and verifies delegated `sendTransaction` behavior against the local chain. > > Updates wallet internals to better support the Docker stack: SRP keyrings can start at a specified BIP-44 `addressIndex`, and the SDK adapter gains `registerEnvironment`/logging so the coordinator can override `@metamask/smart-accounts-kit` environments (e.g. chain `31337`) using addresses deployed in the stack. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5bf6fc4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 069e0e5 commit da4f95c

43 files changed

Lines changed: 3506 additions & 84 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/evm-wallet-experiment/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,8 @@ yarn workspace @ocap/evm-wallet-experiment build
712712
yarn workspace @ocap/evm-wallet-experiment lint:fix
713713
```
714714

715+
For Docker Compose setup (interactive simulation and E2E tests), see [docs/docker.md](./docs/docker.md). Docker Model Runner with `ai/qwen3.5:4B-UD-Q4_K_XL` is required for the interactive simulation's OpenClaw AI agent.
716+
715717
## Testing
716718

717719
The package has four tiers of tests, each exercising a progressively larger slice of the stack.
@@ -784,6 +786,25 @@ yarn workspace @ocap/evm-wallet-experiment test:integration
784786

785787
Vitest-based peer wallet tests in `test/integration/peer-wallet.test.ts`. Requires building bundles first (`yarn build`). Tests OCAP URL connection, remote message/transaction signing via CapTP, no-authority errors, and capabilities reporting across two kernels.
786788

789+
### Docker E2E (3 delegation modes × home/away pair)
790+
791+
```bash
792+
# Start the full stack (Anvil + bundler + 6 kernel containers)
793+
yarn workspace @ocap/evm-wallet-experiment docker:up
794+
795+
# Run all three delegation modes in parallel
796+
yarn workspace @ocap/evm-wallet-experiment test:e2e:docker
797+
798+
# Run a single mode
799+
DELEGATION_MODE=bundler-7702 yarn workspace @ocap/evm-wallet-experiment test:e2e:docker
800+
# or: bundler-hybrid, peer-relay
801+
802+
# Tear down
803+
yarn workspace @ocap/evm-wallet-experiment docker:down
804+
```
805+
806+
Full home/away delegation flow across three delegation modes (`bundler-7702`, `bundler-hybrid`, `peer-relay`) running in parallel. The stack requires Docker Model Runner. See [docs/docker.md](./docs/docker.md) for prerequisites, stack details, and troubleshooting. For manual interactive simulation, see [docs/simulation.md](./docs/simulation.md).
807+
787808
## Supported Chains
788809

789810
The wallet supports all chains where the MetaMask Delegation Framework is deployed:
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
**/node_modules
2+
**/.git
3+
**/dist
4+
**/coverage
5+
**/.turbo
6+
**/logs
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Used by `yarn docker:compose:interactive` (--env-file). Switches
2+
# `kernel-away-bundler-7702` to the `interactive` Dockerfile target (OpenClaw).
3+
KERNEL_AWAY_7702_TARGET=interactive
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
FROM ghcr.io/foundry-rs/foundry:latest AS foundry
2+
3+
FROM node:22-slim
4+
5+
WORKDIR /app
6+
7+
# Copy anvil + cast from the foundry image
8+
COPY --from=foundry /usr/local/bin/anvil /usr/local/bin/anvil
9+
COPY --from=foundry /usr/local/bin/cast /usr/local/bin/cast
10+
11+
# Pinned to match yarn.lock in the monorepo (@ocap/evm-wallet-experiment).
12+
RUN npm init -y > /dev/null 2>&1 && \
13+
npm install viem@2.46.2 @metamask/smart-accounts-kit@0.3.0
14+
15+
COPY packages/evm-wallet-experiment/docker/deploy-contracts.mjs /app/deploy-contracts.mjs
16+
COPY packages/evm-wallet-experiment/docker/entrypoint-evm.sh /app/entrypoint-evm.sh
17+
18+
RUN mkdir -p /logs /run/ocap
19+
20+
EXPOSE 8545
21+
22+
# Health is defined in docker-compose.yml (contracts.json after deploy).
23+
24+
ENTRYPOINT ["/bin/sh", "/app/entrypoint-evm.sh"]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
FROM node:22 AS builder
2+
3+
WORKDIR /build
4+
5+
RUN corepack enable && apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
6+
7+
# Copy root workspace config first for layer caching
8+
COPY package.json yarn.lock .yarnrc.yml tsconfig*.json ./
9+
10+
# Copy all packages (needed for workspace resolution)
11+
COPY packages/ packages/
12+
13+
# Strip ALL postinstall scripts from root and every workspace package.
14+
# These (playwright, git-hooks, native rebuilds) fail in Docker and aren't needed.
15+
RUN node -e " \
16+
const fs = require('fs'); \
17+
const path = require('path'); \
18+
function stripScripts(p) { \
19+
const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); \
20+
let changed = false; \
21+
if (pkg.scripts?.postinstall) { delete pkg.scripts.postinstall; changed = true; } \
22+
if (pkg.scripts?.install) { delete pkg.scripts.install; changed = true; } \
23+
if (pkg.scripts?.['rebuild:native']) { delete pkg.scripts['rebuild:native']; changed = true; } \
24+
if (pkg.lavamoat?.allowScripts) { \
25+
for (const k of Object.keys(pkg.lavamoat.allowScripts)) pkg.lavamoat.allowScripts[k] = false; \
26+
changed = true; \
27+
} \
28+
if (changed) fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); \
29+
} \
30+
stripScripts('package.json'); \
31+
for (const dir of fs.readdirSync('packages')) { \
32+
const p = path.join('packages', dir, 'package.json'); \
33+
if (fs.existsSync(p)) stripScripts(p); \
34+
}"
35+
36+
RUN yarn install --immutable
37+
38+
# Rebuild native addons required at runtime (QUIC / SQLite / WebRTC). Fail the image
39+
# build if compilation does not succeed — do not mask errors with || true.
40+
RUN (cd node_modules/@ipshipyard/node-datachannel && npm run install --ignore-scripts=false) || \
41+
npm rebuild @ipshipyard/node-datachannel
42+
43+
# libp2p/webrtc pulls `node-datachannel` (distinct from @ipshipyard); install
44+
# scripts were stripped above, so compile the N-API addon before kernel-cli build.
45+
RUN npm rebuild node-datachannel
46+
47+
RUN npm rebuild better-sqlite3
48+
49+
# Build the kernel CLI and wallet bundles
50+
RUN yarn workspace @metamask/kernel-cli build && \
51+
yarn workspace @ocap/evm-wallet-experiment build
52+
53+
# ---------------------------------------------------------------------------
54+
# Target: kernel — minimal kernel runtime (used by tests)
55+
# ---------------------------------------------------------------------------
56+
FROM node:22-slim AS kernel
57+
58+
WORKDIR /app
59+
60+
COPY --from=builder /build /app
61+
62+
RUN mkdir -p /logs /run/ocap
63+
64+
# ---------------------------------------------------------------------------
65+
# Target: interactive — kernel + OpenClaw + wallet plugin (used interactively)
66+
# ---------------------------------------------------------------------------
67+
FROM kernel AS interactive
68+
69+
# OpenClaw loads local plugins as TypeScript via jiti (no extra TS runner in the image).
70+
# `package.json` docker:interactive:setup starts the gateway via
71+
# `node /usr/local/lib/node_modules/openclaw/openclaw.mjs` (global bin PATH is unreliable under `docker exec`).
72+
RUN npm install -g openclaw@2026.4.1
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Docker stack — maintainer notes
2+
3+
Local E2E stack for `@ocap/evm-wallet-experiment`: Anvil + deployed contracts, Pimlico Alto, and six kernel containers (three `kernel-home-*` / `kernel-away-*` pairs). See `package.json` scripts (`docker:compose`, `test:e2e:docker`, etc.). Each pair is gated by a Compose **profile** (**`7702`**, **`4337`**, **`relay`**); **`yarn docker:up`** / **`docker:compose`** pass **all three** so Vitest Docker E2E and full-stack dev see every kernel. **`yarn docker:interactive:up`** enables **one** profile (default **`bundler-7702`** delegation mode → profile **`7702`**). Shared kernel **build / volumes / entrypoint / depends_on** live in root **`x-kernel-standard`**; **`x-kernel-build-core`** holds **`context`** / **`dockerfile`** so **`kernel-away-bundler-7702`** can set **`build.target`** from **`${KERNEL_AWAY_7702_TARGET:-kernel}`**. Per-pair **ports**, **`environment`**, and **`healthcheck.test`** stay explicit.
4+
5+
## Startup order
6+
7+
Compose encodes this dependency chain:
8+
9+
1. **`evm`** becomes healthy when `/run/ocap/contracts.json` exists (written only after `deploy-contracts.mjs` finishes). **`entrypoint-evm.sh`** removes a previous **`contracts.json`** on the shared volume first so a stale file does not satisfy the healthcheck while Anvil is redeploying (bundler would otherwise start with dead EntryPoint addresses and exit).
10+
2. **`bundler`** waits on that file, reads `EntryPoint`, then starts Alto.
11+
3. **Kernel services** wait on **both** `evm` and **`bundler` healthy** so wallet setup does not race Alto boot.
12+
13+
If you add a service that kernels need before they are ready, extend `depends_on` and healthchecks accordingly.
14+
15+
## Pinned images and versions
16+
17+
### Alto (bundler)
18+
19+
The bundler image uses a **multi-arch OCI index digest**, not `:latest`, so CI and local builds stay aligned.
20+
21+
To **upgrade Alto**:
22+
23+
```sh
24+
docker buildx imagetools inspect ghcr.io/pimlicolabs/alto:latest
25+
```
26+
27+
Copy the top-level **Digest** (index), then set in `docker-compose.yml`:
28+
29+
`image: ghcr.io/pimlicolabs/alto@sha256:<digest>`
30+
31+
Keep the comment above that line in sync with the command you used.
32+
33+
### OpenClaw (interactive image only)
34+
35+
`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.
36+
37+
Host-side scripts (e.g. `yarn docker:setup:wallets`) use the workspace **`tsx`** devDependency on your machine, not the container.
38+
39+
### EVM deploy image (`Dockerfile.evm`)
40+
41+
`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). Do not pipe **`npm install`** through **`tail`** (or other pipeline tails): **`sh`** uses the last command’s exit status, which would hide install failures.
42+
43+
### Foundry base (`Dockerfile.evm`)
44+
45+
`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.
46+
47+
## Healthchecks
48+
49+
- **`evm`**: File-based (`contracts.json`). The image itself does not define `HEALTHCHECK`; Compose is the source of truth.
50+
- **`bundler`**: JSON-RPC `eth_supportedEntryPoints` must return a **non-empty** array. If Alto changes RPC surface, adjust the probe in `docker-compose.yml`.
51+
52+
## Kernel image build (`Dockerfile.kernel-base`)
53+
54+
- Postinstall scripts are stripped workspace-wide so `yarn install` succeeds in Docker; **native addons are rebuilt explicitly** afterward.
55+
- **`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`.
56+
57+
## Security (local dev only)
58+
59+
`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.
60+
61+
## Docker Model Runner + Compose `models` (base `docker-compose.yml`)
62+
63+
- **`yarn docker:up`** and **`yarn test:e2e:docker`** expect the full stack, including [**Compose `models`**](https://docs.docker.com/ai/compose/models-and-compose/) on each **`kernel-away-*`** service. That requires **Docker Compose v2.38+** and [**Docker Model Runner**](https://docs.docker.com/ai/model-runner/) enabled.
64+
- Top-level **`models.llm`** pins **`ai/qwen3.5:4B-UD-Q4_K_XL`** with **`context_size: 32768`** and **`runtime_flags: ['--ctx-size','32768']`** so llama.cpp does not stay at DMR’s 4096 default (OpenClaw + tools need more). Pull if needed: **`docker model pull ai/qwen3.5:4B-UD-Q4_K_XL`**. If requests still hit 4096, run **`docker model configure --context-size 32768 ai/qwen3.5:4B-UD-Q4_K_XL`** on the host and recreate containers.
65+
- Vitest Docker E2E does **not** call the LLM today, but away containers still receive **`LLM_URL`** / **`LLM_MODEL`** for consistency with interactive OpenClaw and future tests.
66+
67+
## Interactive stack (`docker/.env.interactive` + one pair profile)
68+
69+
- **`yarn docker:compose:interactive`** runs **`node docker/run-interactive-compose.mjs`**, which passes **`--env-file docker/.env.interactive`** ( **`KERNEL_AWAY_7702_TARGET=interactive`** for the 7702 away image) and **one** **`--profile`** (**`7702`**, **`4337`**, or **`relay`**). Default delegation mode is **`bundler-7702`** → profile **`7702`** (same mode strings as Docker E2E **`DELEGATION_MODE`**).
70+
- **Choose the pair**: set **`OCAP_INTERACTIVE_PAIR`** to **`bundler-7702`**, **`bundler-hybrid`**, or **`peer-relay`**, or pass **`--pair <value>`** before compose subcommands (after **`yarn … --`** if needed), e.g. **`yarn docker:interactive:up -- --pair bundler-hybrid`**.
71+
- **`yarn docker:interactive:setup`** runs wallet setup; OpenClaw **`setup-openclaw.mjs`** + gateway run **only** when the pair is **`bundler-7702`** (the image with OpenClaw). Other pairs skip those steps with a short log line.
72+
- OpenClaw UI history for 7702 lives under **`$HOME/.openclaw`** on the **`ocap-run`** volume; use **`yarn docker:interactive:reset-openclaw`** then **`yarn docker:interactive:setup`**, or **`docker compose … down -v`** for a full volume wipe. LLM wiring is **only** in **`docker-compose.yml`** (**`models:`**).
73+
74+
**Raw `docker compose -f docker/docker-compose.yml up`** without **`--profile`** starts **evm** and **bundler** only (no kernels). Prefer **`yarn docker:up`** or the interactive scripts above.
75+
76+
## `yarn docker:delegate`
77+
78+
Runs **`create-delegation.mjs`** inside **`kernel-home-bundler-7702`**. It reads **`/run/ocap/docker-delegation-home.json`** and **`docker-delegation-away.json`** (coordinator **`kref`**, daemon **`socketPath`**, delegate addresses) written by **`yarn docker:setup:wallets`** on the shared **`ocap-run`** volume. Run wallet setup first for the delegation mode / pair you use.
79+
80+
## Ports and conflicts
81+
82+
Published TCP ports include **8545** (Anvil), **4337** (bundler). Kernels publish **UDP 4011–4032** (QUIC, three pairs). Use alternate mappings if these clash with other stacks.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/* eslint-disable */
2+
/**
3+
* Create a delegation on the home kernel and push it to the away node over CapTP.
4+
*
5+
* Connects to the home daemon socket only — the delegation flows to the
6+
* away node through the existing peer (OCAP) connection, exercising the real
7+
* cross-kernel path.
8+
*
9+
* Usage:
10+
* node --conditions development /app/packages/evm-wallet-experiment/docker/create-delegation.mjs
11+
*
12+
* Options (env vars):
13+
* CAVEAT_ETH_LIMIT — total native-token transfer limit in ETH (default: unlimited)
14+
*
15+
* Expects `/run/ocap/docker-delegation-{home,away}.json` on the shared volume
16+
* (written by `yarn docker:setup:wallets`). Kernel `*-ready.json` files only
17+
* expose `socketPath`, not coordinator krefs or wallet addresses.
18+
*/
19+
20+
import '@metamask/kernel-shims/endoify-node';
21+
22+
import { existsSync, readFileSync } from 'node:fs';
23+
24+
import { makeDaemonClient } from '../test/e2e/docker/helpers/daemon-client.mjs';
25+
import {
26+
buildCaveatsFromEnv,
27+
createDelegationForDockerStack,
28+
pushDelegationOverPeer,
29+
resolveOnChainDelegateForDockerMode,
30+
} from '../test/e2e/docker/helpers/delegation-transfer.mjs';
31+
32+
const HOME_CTX = '/run/ocap/docker-delegation-home.json';
33+
const AWAY_CTX = '/run/ocap/docker-delegation-away.json';
34+
35+
async function main() {
36+
if (!existsSync(HOME_CTX) || !existsSync(AWAY_CTX)) {
37+
console.error(
38+
'[delegation] Missing docker-delegation context files. Run on the host first:',
39+
);
40+
console.error(' yarn docker:setup:wallets');
41+
process.exit(1);
42+
}
43+
const homeInfo = JSON.parse(readFileSync(HOME_CTX, 'utf8'));
44+
const awayInfo = JSON.parse(readFileSync(AWAY_CTX, 'utf8'));
45+
const delegationMode = process.env.DELEGATION_MODE ?? 'bundler-7702';
46+
47+
const coordinatorKref = homeInfo.coordinatorKref ?? homeInfo.kref;
48+
if (typeof coordinatorKref !== 'string' || !coordinatorKref) {
49+
throw new Error(
50+
`${HOME_CTX} must include coordinatorKref or kref (run docker:setup:wallets).`,
51+
);
52+
}
53+
const socketPath = homeInfo.socketPath;
54+
if (typeof socketPath !== 'string' || !socketPath) {
55+
throw new Error(
56+
`${HOME_CTX} must include socketPath (run docker:setup:wallets).`,
57+
);
58+
}
59+
60+
const home = makeDaemonClient(socketPath);
61+
62+
const callHome = (method, args) =>
63+
home.callVat(coordinatorKref, method, args);
64+
65+
const delegate = resolveOnChainDelegateForDockerMode({
66+
delegationMode,
67+
homeInfo,
68+
awayInfo,
69+
});
70+
console.log(`[delegation] home coordinator: ${coordinatorKref}`);
71+
console.log(`[delegation] mode: ${delegationMode}`);
72+
console.log(
73+
`[delegation] on-chain delegate: ${delegate}${
74+
delegationMode === 'peer-relay'
75+
? ' (home; peer-relay redeem)'
76+
: awayInfo.smartAccountAddress
77+
? ' (away smart account)'
78+
: ' (away EOA)'
79+
}`,
80+
);
81+
82+
const caveats = buildCaveatsFromEnv();
83+
const ethLimit = process.env.CAVEAT_ETH_LIMIT;
84+
if (ethLimit) {
85+
console.log(
86+
`[delegation] caveat: nativeTokenTransferAmount <= ${ethLimit} ETH`,
87+
);
88+
}
89+
90+
console.log('[delegation] creating on home...');
91+
const delegation = await createDelegationForDockerStack({
92+
callHome,
93+
awayInfo,
94+
homeInfo,
95+
delegationMode,
96+
caveats,
97+
});
98+
console.log(`[delegation] id: ${delegation.id}`);
99+
console.log(`[delegation] status: ${delegation.status}`);
100+
101+
console.log('[delegation] pushing to away over CapTP...');
102+
await pushDelegationOverPeer(callHome, delegation);
103+
console.log(
104+
'[delegation] done — away received the delegation over the peer connection.',
105+
);
106+
}
107+
108+
main().catch((err) => {
109+
console.error('[delegation] FATAL:', err);
110+
process.exit(1);
111+
});

0 commit comments

Comments
 (0)