Skip to content
Merged
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
60 changes: 30 additions & 30 deletions packages/evm-wallet-experiment/docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ The script will:
4. Create a Hybrid smart account and fund it if needed (may trigger a MetaMask approval for the funding tx)
5. Show the OCAP URL and `setup-away.sh` command

Use `--reset` to purge all kernel state and start fresh. The SQLite database is at `~/.ocap/kernel-interactive.sqlite`.
Use `--reset` to purge all kernel state and start fresh. The SQLite database is at `$OCAP_HOME/kernel-interactive.sqlite` (defaults to `~/.ocap/kernel-interactive.sqlite`).

**Note:** Interactive mode uses a Hybrid smart account (different address from the EOA) instead of EIP-7702 stateless. This is because EIP-7702 requires signing an authorization transaction that MetaMask Mobile does not support. The smart account is auto-funded from the EOA if its balance is below 0.05 ETH.

Expand Down Expand Up @@ -335,7 +335,7 @@ When creating a delegation manually, add caveats to the `caveats` array. The `te

```bash
# Delegation with 0.05 ETH total limit and 0.01 ETH per-transaction limit
yarn ocap daemon exec queueMessage '["ko4", "createDelegation", [{
yarn ocap daemon queueMessage ko4 createDelegation '[{
"delegate": "0xAWAY_SMART_ACCOUNT",
"caveats": [
{
Expand All @@ -350,7 +350,7 @@ yarn ocap daemon exec queueMessage '["ko4", "createDelegation", [{
}
],
"chainId": 11155111
}]]'
}]'
```

### Changing limits
Expand Down Expand Up @@ -436,7 +436,7 @@ The home device holds the master wallet keys and runs a kernel daemon that the a
yarn ocap daemon start
```

This starts the OCAP daemon at `~/.ocap/daemon.sock` with persistent storage at `~/.ocap/kernel.sqlite`.
This starts the OCAP daemon at `~/.ocap/daemon.sock` with persistent storage at `~/.ocap/kernel.sqlite`. You can override the `~/.ocap` base directory by setting the `OCAP_HOME` environment variable (e.g. `export OCAP_HOME=/data/ocap`).

### 2b. Initialize remote comms (QUIC)

Expand Down Expand Up @@ -495,34 +495,34 @@ Note the `rootKref` from the output (e.g. `ko4`). This is the wallet coordinator

```bash
# Initialize with your mnemonic (SRP) — plaintext storage
yarn ocap daemon exec queueMessage '["ko4", "initializeKeyring", [{"type": "srp", "mnemonic": "your twelve word mnemonic phrase here"}]]'
yarn ocap daemon queueMessage ko4 initializeKeyring '[{"type": "srp", "mnemonic": "your twelve word mnemonic phrase here"}]'

# Or encrypt the mnemonic at rest with a password:
SALT="$(node -e "process.stdout.write(require('crypto').randomBytes(16).toString('hex'))")"
yarn ocap daemon exec queueMessage "[\"ko4\", \"initializeKeyring\", [{\"type\": \"srp\", \"mnemonic\": \"your twelve word mnemonic phrase here\", \"password\": \"your-password\", \"salt\": \"$SALT\"}]]"
yarn ocap daemon queueMessage ko4 initializeKeyring "[{\"type\": \"srp\", \"mnemonic\": \"your twelve word mnemonic phrase here\", \"password\": \"your-password\", \"salt\": \"$SALT\"}]"

# Verify
yarn ocap daemon exec queueMessage '["ko4", "getAccounts", []]'
yarn ocap daemon queueMessage ko4 getAccounts
```

When a password is provided, the mnemonic is encrypted with AES-256-GCM (PBKDF2 key derivation). After a daemon restart, the keyring will be locked — unlock it before signing:

```bash
yarn ocap daemon exec queueMessage '["ko4", "unlockKeyring", ["your-password"]]'
yarn ocap daemon queueMessage ko4 unlockKeyring '["your-password"]'
```

### 2e. Configure the provider

```bash
yarn ocap daemon exec queueMessage '["ko4", "configureProvider", [{"chainId": 11155111, "rpcUrl": "https://sepolia.infura.io/v3/YOUR_INFURA_KEY"}]]'
yarn ocap daemon queueMessage ko4 configureProvider '[{"chainId": 11155111, "rpcUrl": "https://sepolia.infura.io/v3/YOUR_INFURA_KEY"}]'
# For other chains, adjust chainId and rpcUrl:
# yarn ocap daemon exec queueMessage '["ko4", "configureProvider", [{"chainId": 8453, "rpcUrl": "https://base-mainnet.infura.io/v3/YOUR_INFURA_KEY"}]]'
# yarn ocap daemon queueMessage ko4 configureProvider '[{"chainId": 8453, "rpcUrl": "https://base-mainnet.infura.io/v3/YOUR_INFURA_KEY"}]'
```

### 2f. Issue an OCAP URL for the away device

```bash
yarn ocap daemon exec queueMessage '["ko4", "issueOcapUrl", []]'
yarn ocap daemon queueMessage ko4 issueOcapUrl
```

Save the returned `ocap:...` URL and the listen addresses from `getStatus` above — you'll give both to the away device.
Expand Down Expand Up @@ -565,19 +565,19 @@ The away wallet gets a throwaway key (for signing UserOps within delegations). U

```bash
ENTROPY="0x$(node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))")"
yarn ocap daemon exec queueMessage "[\"ko4\", \"initializeKeyring\", [{\"type\": \"throwaway\", \"entropy\": \"$ENTROPY\"}]]"
yarn ocap daemon queueMessage ko4 initializeKeyring "[{\"type\": \"throwaway\", \"entropy\": \"$ENTROPY\"}]"
```

### 3f. Connect to the home wallet

```bash
yarn ocap daemon exec queueMessage '["ko4", "connectToPeer", ["ocap:zgAu...YOUR_OCAP_URL_HERE"]]'
yarn ocap daemon queueMessage ko4 connectToPeer '["ocap:zgAu...YOUR_OCAP_URL_HERE"]'
```

### 3g. Verify the connection

```bash
yarn ocap daemon exec queueMessage '["ko4", "getCapabilities", []]'
yarn ocap daemon queueMessage ko4 getCapabilities
```

Should show `hasPeerWallet: true`.
Expand All @@ -594,43 +594,43 @@ For manual setup, the steps are:

```bash
# Home device (EIP-7702 — EOA becomes the smart account):
yarn ocap daemon exec queueMessage '["ko4", "createSmartAccount", [{"chainId": 11155111, "implementation": "stateless7702"}]]'
yarn ocap daemon queueMessage ko4 createSmartAccount '[{"chainId": 11155111, "implementation": "stateless7702"}]'

# Away device (Hybrid — deploys on first UserOp):
yarn ocap daemon exec queueMessage '["ko4", "createSmartAccount", [{"chainId": 11155111}]]'
yarn ocap daemon queueMessage ko4 createSmartAccount '[{"chainId": 11155111}]'
```

The home EOA's existing ETH balance is used directly for delegated transfers — no separate funding step needed.

2. Read the delegate address from the away device (sent automatically by the setup flow after peer connection):

```bash
yarn ocap daemon exec queueMessage '["ko4", "getDelegateAddress", []]'
yarn ocap daemon queueMessage ko4 getDelegateAddress
```

