Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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.
88 changes: 88 additions & 0 deletions packages/evm-wallet-experiment/docker/create-delegation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* 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,
resolveOnChainDelegateForDockerMode,
} 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 delegationMode = process.env.DELEGATION_MODE ?? 'bundler-7702';

const home = makeDaemonClient(HOME_SOCKET);

const callHome = (method, args) =>
home.callVat(homeInfo.coordinatorKref, method, args);

const delegate = resolveOnChainDelegateForDockerMode({
delegationMode,
homeInfo,
awayInfo,
});
console.log(`[delegation] home coordinator: ${homeInfo.coordinatorKref}`);
console.log(`[delegation] mode: ${delegationMode}`);
console.log(
`[delegation] on-chain delegate: ${delegate}${
delegationMode === 'peer-relay'
? ' (home; peer-relay redeem)'
: awayInfo.smartAccountAddress
? ' (away smart account)'
: ' (away 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,
homeInfo,
delegationMode,
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);
});
Loading
Loading