Skip to content
Open
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
161 changes: 161 additions & 0 deletions .github/workflows/passkey-bundler-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
name: Passkey Bundler Docker

on:
push:
branches:
- main
tags:
- "passkey-bundler/v*"
workflow_dispatch:

permissions:
contents: read
packages: write

concurrency:
group: passkey-bundler-docker-${{ github.ref }}
cancel-in-progress: true

jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
bundler: ${{ steps.determine.outputs.bundler }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Determine whether to build
id: determine
run: |
set -euo pipefail

if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
echo "bundler=true" >> "$GITHUB_OUTPUT"
exit 0
fi

if [[ "${GITHUB_REF}" == refs/tags/passkey-bundler/v* ]]; then
echo "bundler=true" >> "$GITHUB_OUTPUT"
exit 0
fi

before="${{ github.event.before }}"
if [[ -z "${before}" || "${before}" == "0000000000000000000000000000000000000000" ]]; then
echo "bundler=true" >> "$GITHUB_OUTPUT"
exit 0
fi

if git diff --name-only "${before}" "${GITHUB_SHA}" -- "passkey-bundler" ".github/workflows/passkey-bundler-docker.yml" | grep -q .; then
echo "bundler=true" >> "$GITHUB_OUTPUT"
exit 0
fi

echo "bundler=false" >> "$GITHUB_OUTPUT"

build:
name: Build (${{ matrix.build.platform }})
needs: [changes]
if: |
github.event_name == 'workflow_dispatch' ||
startsWith(github.ref, 'refs/tags/passkey-bundler/v') ||
needs.changes.outputs.bundler == 'true'
strategy:
fail-fast: true
matrix:
build:
- platform: linux/amd64
runner: ubuntu-22.04
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.build.runner }}

steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Prepare env vars
run: |
ARCH="$(echo "${{ matrix.build.platform }}" | cut -d '/' -f 2)"
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
echo "SHORT_SHA=${GITHUB_SHA::7}" >> "$GITHUB_ENV"
OWNER="${{ github.repository_owner }}"
OWNER="${OWNER,,}"
echo "IMAGE=ghcr.io/${OWNER}/passkey-bundler" >> "$GITHUB_ENV"
if [[ "${GITHUB_REF}" == refs/tags/passkey-bundler/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/passkey-bundler/v}"
echo "TAG_PREFIX=$VERSION" >> "$GITHUB_ENV"
else
echo "TAG_PREFIX=main" >> "$GITHUB_ENV"
fi

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push (arch)
uses: docker/build-push-action@v6
with:
context: passkey-bundler
push: true
platforms: ${{ matrix.build.platform }}
tags: ${{ env.IMAGE }}:${{ env.TAG_PREFIX }}-${{ env.ARCH }}

merge:
name: Create multi-arch image
needs: [build]
runs-on: ubuntu-latest

steps:
- name: Prepare env vars
run: |
echo "SHORT_SHA=${GITHUB_SHA::7}" >> "$GITHUB_ENV"
OWNER="${{ github.repository_owner }}"
OWNER="${OWNER,,}"
echo "IMAGE=ghcr.io/${OWNER}/passkey-bundler" >> "$GITHUB_ENV"
if [[ "${GITHUB_REF}" == refs/tags/passkey-bundler/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/passkey-bundler/v}"
echo "TAG_PREFIX=$VERSION" >> "$GITHUB_ENV"
echo "IS_RELEASE=true" >> "$GITHUB_ENV"
else
echo "TAG_PREFIX=main" >> "$GITHUB_ENV"
echo "IS_RELEASE=false" >> "$GITHUB_ENV"
fi

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Create and push manifest list
run: |
set -euo pipefail
if [[ "${IS_RELEASE}" == "true" ]]; then
docker buildx imagetools create \
--tag "${IMAGE}:${TAG_PREFIX}" \
--tag "${IMAGE}:latest" \
"${IMAGE}:${TAG_PREFIX}-amd64" \
"${IMAGE}:${TAG_PREFIX}-arm64"
else
docker buildx imagetools create \
--tag "${IMAGE}:main" \
--tag "${IMAGE}:sha-${SHORT_SHA}" \
"${IMAGE}:main-amd64" \
"${IMAGE}:main-arm64"
fi
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
tags:
- "*"
tags-ignore:
- "passkey-bundler/v*"

permissions:
contents: write
Expand Down Expand Up @@ -289,4 +291,4 @@ jobs:
body: ${{ steps.changes.outputs.changes }}
files: |
dist/**/*.tar.gz
dist/nibid_${{ needs.version.outputs.value }}_checksums.txt
dist/nibid_${{ needs.version.outputs.value }}_checksums.txt
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,4 @@ debug_container.dot
AGENTS.md
precompile.test
tx_log.json
passkey-bundler/.tmp
4 changes: 2 additions & 2 deletions passkey-bundler/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# syntax=docker/dockerfile:1

FROM node:20-alpine AS build
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package*.json ./
Expand Down
9 changes: 8 additions & 1 deletion passkey-bundler/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ export function loadConfig(): BundlerConfig {
}

function envOverrides(): RawConfig {
const toBool = (value?: string) => (value ? ["1", "true", "yes"].includes(value.toLowerCase()) : undefined)
const toBool = (value?: string) => {
if (value === undefined) return undefined
const normalized = value.trim().toLowerCase()
if (!normalized) return undefined

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This check for an empty normalized string is redundant. If normalized is an empty string, it won't be found in either of the subsequent includes() checks, and the function will correctly return undefined at the end. Removing this line makes the function more concise without changing its behavior.

if (["1", "true", "yes", "y", "on"].includes(normalized)) return true
if (["0", "false", "no", "n", "off"].includes(normalized)) return false
return undefined
}
const toBigInt = (value?: string) => (value ? BigInt(value) : undefined)
const toInt = (value?: string) => (value ? Number.parseInt(value, 10) : undefined)

Expand Down
104 changes: 104 additions & 0 deletions passkey-bundler/test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,78 @@ import os from "node:os"
import path from "node:path"
import { test } from "node:test"

import { loadConfig } from "../src/config"
import { RateLimiter } from "../src/rateLimiter"
import { SqliteStore } from "../src/store"
import { requiredPrefund } from "../src/userop"
import { NonceManager } from "../src/submission"
import { calldataGasCost, estimatePreVerificationGas } from "../src/validation"

function withEnv<T>(updates: Record<string, string | undefined>, fn: () => T): T {
const previous: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(updates)) {
previous[key] = process.env[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}

try {
return fn()
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
}

const bundlerEnvKeys = [
"BUNDLER_CONFIG",
"BUNDLER_MODE",
"RPC_URL",
"JSON_RPC_ENDPOINT",
"ENTRY_POINT",
"CHAIN_ID",
"BUNDLER_PRIVATE_KEY",
"BENEFICIARY",
"BUNDLER_PORT",
"METRICS_PORT",
"MAX_BODY_BYTES",
"BUNDLER_REQUIRE_AUTH",
"RATE_LIMIT",
"BUNDLER_API_KEYS",
"MAX_QUEUE",
"QUEUE_CONCURRENCY",
"LOG_LEVEL",
"ENABLE_PASSKEY_HELPERS",
"DB_URL",
"VALIDATION_ENABLED",
"GAS_BUMP",
"GAS_BUMP_WEI",
"PREFUND_ENABLED",
"MAX_PREFUND_WEI",
"PREFUND_ALLOWLIST",
"SUBMISSION_TIMEOUT_MS",
"FINALITY_BLOCKS",
"RECEIPT_LIMIT",
"RECEIPT_POLL_INTERVAL_MS",
] as const

function withBundlerEnv<T>(
updates: Partial<Record<(typeof bundlerEnvKeys)[number], string | undefined>>,
fn: () => T,
): T {
const reset: Record<string, string | undefined> = {}
for (const key of bundlerEnvKeys) reset[key] = undefined
return withEnv({ ...reset, ...updates }, fn)
}

test("rate limiter enforces window", () => {
const limiter = new RateLimiter(2, 1000)
assert.equal(limiter.allow("a"), true)
Expand Down Expand Up @@ -148,3 +214,41 @@ test("sqlite store persists userOp records and receipts", async () => {

fs.rmSync(dir, { recursive: true, force: true })
})

test("testnet mode defaults authRequired=true and requires API keys", () => {
const entryPoint = "0x" + "11".repeat(20)
const privateKey = "0x" + "22".repeat(32)

withBundlerEnv(
{
BUNDLER_MODE: "testnet",
ENTRY_POINT: entryPoint,
BUNDLER_PRIVATE_KEY: privateKey,
DB_URL: "sqlite:./data/bundler.sqlite",
},
() => {
assert.throws(
() => loadConfig(),
/Testnet mode requires API keys \(set BUNDLER_API_KEYS\) or disable authRequired/,
)
},
)
})
Comment on lines +218 to +236

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test writes to a hardcoded database path (sqlite:./data/bundler.sqlite), which can cause side effects and leave artifacts after test runs. To ensure test isolation and cleanliness, it's best practice to use a temporary directory for such files and clean it up afterwards, similar to how other tests in this file are structured.

test("testnet mode defaults authRequired=true and requires API keys", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "passkey-bundler-"))
  try {
    const entryPoint = "0x" + "11".repeat(20)
    const privateKey = "0x" + "22".repeat(32)

    withBundlerEnv(
      {
        BUNDLER_MODE: "testnet",
        ENTRY_POINT: entryPoint,
        BUNDLER_PRIVATE_KEY: privateKey,
        DB_URL: `sqlite:${path.join(dir, "bundler.sqlite")}`,
      },
      () => {
        assert.throws(
          () => loadConfig(),
          /Testnet mode requires API keys \(set BUNDLER_API_KEYS\) or disable authRequired/,
        )
      },
    )
  } finally {
    fs.rmSync(dir, { recursive: true, force: true })
  }
})


test("testnet mode respects BUNDLER_REQUIRE_AUTH=false", () => {
const entryPoint = "0x" + "11".repeat(20)
const privateKey = "0x" + "22".repeat(32)

const config = withBundlerEnv(
{
BUNDLER_MODE: "testnet",
ENTRY_POINT: entryPoint,
BUNDLER_PRIVATE_KEY: privateKey,
DB_URL: "sqlite:./data/bundler.sqlite",
BUNDLER_REQUIRE_AUTH: "false",
},
() => loadConfig(),
)

assert.equal(config.authRequired, false)
})
Comment on lines +238 to +254

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the previous test, this test writes to a hardcoded database path. This should be updated to use a temporary directory to ensure test isolation and prevent leaving artifacts.

test("testnet mode respects BUNDLER_REQUIRE_AUTH=false", () => {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "passkey-bundler-"))
  try {
    const entryPoint = "0x" + "11".repeat(20)
    const privateKey = "0x" + "22".repeat(32)

    const config = withBundlerEnv(
      {
        BUNDLER_MODE: "testnet",
        ENTRY_POINT: entryPoint,
        BUNDLER_PRIVATE_KEY: privateKey,
        DB_URL: `sqlite:${path.join(dir, "bundler.sqlite")}`,
        BUNDLER_REQUIRE_AUTH: "false",
      },
      () => loadConfig(),
    )

    assert.equal(config.authRequired, false)
  } finally {
    fs.rmSync(dir, { recursive: true, force: true })
  }
})