3. Create the delegation on the home device (delegate = away smart account). See [Spending limits](#spending-limits) for adding caveats:

```bash
yarn ocap daemon exec queueMessage '["ko4", "createDelegation", [{"delegate": "0xAWAY_SMART_ACCOUNT", "caveats": [], "chainId": 11155111}]]'
yarn ocap daemon queueMessage ko4 createDelegation '[{"delegate": "0xAWAY_SMART_ACCOUNT", "caveats": [], "chainId": 11155111}]'
```

4. Push the delegation to the away device (if connected):

```bash
yarn ocap daemon exec queueMessage '["ko4", "pushDelegationToAway", [<DELEGATION_JSON>]]'
yarn ocap daemon queueMessage ko4 pushDelegationToAway '[<DELEGATION_JSON>]'
```

Or transfer manually if the away device is offline:

```bash
# On the away device:
yarn ocap daemon exec queueMessage '["ko4", "receiveDelegation", [<DELEGATION_JSON>]]'
yarn ocap daemon queueMessage ko4 receiveDelegation '[<DELEGATION_JSON>]'
```

5. Verify:

```bash
yarn ocap daemon exec queueMessage '["ko4", "getCapabilities", []]'
yarn ocap daemon queueMessage ko4 getCapabilities
# Should show delegationCount: 1
```

Expand Down Expand Up @@ -659,26 +659,26 @@ You can also call the wallet coordinator directly. Replace `ko4` with your `root

```bash
# List accounts
yarn ocap daemon exec queueMessage '["ko4", "getAccounts", []]'
yarn ocap daemon queueMessage ko4 getAccounts

# Check capabilities (local keys, peer wallet, delegations, bundler)
yarn ocap daemon exec queueMessage '["ko4", "getCapabilities", []]'
yarn ocap daemon queueMessage ko4 getCapabilities

# Sign a message
yarn ocap daemon exec queueMessage '["ko4", "signMessage", ["hello world"]]'
yarn ocap daemon queueMessage ko4 signMessage '["hello world"]'

# Sign a transaction
yarn ocap daemon exec queueMessage '["ko4", "signTransaction", [{"to": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", "value": "0x2386F26FC10000", "chainId": 11155111}]]'
yarn ocap daemon queueMessage ko4 signTransaction '[{"to": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", "value": "0x2386F26FC10000", "chainId": 11155111}]'

# Query the chain (eth_getBalance, eth_blockNumber, etc.)
yarn ocap daemon exec queueMessage '["ko4", "request", ["eth_getBalance", ["0x71fA1599e6c6FE46CD2A798E136f3ba22863cF82", "latest"]]]'
yarn ocap daemon exec queueMessage '["ko4", "request", ["eth_blockNumber", []]]'
yarn ocap daemon queueMessage ko4 request '["eth_getBalance", ["0x71fA1599e6c6FE46CD2A798E136f3ba22863cF82", "latest"]]'
yarn ocap daemon queueMessage ko4 request '["eth_blockNumber", []]'

# Create a delegation for another address
yarn ocap daemon exec queueMessage '["ko4", "createDelegation", [{"delegate": "0x...", "caveats": [], "chainId": 11155111}]]'
yarn ocap daemon queueMessage ko4 createDelegation '[{"delegate": "0x...", "caveats": [], "chainId": 11155111}]'

# List active delegations
yarn ocap daemon exec queueMessage '["ko4", "listDelegations", []]'
yarn ocap daemon queueMessage ko4 listDelegations
```

## 6. How it works
Expand All @@ -690,7 +690,7 @@ Agent (AI)
├─ wallet_send ──→ │
└─ wallet_sign ──→ │
yarn ocap daemon exec queueMessage
yarn ocap daemon queueMessage
OCAP Daemon (Unix socket)
Expand Down
117 changes: 37 additions & 80 deletions packages/evm-wallet-experiment/openclaw-plugin/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* Daemon communication layer for the OpenClaw wallet plugin.
*
* Spawns `ocap daemon exec` commands and decodes Endo CapData responses.
* Spawns `ocap daemon queueMessage` commands. The CLI auto-decodes CapData
* via prettifySmallcaps, so no manual CapData unwrapping is needed here.
*/
import { spawn } from 'node:child_process';

type ExecResult = { stdout: string; stderr: string; code: number | null };
type CapDataLike = { body: string; slots: unknown[] };

export type WalletCallOptions = {
cliPath: string;
Expand Down Expand Up @@ -51,89 +51,25 @@ export function makeWalletCaller(options: {
}

/**
* Check if a value looks like Endo CapData.
*
* @param value - The parsed JSON value.
* @returns True when value has CapData shape.
*/
function isCapDataLike(value: unknown): value is CapDataLike {
if (typeof value !== 'object' || value === null) {
return false;
}
if (!('body' in value) || !('slots' in value)) {
return false;
}
const { body } = value as { body?: unknown };
const { slots } = value as { slots?: unknown };
return typeof body === 'string' && Array.isArray(slots);
}

/**
* Decode daemon JSON output, unwrapping Endo CapData.
*
* @param raw - Raw stdout from `ocap daemon exec`.
* @param method - Wallet method name (for better errors).
* @returns The decoded value.
*/
function decodeCapData(raw: string, method: string): unknown {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Wallet ${method} returned non-JSON output`);
}

