Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1559a67
feat(evm-wallet-experiment): add Docker Compose E2E stack
grypez Mar 27, 2026
de55f70
feat(evm-wallet-experiment): make scripts Docker-friendly and delegat…
grypez Mar 27, 2026
a720e6e
feat(evm-wallet-experiment): add direct 7702 delegation redemption to…
grypez Mar 27, 2026
34b39e3
test(evm-wallet-experiment): add Docker E2E vitest suite
grypez Mar 27, 2026
cb1524a
refactor(evm-wallet-experiment): replace self-configuring Docker cont…
grypez Mar 27, 2026
4e7809e
refactor(evm-wallet-experiment): rewrite ollama-agent using makeChatA…
grypez Mar 30, 2026
5937ea3
refactor(evm-wallet-experiment): inline agent loop into test, delete …
grypez Mar 30, 2026
7f209f6
refactor(evm-wallet-experiment): extract wallet capabilities into cap…
grypez Mar 30, 2026
7a390e3
fix(evm-wallet-experiment): Docker E2E vs fetch mock
grypez Apr 1, 2026
361df4c
fix(evm-wallet-experiment): wait for bundler before kernel services s…
grypez Apr 1, 2026
8f2b024
fix(evm-wallet-experiment): tighten Docker healthchecks
grypez Apr 1, 2026
7d4df09
chore(evm-wallet-experiment): pin Alto, OpenClaw, and EVM image npm deps
grypez Apr 1, 2026
25e6b1f
fix(evm-wallet-experiment): fail kernel Docker build on native rebuil…
grypez Apr 1, 2026
c77092e
docs(evm-wallet-experiment): add Docker stack MAINTAINERS.md
grypez Apr 1, 2026
c56bd29
chore(evm-wallet-experiment): name Anvil for local chain 31337
grypez Apr 1, 2026
1ff162d
fix(evm-wallet-experiment): pin tsx for host docker:setup:wallets
grypez Apr 1, 2026
f036a65
fix(evm-wallet-experiment): rebuild node-datachannel in kernel Docker…
grypez Apr 1, 2026
66a9505
fix(evm-wallet-experiment): align Docker OpenClaw tools.allow with ho…
grypez Apr 1, 2026
4470d2b
refactor(evm-wallet-experiment): centralize Docker E2E stack defaults
grypez Apr 1, 2026
c1e1173
refactor(evm-wallet-experiment): share Docker delegation create/push …
grypez Apr 1, 2026
eda0887
refactor(evm-wallet-experiment): load docker stack config from JSON a…
grypez Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/evm-wallet-experiment/docker/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/node_modules
**/.git
**/dist
**/coverage
**/.turbo
**/logs
24 changes: 24 additions & 0 deletions packages/evm-wallet-experiment/docker/Dockerfile.evm
Original file line number Diff line number Diff line change
@@ -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"]
70 changes: 70 additions & 0 deletions packages/evm-wallet-experiment/docker/Dockerfile.kernel-base
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions packages/evm-wallet-experiment/docker/MAINTAINERS.md
Original file line number Diff line number Diff line change
@@ -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:<digest>`

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.
74 changes: 74 additions & 0 deletions packages/evm-wallet-experiment/docker/create-delegation.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
128 changes: 128 additions & 0 deletions packages/evm-wallet-experiment/docker/deploy-contracts.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading