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
118 changes: 118 additions & 0 deletions examples/cdp-integration-test/01-create-spend-permission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
CdpClient,
spendRouterAddress as SPEND_ROUTER_ADDRESS,
spendPermissionManagerAddress as SPM_ADDRESS,
spendRouterAbi as SPEND_ROUTER_ABI,
buildSpendCalldata,
} from "@coinbase/cdp-sdk";
import { decodeFunctionData } from "viem";

async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}

async function main() {
const walletSecret = process.env.CDP_WALLET_SECRET;
const apiKeySecret = process.env.CDP_API_KEY_SECRET;
if (!walletSecret) throw new Error("set CDP_WALLET_SECRET (from cdp-local register-wallet-secret)");
if (!apiKeySecret)
throw new Error("set CDP_API_KEY_SECRET (any valid PEM EC key works locally; auth-proxy replaces it)");

const cdp = new CdpClient({
apiKeyId: process.env.CDP_API_KEY_ID || "local-dummy-key-id",
apiKeySecret,
walletSecret,
basePath: "http://localhost:8002/platform",
});

console.log("=== STEP 1: Create EOA owner ===");
const eoa = await cdp.evm.createAccount();
console.log(`EOA: ${eoa.address}\n`);

console.log("=== STEP 2: Create Smart Account (with enableSpendPermissions) ===");
const sa = await cdp.evm.createSmartAccount({
owner: eoa,
enableSpendPermissions: true,
});
console.log(`Smart Account: ${sa.address}\n`);

console.log("=== STEP 3: Faucet ETH to smart wallet (with retry) ===");
let faucetRes;
for (let attempt = 1; attempt <= 5; attempt++) {
try {
faucetRes = await cdp.evm.requestFaucet({
address: sa.address,
network: "base-sepolia",
token: "eth",
});
break;
} catch (e: any) {
console.log(` attempt ${attempt} failed: ${e.errorMessage || e.message}, retrying in 8s...`);
if (attempt === 5) throw e;
await sleep(8000);
}
}
console.log(`Faucet tx: ${faucetRes!.transactionHash}`);
console.log(` https://sepolia.basescan.org/tx/${faucetRes!.transactionHash}`);
console.log(` waiting 45s for funds to land...\n`);
await sleep(45000);

console.log("=== STEP 4: Create spend permission ===");
const allowanceWei = 100000000000000n;
const createRes = await cdp.evm.createSpendPermission({
network: "base-sepolia",
spendPermission: {
account: sa.address,
spender: eoa.address as `0x${string}`,
token: "eth",
allowance: allowanceWei,
periodInDays: 1,
} as any,
});
console.log(`Approve user op: ${createRes.userOpHash}`);
console.log(` polling for approval to land onchain...\n`);

for (let i = 0; i < 60; i++) {
await sleep(5000);
try {
const op = await sa.getUserOperation({ userOpHash: createRes.userOpHash as `0x${string}` });
console.log(` poll ${i + 1}: status=${op.status} txHash=${op.transactionHash || "<none yet>"}`);
if (op.status === "complete") {
console.log(`Approve completed onchain: https://sepolia.basescan.org/tx/${op.transactionHash}\n`);
break;
}
if (op.status === "failed") throw new Error("approve user op failed");
} catch (e: any) {
console.log(` poll ${i + 1}: ${e.errorMessage || e.message}`);
}
}

console.log("=== STEP 5: List permissions and verify substitution ===");
const list = await cdp.evm.listSpendPermissions({ address: sa.address });
console.log(`Found ${list.spendPermissions.length} permission(s)`);
const perm = list.spendPermissions[list.spendPermissions.length - 1];
console.log(`Permission spender: ${perm.permission.spender}`);
console.log(` expected SpendRouter: ${SPEND_ROUTER_ADDRESS}`);
console.log(`Permission extraData: ${perm.permission.extraData}`);
console.log(`Routing object: ${JSON.stringify((perm as any).routing)}\n`);

console.log("=== STEP 6: Synchronous dispatch proof ===");
const dispatch = buildSpendCalldata(perm.permission as any, 1000000000n);
console.log(`buildSpendCalldata().to = ${dispatch.to}`);
if (dispatch.to.toLowerCase() === SPEND_ROUTER_ADDRESS.toLowerCase()) {
const decoded = decodeFunctionData({ abi: SPEND_ROUTER_ABI, data: dispatch.data });
console.log(`Dispatch chose SpendRouter, function = ${decoded.functionName}`);
} else {
console.log(`Dispatch chose ${dispatch.to} (expected SpendRouter)`);
}

console.log("\n=== READY FOR STEP 2 ===");
console.log(`Set these env vars for 02-spend-via-eoa.ts:`);
console.log(` export CDP_TEST_SMART_WALLET=${sa.address}`);
console.log(` export CDP_TEST_EOA_OWNER=${eoa.address}`);
}