if (!isCapDataLike(parsed)) {
return parsed;
}

if (!parsed.body.startsWith('#')) {
throw new Error(`Wallet ${method} returned invalid CapData body`);
}

const bodyContent = parsed.body.slice(1);

// Handle error bodies from vat exceptions (e.g. "#error:message")
if (bodyContent.startsWith('error:')) {
throw new Error(`Wallet ${method} vat error: ${bodyContent.slice(6)}`);
}

let decoded: unknown;
try {
decoded = JSON.parse(bodyContent);
} catch {
throw new Error(`Wallet ${method} returned undecodable CapData body`);
}

// Handle Endo CapData error encoding: #{"#error": "message", ...}
if (decoded !== null && typeof decoded === 'object' && '#error' in decoded) {
const errorMsg = (decoded as Record<string, unknown>)['#error'];
throw new Error(
`Wallet ${method} failed: ${typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg)}`,
);
}

return decoded;
}

/**
* Run an `ocap daemon exec` command and return its output.
* Run an `ocap daemon queueMessage` command and return its output.
*
* @param options - Execution options.
* @param options.cliPath - Path to the ocap CLI.
* @param options.method - The daemon RPC method.
* @param options.params - The method parameters.
* @param options.walletKref - KRef of the target kernel object.
* @param options.method - Method name to invoke.
* @param options.argsJson - JSON-encoded array of arguments.
* @param options.timeoutMs - Timeout in ms.
* @returns The command result.
*/
async function runDaemonExec(options: {
async function runDaemonQueueMessage(options: {
cliPath: string;
walletKref: string;
method: string;
params: unknown;
argsJson: string;
timeoutMs: number;
}): Promise<ExecResult> {
const { cliPath, method, params, timeoutMs } = options;
const daemonArgs = ['daemon', 'exec', method, JSON.stringify(params)];
const { cliPath, walletKref, method, argsJson, timeoutMs } = options;
const daemonArgs = ['daemon', 'queueMessage', walletKref, method, argsJson];

// If cliPath points to a .mjs file, invoke it via node.
const command = cliPath.endsWith('.mjs') ? 'node' : cliPath;
Expand All @@ -160,7 +96,9 @@ async function runDaemonExec(options: {
try {
child.kill('SIGKILL');
} finally {
reject(new Error(`ocap daemon exec timed out after ${timeoutMs}ms`));
reject(
new Error(`ocap daemon queueMessage timed out after ${timeoutMs}ms`),
);
}
}, timeoutMs);

Expand All @@ -179,15 +117,19 @@ async function runDaemonExec(options: {
/**
* Call a wallet coordinator method via the OCAP daemon.
*
* The CLI's `daemon queueMessage` auto-decodes CapData via prettifySmallcaps,
* so the output is already a decoded JSON value.
*
* @param options - Call options.
* @returns The decoded response value.
*/
async function callWallet(options: WalletCallOptions): Promise<unknown> {
const { cliPath, walletKref, method, args, timeoutMs } = options;
const result = await runDaemonExec({
const result = await runDaemonQueueMessage({
cliPath,
method: 'queueMessage',
params: [walletKref, method, args],
walletKref,
method,
argsJson: JSON.stringify(args),
timeoutMs,
});

Expand All @@ -196,5 +138,20 @@ async function callWallet(options: WalletCallOptions): Promise<unknown> {
throw new Error(`Wallet ${method} failed (exit ${result.code}): ${detail}`);
}

return decodeCapData(result.stdout.trim(), method);
const raw = result.stdout.trim();
let decoded: unknown;
try {
decoded = JSON.parse(raw);
} catch {
throw new Error(`Wallet ${method} returned non-JSON output`);
}

// prettifySmallcaps converts #error objects to "[ErrorName: msg]" strings,
// but also converts manifest constants to "[undefined]", "[NaN]", etc.
// Distinguish errors by checking for the ": " separator after the bracket.
if (typeof decoded === 'string' && /^\[.+: /u.test(decoded)) {
throw new Error(`Wallet ${method} failed: ${decoded}`);
}

return decoded;
}
Loading
Loading