Skip to content

Commit 3c341f0

Browse files
authored
feat: add bundler for passkey transactions (#2493)
* feat: add bundler for passkey transactions * feat: continue bundler
1 parent dcac36a commit 3c341f0

21 files changed

+5639
-10
lines changed

evm-e2e/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ npm run bundler
9292
The `bun test` suite now exercises the passkey ERC-4337 flow end-to-end. During the run it:
9393

9494
- Builds the `passkey-sdk`, deploys a fresh `EntryPointV06` + `PasskeyAccountFactory`, and funds the dev bundler key.
95-
- Starts the local bundler from `passkey-sdk/dist/local-bundler.js` on port `14437`.
95+
- Starts a bundler on port `14437`: by default the lightweight `passkey-sdk/dist/local-bundler.js`; set
96+
`PASSKEY_BUNDLER_MODE=official` to instead build and start `passkey-bundler/dist/index.js` (ensure `npm install` in
97+
`passkey-bundler/` first).
9698
- Executes the CLI passkey script against that bundler to prove a full user operation.
9799

98-
Ensure `node`, `npm`, and `tsup` dependencies are installed (`npm install` in both `evm-e2e/` and `evm-e2e/passkey-sdk/`) and that port `14437` is free before running `bun test` or `just test-e2e`.
100+
Ensure `node`, `npm`, and `tsup` dependencies are installed (`npm install` in `evm-e2e/`, `evm-e2e/passkey-sdk/`, and
101+
`passkey-bundler/` if using `PASSKEY_BUNDLER_MODE=official`) and that port `14437` is free before running `bun test` or
102+
`just test-e2e`.

evm-e2e/test/passkey_account.test.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ import { account, provider } from "./setup"
88
import { EntryPointV06__factory, PasskeyAccountFactory__factory } from "../types"
99

1010
const PASSKEY_SDK_DIR = path.resolve(__dirname, "..", "passkey-sdk")
11+
const PASSKEY_BUNDLER_DIR = path.resolve(__dirname, "..", "..", "passkey-bundler")
1112
const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm"
1213
const NODE_BIN = "node"
1314
const JSON_RPC_ENDPOINT = process.env.JSON_RPC_ENDPOINT ?? "http://127.0.0.1:8545"
1415
const MNEMONIC = process.env.MNEMONIC
1516
const BUNDLER_DEV_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
17+
const BUNDLER_DEV_PRIVATE_KEY =
18+
process.env.BUNDLER_DEV_PRIVATE_KEY ??
19+
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
1620
const BUNDLER_PORT = 14437
1721
const PASSKEY_SEED = "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
1822
const PASSKEY_TEST_TIMEOUT = Number(process.env.PASSKEY_TEST_TIMEOUT ?? 240000)
23+
const PASSKEY_BUNDLER_MODE = process.env.PASSKEY_BUNDLER_MODE ?? "local"
1924

2025
if (!MNEMONIC) {
2126
throw new Error("MNEMONIC must be set for passkey e2e test")
@@ -25,14 +30,15 @@ describe(
2530
"passkey ERC-4337 flow",
2631
() => {
2732
it(
28-
"executes a passkey user operation via local bundler",
33+
"executes a passkey user operation via bundler",
2934
async () => {
3035
await buildPasskeySdk()
36+
await buildPasskeyBundlerIfNeeded()
3137
const { entryPointAddr, factoryAddr } = await deployPasskeyContracts()
3238
const chainId = BigInt((await provider.getNetwork()).chainId)
3339
await fundBundlerSigner()
3440

35-
const bundler = startBundler(entryPointAddr, chainId)
41+
const bundler = await startBundler(entryPointAddr, chainId)
3642
try {
3743
await waitForBundlerReady(bundler)
3844
await runPasskeyScript({ entryPointAddr, factoryAddr })
@@ -52,6 +58,18 @@ async function buildPasskeySdk() {
5258
})
5359
}
5460

61+
async function buildPasskeyBundlerIfNeeded() {
62+
if (PASSKEY_BUNDLER_MODE !== "official") return
63+
await runCommand(NPM_BIN, ["install"], {
64+
cwd: PASSKEY_BUNDLER_DIR,
65+
env: process.env,
66+
})
67+
await runCommand(NPM_BIN, ["run", "build"], {
68+
cwd: PASSKEY_BUNDLER_DIR,
69+
env: process.env,
70+
})
71+
}
72+
5573
async function deployPasskeyContracts() {
5674
const entryPoint = await new EntryPointV06__factory(account).deploy()
5775
await entryPoint.waitForDeployment()
@@ -72,7 +90,14 @@ async function fundBundlerSigner() {
7290
await tx.wait()
7391
}
7492

75-
function startBundler(entryPointAddr: string, chainId: bigint) {
93+
async function startBundler(entryPointAddr: string, chainId: bigint) {
94+
if (PASSKEY_BUNDLER_MODE === "official") {
95+
return startOfficialBundler(entryPointAddr, chainId)
96+
}
97+
return startLocalBundler(entryPointAddr, chainId)
98+
}
99+
100+
function startLocalBundler(entryPointAddr: string, chainId: bigint) {
76101
const env = {
77102
...process.env,
78103
ENTRY_POINT: entryPointAddr,
@@ -89,8 +114,26 @@ function startBundler(entryPointAddr: string, chainId: bigint) {
89114
return proc
90115
}
91116

117+
function startOfficialBundler(entryPointAddr: string, chainId: bigint) {
118+
const env = {
119+
...process.env,
120+
ENTRY_POINT: entryPointAddr,
121+
JSON_RPC_ENDPOINT,
122+
CHAIN_ID: chainId.toString(),
123+
BUNDLER_PORT: BUNDLER_PORT.toString(),
124+
BUNDLER_PRIVATE_KEY: BUNDLER_DEV_PRIVATE_KEY,
125+
}
126+
const proc = spawn(NODE_BIN, ["dist/index.js"], {
127+
cwd: PASSKEY_BUNDLER_DIR,
128+
env,
129+
stdio: ["ignore", "pipe", "pipe"],
130+
})
131+
pipeOutput(proc, "bundler")
132+
return proc
133+
}
134+
92135
async function waitForBundlerReady(proc: ChildProcessWithoutNullStreams) {
93-
await waitForOutput(proc, "Bundler JSON-RPC listening")
136+
await waitForOutput(proc, ["Bundler JSON-RPC listening", "Bundler listening"])
94137
}
95138

96139
async function runPasskeyScript(opts: { entryPointAddr: string; factoryAddr: string }) {
@@ -141,21 +184,23 @@ function pipeOutput(proc: ChildProcessWithoutNullStreams, label: string) {
141184
proc.stderr.on("data", (data) => process.stderr.write(`[${label}] ${data}`))
142185
}
143186

144-
function waitForOutput(proc: ChildProcessWithoutNullStreams, marker: string, timeoutMs = 20000) {
187+
function waitForOutput(proc: ChildProcessWithoutNullStreams, markers: string | string[], timeoutMs = 20000) {
188+
const markerList = Array.isArray(markers) ? markers : [markers]
145189
return new Promise<void>((resolve, reject) => {
146190
const onData = (data: Buffer) => {
147-
if (data.toString().includes(marker)) {
191+
const str = data.toString()
192+
if (markerList.some((m) => str.includes(m))) {
148193
cleanup()
149194
resolve()
150195
}
151196
}
152197
const onExit = (code: number | null) => {
153198
cleanup()
154-
reject(new Error(`process exited before emitting "${marker}" (code=${code})`))
199+
reject(new Error(`process exited before emitting "${markerList.join(",")}" (code=${code})`))
155200
}
156201
const timer = setTimeout(() => {
157202
cleanup()
158-
reject(new Error(`timed out waiting for "${marker}"`))
203+
reject(new Error(`timed out waiting for "${markerList.join(",")}"`))
159204
}, timeoutMs)
160205

161206
const cleanup = () => {

passkey-bundler/Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# syntax=docker/dockerfile:1
2+
3+
FROM node:20-alpine AS build
4+
WORKDIR /app
5+
COPY package*.json ./
6+
RUN npm ci
7+
COPY . .
8+
RUN npm run build
9+
10+
FROM node:20-alpine
11+
WORKDIR /app
12+
ENV NODE_ENV=production
13+
COPY --from=build /app/package*.json ./
14+
RUN npm ci --omit=dev
15+
COPY --from=build /app/dist ./dist
16+
EXPOSE 4337
17+
CMD ["node", "dist/index.js"]

passkey-bundler/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Passkey Bundler
2+
3+
Lightweight ERC-4337 bundler focused on passkey-backed accounts on Nibiru, aligned with `bundler-prd.md`. It exposes
4+
JSON-RPC on port `4337` by default, performs validation, prefunding, and queue-based submission to the configured
5+
EntryPoint, and ships with health and metrics endpoints for operations.
6+
7+
## Features
8+
- JSON-RPC: `eth_chainId`, `eth_supportedEntryPoints`, `eth_sendUserOperation`, `eth_getUserOperationReceipt`.
9+
- Passkey helpers: `passkey_createAccount(qx,qy,factory?)`, `passkey_fundAccount(address,amountWei)`,
10+
`passkey_getLogs(limit)`.
11+
- Validation: entry point and chain ID enforcement, userOp schema checks, rate limiting, optional API key auth
12+
(`x-api-key` or `Authorization: Bearer <key>`).
13+
- Queue + retries: in-memory FIFO queue (tie-breaker `maxPriorityFeePerGas`), configurable concurrency, retries with
14+
gas bumping and nonce management.
15+
- Prefunding: optional EntryPoint `depositTo` top-ups with configurable ceiling.
16+
- Observability: `/healthz`, `/readyz`, `/metrics` (Prometheus), structured logs kept in a rolling buffer.
17+
- Storage: in-memory receipt/log store with configurable retention (intended to be swapped for SQLite/Postgres later).
18+
19+
## Quickstart
20+
21+
```bash
22+
cd passkey-bundler
23+
npm install # already run in repo; keeps package-lock.json
24+
25+
# run in dev mode (ts)
26+
RPC_URL=http://127.0.0.1:8545 \
27+
ENTRY_POINT=0x... \
28+
BUNDLER_PRIVATE_KEY=0x... \
29+
BUNDLER_PORT=4337 \
30+
CHAIN_ID=9000 \
31+
npm run dev
32+
33+
# or build then run
34+
npm run build
35+
node dist/index.js
36+
```
37+
38+
## Configuration (env)
39+
40+
- `BUNDLER_MODE`: `dev` (default) or `testnet` (enforces safer defaults + requires `DB_URL`).
41+
- `RPC_URL` / `JSON_RPC_ENDPOINT`: upstream RPC endpoint.
42+
- `ENTRY_POINT`: EntryPoint address (required).
43+
- `CHAIN_ID`: chain ID (falls back to RPC if unset).
44+
- `BUNDLER_PRIVATE_KEY`: bundler signer (required). Optional `BENEFICIARY` overrides the handleOps beneficiary.
45+
- `BUNDLER_PORT`: JSON-RPC/health/metrics port (default `4337`).
46+
- `METRICS_PORT`: optional separate port for metrics.
47+
- `DB_URL`: SQLite database location (e.g. `sqlite:./data/bundler.sqlite` or `./data/bundler.sqlite`).
48+
- `MAX_BODY_BYTES`: request body size limit (default `1000000`).
49+
- `BUNDLER_REQUIRE_AUTH`: require API key auth even if `BUNDLER_API_KEYS` is empty (defaults to `true` in testnet mode).
50+
- `MAX_QUEUE` (default `1000`), `QUEUE_CONCURRENCY` (default `4`).
51+
- `RATE_LIMIT`: requests/minute per IP or API key (default `120`); `BUNDLER_API_KEYS` as comma-separated list enables
52+
auth.
53+
- `GAS_BUMP` (percent, default `15`), `GAS_BUMP_WEI` (absolute bump), `SUBMISSION_TIMEOUT_MS` (default `45000`),
54+
`FINALITY_BLOCKS` (default `2`).
55+
- `VALIDATION_ENABLED`: run `simulateValidation` before enqueue (defaults to `true` in testnet mode).
56+
- `ENABLE_PASSKEY_HELPERS`: enable `passkey_*` helper RPC methods (defaults to `false` in testnet mode).
57+
- `PREFUND_ENABLED` (default `true` in dev, `false` in testnet), `MAX_PREFUND_WEI` (default `5e18`),
58+
`PREFUND_ALLOWLIST` (comma-separated sender addresses; required if `PREFUND_ENABLED=true` in testnet mode).
59+
- `RECEIPT_LIMIT` (default `1000`), `RECEIPT_POLL_INTERVAL_MS` (default `5000`).
60+
61+
Health: `GET /healthz` (process + RPC reachability), `GET /readyz` (RPC synced, signer nonce). Metrics: `GET /metrics`
62+
Prometheus text.
63+
64+
## Notes
65+
- Queue and receipt storage are in-memory for now; the `BundlerStore` interface is intentionally pluggable for
66+
SQLite/Postgres backends in a follow-up.
67+
- Rate limiting and API keys are best-effort protections; front a TLS terminator/reverse proxy in production.

0 commit comments

Comments
 (0)