main().catch((e) => {
console.error("FATAL:", e.errorMessage || e.message);
process.exit(1);
});
98 changes: 98 additions & 0 deletions examples/cdp-integration-test/02-spend-via-eoa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
CdpClient,
spendRouterAddress as SPEND_ROUTER_ADDRESS,
spendRouterAbi as SPEND_ROUTER_ABI,
} from "@coinbase/cdp-sdk";
import { decodeFunctionData } from "viem";

async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}

async function main() {
const walletSecret = process.env.CDP_WALLET_SECRET;
const apiKeySecret = process.env.CDP_API_KEY_SECRET;
const sw = process.env.CDP_TEST_SMART_WALLET as `0x${string}` | undefined;
const owner = process.env.CDP_TEST_EOA_OWNER as `0x${string}` | undefined;
if (!walletSecret) throw new Error("set CDP_WALLET_SECRET");
if (!apiKeySecret) throw new Error("set CDP_API_KEY_SECRET");
if (!sw) throw new Error("set CDP_TEST_SMART_WALLET (printed by step 1)");
if (!owner) throw new Error("set CDP_TEST_EOA_OWNER (printed by step 1)");

const cdp = new CdpClient({
apiKeyId: process.env.CDP_API_KEY_ID || "local-dummy-key-id",
apiKeySecret,
walletSecret,
basePath: "http://localhost:8002/platform",
});

console.log(`=== Get the EOA executor (it needs gas for the spend) ===`);
const eoa = await cdp.evm.getAccount({ address: owner });
console.log(`EOA: ${eoa.address}\n`);

console.log(`=== Faucet ETH to EOA so it can pay gas ===`);
const faucetRes = await cdp.evm.requestFaucet({
address: eoa.address,
network: "base-sepolia",
token: "eth",
});
console.log(`EOA faucet tx: ${faucetRes.transactionHash}`);
console.log(` https://sepolia.basescan.org/tx/${faucetRes.transactionHash}`);
console.log(` waiting 45s for funds...\n`);
await sleep(45000);

console.log(`=== List the smart wallet's permissions ===`);
const list = await cdp.evm.listSpendPermissions({ address: sw });
const perm = list.spendPermissions[list.spendPermissions.length - 1];
console.log(`Permission spender: ${perm.permission.spender}`);
console.log(`Permission account: ${perm.permission.account}\n`);

console.log(`=== EOA calls useSpendPermission ===`);
console.log(`(msg.sender will be the EOA = executor encoded in extraData → SpendRouter check passes)`);
const result = await eoa.useSpendPermission({
spendPermission: perm.permission as any,
value: 100000000000n,
network: "base-sepolia",
});
console.log(`Spend tx hash: ${result.transactionHash}`);
console.log(` https://sepolia.basescan.org/tx/${result.transactionHash}\n`);

console.log(`=== Wait + decode the actual onchain tx to PROVE SpendRouter was called ===`);
await sleep(15000);
const receipt = await fetch("https://sepolia.base.org", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "eth_getTransactionByHash",
params: [result.transactionHash],
id: 1,
}),
}).then((r) => r.json());

const tx = receipt.result;
if (!tx) {
console.log("Tx not found yet — try again in a minute");
return;
}
console.log(`Tx target (to): ${tx.to}`);
console.log(`SpendRouter: ${SPEND_ROUTER_ADDRESS}`);
if (tx.to.toLowerCase() === SPEND_ROUTER_ADDRESS.toLowerCase()) {
console.log(`\nONCHAIN PROOF: real Base Sepolia tx with target = SpendRouter`);
const decoded = decodeFunctionData({ abi: SPEND_ROUTER_ABI, data: tx.input as `0x${string}` });
console.log(`Function called: ${decoded.functionName}`);
if (decoded.functionName === "spendAndRoute") {
console.log(
`\nFULL E2E PROVEN: real onchain tx where the EOA called SpendRouter.spendAndRoute via the SDK dispatch.`,
);
console.log(`basescan: https://sepolia.basescan.org/tx/${result.transactionHash}`);
}
} else {
console.log(`Target mismatch (expected SpendRouter)`);
}
}

main().catch((e) => {
console.error("FATAL:", e.errorMessage || e.message);
process.exit(1);
});
92 changes: 92 additions & 0 deletions examples/cdp-integration-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# cdp-integration-test

