diff --git a/.github/workflows/passkey-bundler-docker.yml b/.github/workflows/passkey-bundler-docker.yml new file mode 100644 index 000000000..94457c86f --- /dev/null +++ b/.github/workflows/passkey-bundler-docker.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ad3a2e40..db04e7f9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: push: tags: - "*" + tags-ignore: + - "passkey-bundler/v*" permissions: contents: write @@ -289,4 +291,4 @@ jobs: body: ${{ steps.changes.outputs.changes }} files: | dist/**/*.tar.gz - dist/nibid_${{ needs.version.outputs.value }}_checksums.txt \ No newline at end of file + dist/nibid_${{ needs.version.outputs.value }}_checksums.txt diff --git a/.gitignore b/.gitignore index 896802167..79d048a35 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,4 @@ debug_container.dot AGENTS.md precompile.test tx_log.json +passkey-bundler/.tmp diff --git a/passkey-bundler/Dockerfile b/passkey-bundler/Dockerfile index b19779046..29e48f1eb 100644 --- a/passkey-bundler/Dockerfile +++ b/passkey-bundler/Dockerfile @@ -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 ./ diff --git a/passkey-bundler/src/config.ts b/passkey-bundler/src/config.ts index cac609ae7..ca1348b43 100644 --- a/passkey-bundler/src/config.ts +++ b/passkey-bundler/src/config.ts @@ -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 + 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) diff --git a/passkey-bundler/test/basic.test.ts b/passkey-bundler/test/basic.test.ts index 76b6fa10f..a4e4d395f 100644 --- a/passkey-bundler/test/basic.test.ts +++ b/passkey-bundler/test/basic.test.ts @@ -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(updates: Record, fn: () => T): T { + const previous: Record = {} + 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( + updates: Partial>, + fn: () => T, +): T { + const reset: Record = {} + 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) @@ -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/, + ) + }, + ) +}) + +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) +})