Reproducible end-to-end test harness for the SpendRouter integration with CDP (cdp-api + cdp-service + cdp-sdk). Used to verify the integration before release; full writeup of methodology, the four resulting Base Sepolia tx hashes, and known gaps lives at [SpendRouter — local testing writeup](https://base-onchain-protocols-wiki.cbhq.net/49ae1313-3b48-4e27-a3fb-ead99f454f04) on the Base Onchain Protocols wiki (Coinbase-internal).

## What these scripts prove

Running the two scripts in order produces real Base Sepolia transactions where the SDK's dispatch logic correctly targets `SpendRouter.spendAndRoute()` instead of `SpendPermissionManager.spend()` for permissions whose onchain spender is the SpendRouter contract.

The reference run produced this transaction:

[`0x6025f0bf9bb630d8c6199bd1e3d527a3a3bff7d5144bf4bdc1e8e301b6624e7f`](https://sepolia.basescan.org/tx/0x6025f0bf9bb630d8c6199bd1e3d527a3a3bff7d5144bf4bdc1e8e301b6624e7f) — `to = SpendRouter`, `function = spendAndRoute`.

## Prerequisites

You need a working local cdp-service stack with a registered wallet secret. Setup is documented in the wiki writeup linked above; the short version:

1. Coinbase full-tunnel VPN, ECR access to `c3/*` repos in account `652969937640`
2. `cd ~/cdp-service && make docker_deps` (brings up 16 dependency containers)
3. Run cdp-service main + auth-proxy with these env tweaks (see writeup for the full bash blocks):
- `GOLANG_PROTOBUF_REGISTRATION_CONFLICT=warn` (workaround for a master-side dep collision)
- `BLOCKCHAIN_ENDPOINT=blockchain-service-platform-us-east-1-development.cbhq.net:443` (and matching S2S settings)
- `AUTH_PROXY_USER_UUID=00000000-0000-0000-0000-000000000000` (default is commented out in the bundled `.env`)
4. `go install github.cbhq.net/alexstone/cdp-local@latest && cdp-local register-wallet-secret` — copy the printed base64 PKCS8 private key
5. Generate a throwaway PKCS8 EC API key (the SDK requires one for header parsing; the auth-proxy strips and replaces it):
```bash
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out dummy-key.pem
```

## Install

```bash
npm install
```

If you're testing against a local cdp-sdk build (not the published version), point the dependency at your local checkout:

```bash
npm install --save '@coinbase/cdp-sdk@file:/path/to/cdp-sdk/typescript/src'
```

The `file:` path must be the `src` subdir, not the repo root, because that's where the published `package.json` lives.

## Run

```bash
export CDP_WALLET_SECRET="<base64 from cdp-local register-wallet-secret>"
export CDP_API_KEY_SECRET="$(cat dummy-key.pem)"

# Step 1: create EOA → smart wallet (with enableSpendPermissions) → faucet → create permission
npm run create
# Output: prints the smart wallet + EOA addresses, the permission listed back
# with spender = SpendRouter (0x1a672dE4...) and decoded routing object

# Step 2: spend the permission via the EOA executor (this is the dispatch test)
# Edit the SW + OWNER constants at the top of 02-spend-via-eoa.ts to match
# the addresses from step 1's output, then:
npm run spend
# Output: prints the real Base Sepolia tx hash where SpendRouter.spendAndRoute
# was called via the SDK dispatch logic. Decoded onchain to confirm.
```

## Why two scripts

Permission approval (`SPM.approve(permission)` via user op) is async — the smart wallet's user op needs to be bundled and land onchain before any spend can succeed. Splitting the flow into two scripts gives a natural pause and lets you verify intermediate state (e.g. `SpendPermissionManager.isValid()` returning true via `cast`) before triggering the spend.

## Verifying intermediate state via cast

After step 1 you can confirm onchain that the permission was registered with SpendRouter as spender:

```bash
SPM=0xf85210B21cC50302F477BA56686d2019dC9b67Ad
SW=<smart wallet from step 1>
ROUTER=0x1a672dE48c82278b2F1BB68d7b9141634dD6BE29
ETH=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
EXTRADATA=0x<from the listSpendPermissions response>
SALT=<from response>
START=<from response>

cast call $SPM \
"isValid((address,address,address,uint160,uint48,uint48,uint48,uint256,bytes))(bool)" \
"($SW,$ROUTER,$ETH,100000000000000,86400,$START,281474976710655,$SALT,$EXTRADATA)" \
--rpc-url https://sepolia.base.org
# Should print: true
```

## Known gaps

See the wiki writeup for the full list. Short version:

- The `recipient` request field is dropped by the SDK's typed serializer until the cdp-api PR ships and the SDKs regenerate. Default-recipient (no-routing) path is fully tested; explicit-recipient is mechanical to verify with the same harness once the SDK regens.
- No paymaster path — both EOA and smart wallet pay their own gas via faucet ETH. CDP's sponsored paymaster wasn't tested.
- Smart wallet must be created with `enableSpendPermissions: true` so SpendPermissionManager is added as the second owner; without it cdp-service's create-spend-permission validation rejects the request.
15 changes: 15 additions & 0 deletions examples/cdp-integration-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "cdp-integration-test",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"create": "tsx 01-create-spend-permission.ts",
"spend": "tsx 02-spend-via-eoa.ts"
},
"dependencies": {
"@coinbase/cdp-sdk": "^1.48.2",
"tsx": "^4.7.0",
"viem": "^2.21.0"
}
}
11 changes: 11 additions & 0 deletions examples/cdp-integration-test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": false,
"skipLibCheck": true,
"resolveJsonModule": true
}
}