From f26275c3d7607cf5d7021758d6900b4ca0d3e0bc Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:59:41 -0500 Subject: [PATCH 1/5] fix: verify signature before consuming edit nonce; rate-limit on proven wallet The wallet rate limit was keyed on a client-supplied wallet string before the signature or creator check, so anyone who knows a creator's public address could exhaust their edit/nonce budget with 10 unauthenticated POSTs. The nonce was also consumed (UPDATE used_at) before verifyMessage, and RPC errors were caught as invalid signatures, so a flaky provider burned the nonce and returned 401, preventing retry with the same nonce. Move the wallet rate limit inside applySignedEdit and issueNonce, after the signature/creator check. Move verifyMessage before the nonce UPDATE. RPC errors now return 503 instead of 401. Wrap the nonce consume and metadata INSERT in a transaction so a write failure rolls the nonce back. --- app/src/app/api/launch/edit/nonce/route.ts | 2 +- app/src/app/api/launch/edit/route.ts | 1 - app/src/lib/launchpad/editServer.ts | 52 +++++++++++++++------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/app/src/app/api/launch/edit/nonce/route.ts b/app/src/app/api/launch/edit/nonce/route.ts index 74fe12c7..dc0f1447 100644 --- a/app/src/app/api/launch/edit/nonce/route.ts +++ b/app/src/app/api/launch/edit/nonce/route.ts @@ -16,8 +16,8 @@ export async function POST(req: Request) { return NextResponse.json({ error: "bad json" }, { status: 400 }); } if (!isChainKey(b.chain) || typeof b.token !== "string" || !isAddress(b.token) || typeof b.wallet !== "string" || !isAddress(b.wallet)) return NextResponse.json({ error: "bad params" }, { status: 400 }); - if (rateLimited(`nonce:wallet:${b.wallet.toLowerCase()}`, 10)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const n = await issueNonce(b.chain, b.token, b.wallet); if (!n) return NextResponse.json({ error: "not the creator" }, { status: 403 }); + if ("ok" in n && !n.ok) return NextResponse.json({ error: n.error }, { status: n.status }); return NextResponse.json(n, { headers: { "cache-control": "no-store" } }); } diff --git a/app/src/app/api/launch/edit/route.ts b/app/src/app/api/launch/edit/route.ts index 00c07ddb..830141b3 100644 --- a/app/src/app/api/launch/edit/route.ts +++ b/app/src/app/api/launch/edit/route.ts @@ -15,7 +15,6 @@ export async function POST(req: Request) { return NextResponse.json({ error: "bad json" }, { status: 400 }); } if (!isChainKey(b.chain) || typeof b.token !== "string" || typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); - if (rateLimited(`edit:wallet:${b.wallet.toLowerCase()}`, 10)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const r = await applySignedEdit({ chain: b.chain, token: b.token, wallet: b.wallet, nonce: b.nonce, expiresAt: b.expiresAt, signature: b.signature, fields: (b.fields ?? {}) as Record }); if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); return NextResponse.json({ ok: true }); diff --git a/app/src/lib/launchpad/editServer.ts b/app/src/lib/launchpad/editServer.ts index 32ddaf16..58755390 100644 --- a/app/src/lib/launchpad/editServer.ts +++ b/app/src/lib/launchpad/editServer.ts @@ -26,11 +26,15 @@ export function rateLimited(key: string, max = 10, windowMs = 60_000): boolean { return b.n > max; } -export async function issueNonce(chain: ChainKey, token: string, wallet: string): Promise<{ nonce: string; expiresAt: number } | null> { +export async function issueNonce(chain: ChainKey, token: string, wallet: string): Promise<{ nonce: string; expiresAt: number } | { ok: false; error: string; status: number } | null> { const db = maybeDb(); if (!db) return null; const launcher = await db<{ launcher: string }[]>`SELECT launcher FROM bb_launches WHERE chain_id = ${chainIdOf(chain)} AND token = ${token.toLowerCase()}`; if (!launcher[0] || launcher[0].launcher !== wallet.toLowerCase()) return null; // only the creator gets a nonce + // wallet rate limit is applied AFTER the creator check, so an attacker + // cannot freeze a creator's nonce budget by sending requests with the + // creator's public address (the wallet is proven before the bucket is spent) + if (rateLimited(`nonce:wallet:${wallet.toLowerCase()}`, 10)) return { ok: false, error: "slow down", status: 429 }; const nonce = randomBytes(16).toString("hex"); const expiresAt = Date.now() + EDIT_TTL_MS; await db`INSERT INTO bb_edit_nonces (nonce, chain_id, token, wallet, expires_at) VALUES (${nonce}, ${chainIdOf(chain)}, ${token.toLowerCase()}, ${wallet.toLowerCase()}, ${new Date(expiresAt).toISOString()})`; @@ -54,30 +58,46 @@ export async function applySignedEdit(r: EditRequest): Promise<{ ok: true } | { const token = r.token.toLowerCase(); const wallet = r.wallet.toLowerCase(); - // nonce: exists, matches, unexpired, unused — consumed atomically - const consumed = await db<{ nonce: string }[]>` - UPDATE bb_edit_nonces SET used_at = now() - WHERE nonce = ${r.nonce} AND chain_id = ${cid} AND token = ${token} AND wallet = ${wallet} AND used_at IS NULL AND expires_at > now() - AND expires_at = ${new Date(expiresAt).toISOString()} - RETURNING nonce`; - if (consumed.length === 0) return { ok: false, error: "nonce invalid or already used", status: 401 }; - - // signer must be the on-chain launcher + // signer must be the on-chain launcher (checked before the nonce is spent, + // so a non-creator does not burn a nonce they should not have) const row = await db<{ launcher: string; name: string; symbol: string }[]>`SELECT launcher, name, symbol FROM bb_launches WHERE chain_id = ${cid} AND token = ${token}`; if (!row[0] || row[0].launcher !== wallet) return { ok: false, error: "not the creator", status: 403 }; + // verify the signature before consuming the nonce: an RPC blip or a bad + // signature must not burn the nonce (otherwise the creator cannot retry + // with the same nonce and, combined with the wallet rate limit, can be + // locked out of edits entirely) const message = buildEditMessage({ chain: r.chain, token, wallet, nonce: r.nonce, expiresAt, fields: v.value }); let valid = false; try { valid = await publicClient(r.chain).verifyMessage({ address: wallet as Address, message, signature: r.signature as Hex }); } catch { - valid = false; + return { ok: false, error: "signature verification unavailable, try again", status: 503 }; } if (!valid) return { ok: false, error: "signature does not match", status: 401 }; - await db` - INSERT INTO bb_launch_meta (chain_id, token, launcher, name, symbol, description, image_url, website, x_handle, updated_at) - VALUES (${cid}, ${token}, ${wallet}, ${row[0].name}, ${row[0].symbol}, ${v.value.description || null}, ${v.value.image_url || null}, ${v.value.website || null}, ${v.value.x_handle || null}, now()) - ON CONFLICT (chain_id, token) DO UPDATE SET description = EXCLUDED.description, image_url = EXCLUDED.image_url, website = EXCLUDED.website, x_handle = EXCLUDED.x_handle, updated_at = now()`; - return { ok: true }; + // wallet rate limit is applied AFTER the signature is verified, so an + // attacker cannot freeze a creator's edit budget by sending requests with + // the creator's public address (the wallet is proven before the bucket is spent) + if (rateLimited(`edit:wallet:${wallet}`, 10)) return { ok: false, error: "slow down", status: 429 }; + + // nonce: exists, matches, unexpired, unused — consumed atomically, only + // after the signature is proven, inside a transaction with the metadata + // write so a failed INSERT rolls the nonce back + const result = await db.begin(async (tx) => { + const t = tx as unknown as NonNullable>; + const nonce = r.nonce as string; + const consumed = await t<{ nonce: string }[]>` + UPDATE bb_edit_nonces SET used_at = now() + WHERE nonce = ${nonce} AND chain_id = ${cid} AND token = ${token} AND wallet = ${wallet} AND used_at IS NULL AND expires_at > now() + AND expires_at = ${new Date(expiresAt).toISOString()} + RETURNING nonce`; + if (consumed.length === 0) return { ok: false as const, error: "nonce invalid or already used", status: 401 }; + await t` + INSERT INTO bb_launch_meta (chain_id, token, launcher, name, symbol, description, image_url, website, x_handle, updated_at) + VALUES (${cid}, ${token}, ${wallet}, ${row[0].name}, ${row[0].symbol}, ${v.value.description || null}, ${v.value.image_url || null}, ${v.value.website || null}, ${v.value.x_handle || null}, now()) + ON CONFLICT (chain_id, token) DO UPDATE SET description = EXCLUDED.description, image_url = EXCLUDED.image_url, website = EXCLUDED.website, x_handle = EXCLUDED.x_handle, updated_at = now()`; + return { ok: true as const }; + }); + return result; } From 366742464efba3a0502405d4a9c165d32242a2e6 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:59:50 -0500 Subject: [PATCH 2/5] test: add regression tests for edit nonce ordering and wallet rate-limit DoS Adds editServer.test.ts with 8 tests covering: RPC error returns 503 with nonce intact, invalid signature returns 401 with nonce intact, valid signature consumes nonce and writes metadata, already-used nonce rejected, rate-limit DoS mechanism, cross-wallet isolation, 10 invalid-sig requests do not freeze the wallet, non-creator nonce requests do not spend the wallet bucket. Test infrastructure: tsx dev dependency, tsconfig.test.json for path-alias mocking of @/lib/db and @/lib/chain, db-mock.ts and chain-mock.ts for mocked DB and RPC, test-loader.mjs for @/ path resolution. The test script uses tsx with react-server conditions; test:unit preserves the original node-only runner for pure-logic tests. --- app/eslint.config.mjs | 2 +- app/package-lock.json | 519 +++++++++++++++++++++++ app/package.json | 4 +- app/src/lib/chain-mock.ts | 24 ++ app/src/lib/db-mock.ts | 93 ++++ app/src/lib/launchpad/editServer.test.ts | 179 ++++++++ app/src/test-loader.mjs | 21 + app/tsconfig.test.json | 10 + 8 files changed, 850 insertions(+), 2 deletions(-) create mode 100644 app/src/lib/chain-mock.ts create mode 100644 app/src/lib/db-mock.ts create mode 100644 app/src/lib/launchpad/editServer.test.ts create mode 100644 app/src/test-loader.mjs create mode 100644 app/tsconfig.test.json diff --git a/app/eslint.config.mjs b/app/eslint.config.mjs index 00a957dc..1bb0821e 100644 --- a/app/eslint.config.mjs +++ b/app/eslint.config.mjs @@ -5,5 +5,5 @@ import nextTs from "eslint-config-next/typescript"; export default defineConfig([ ...nextVitals, ...nextTs, - globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "skills/**", "scripts/**"]), + globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "skills/**", "scripts/**", "src/lib/db-mock.ts", "src/lib/chain-mock.ts", "src/test-loader.mjs"]), ]); diff --git a/app/package-lock.json b/app/package-lock.json index 6e352768..9e4cd081 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -32,6 +32,7 @@ "eslint": "^9", "eslint-config-next": "^16.2.9", "tailwindcss": "^4", + "tsx": "^4.23.13", "typescript": "^5" } }, @@ -635,6 +636,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -3814,6 +4257,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4438,6 +4923,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7220,6 +7720,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/app/package.json b/app/package.json index e7ae4806..09df1d11 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", - "test": "node --experimental-strip-types --no-warnings --test \"src/**/*.test.ts\"", + "test": "TEST_MOCK_DB=1 NODE_OPTIONS=--conditions=react-server tsx --tsconfig tsconfig.test.json --test \"src/**/*.test.ts\"", + "test:unit": "node --experimental-strip-types --no-warnings --test \"src/**/*.test.ts\"", "migrate": "node scripts/migrate.mjs" }, "dependencies": { @@ -36,6 +37,7 @@ "eslint": "^9", "eslint-config-next": "^16.2.9", "tailwindcss": "^4", + "tsx": "^4.23.13", "typescript": "^5" }, "overrides": { diff --git a/app/src/lib/chain-mock.ts b/app/src/lib/chain-mock.ts new file mode 100644 index 00000000..60ed5101 --- /dev/null +++ b/app/src/lib/chain-mock.ts @@ -0,0 +1,24 @@ +// Mock @/lib/chain for tests. publicClient returns a fake client whose verifyMessage +// behavior is driven by the global mock state from db-mock.ts. +import type { ChainKey } from "./chainPublic.ts"; +import { getMock } from "./db-mock.ts"; + +export * from "./chainPublic.ts"; + +export function rpcUrl(_key: ChainKey): string | undefined { + return undefined; +} + +export function b20RpcUrl(): string { + return "http://mock"; +} + +export function publicClient(_key: ChainKey): any { + return { + verifyMessage: async (_args: any): Promise => { + const m = getMock(); + if (m.verifyShouldThrow) throw new Error("mock RPC error"); + return m.verifyResult; + }, + }; +} diff --git a/app/src/lib/db-mock.ts b/app/src/lib/db-mock.ts new file mode 100644 index 00000000..edb15b3f --- /dev/null +++ b/app/src/lib/db-mock.ts @@ -0,0 +1,93 @@ +// Mock @/lib/db for tests. Exports maybeDb() returning a fake postgres tagged-template +// function whose behavior is driven by a global mock state set by the test. +import type { Db } from "./db.ts"; + +type MockState = { + nonceRows: Record; + launcherRows: Record; + metaInserted: boolean; + verifyShouldThrow: boolean; + verifyResult: boolean; +}; + +declare global { + // eslint-disable-next-line no-var + var __editMock: MockState | undefined; +} + +export function resetMock() { + globalThis.__editMock = { + nonceRows: {}, + launcherRows: {}, + metaInserted: false, + verifyShouldThrow: false, + verifyResult: true, + }; +} + +export function getMock(): MockState { + if (!globalThis.__editMock) resetMock(); + return globalThis.__editMock!; +} + +// A fake db tagged-template function. It inspects the SQL text to decide what to return. +function fakeDb(strings: TemplateStringsArray, ...values: unknown[]): Promise { + const sql = strings.join("?"); + const m = getMock(); + + // UPDATE bb_edit_nonces SET used_at = now() ... RETURNING nonce + if (sql.includes("UPDATE bb_edit_nonces") && sql.includes("used_at")) { + const nonce = String(values[0]); + const row = m.nonceRows[nonce]; + if (!row || row.used_at !== null) return Promise.resolve([] as any[]); + row.used_at = new Date().toISOString(); + return Promise.resolve([{ nonce }] as any[]); + } + + // launcher lookup (issueNonce and applySignedEdit) + if (sql.includes("FROM bb_launches") && sql.includes("launcher")) { + const token = String(values[1]); + const row = m.launcherRows[token]; + if (!row) return Promise.resolve([] as any[]); + if (sql.includes("name, symbol")) return Promise.resolve([row] as any[]); + return Promise.resolve([{ launcher: row.launcher }] as any[]); + } + + // metadata write + if (sql.includes("bb_launch_meta")) { + m.metaInserted = true; + return Promise.resolve([] as any[]); + } + + // nonce insert (issueNonce) + if (sql.includes("bb_edit_nonces") && sql.startsWith("?") === false && sql.includes("VALUES")) { + return Promise.resolve([] as any[]); + } + + // nonce consume (UPDATE used_at) — handled above by the used_at check + // expired nonce cleanup + if (sql.includes("DELETE") && sql.includes("bb_edit_nonces")) { + return Promise.resolve([] as any[]); + } + + return Promise.resolve([] as any[]); +} + +// Attach properties the real db has (so type checks pass) +// begin() runs the callback with the same fake db (no real transaction, but +// the mock tracks nonce consumption so tests can assert on it) +(fakeDb as any).begin = async (fn: (tx: any) => Promise) => fn(fakeDb); + +export function maybeDb(): any | null { + return fakeDb as any; +} + +export function dbConfigured(): boolean { + return true; +} + +export function db(): any { + return fakeDb as any; +} + +export type { Db }; diff --git a/app/src/lib/launchpad/editServer.test.ts b/app/src/lib/launchpad/editServer.test.ts new file mode 100644 index 00000000..feff3c66 --- /dev/null +++ b/app/src/lib/launchpad/editServer.test.ts @@ -0,0 +1,179 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resetMock, getMock } from "@/lib/db-mock"; +import * as ed from "./editServer.ts"; + +// These tests exercise applySignedEdit with mocked DB and RPC (TEST_MOCK_DB=1 +// swaps @/lib/db and @/lib/chain for mocks). The mock state lives on globalThis. + +const CHAIN = "base"; +const TOKEN = "0x" + "11".repeat(20); +const WALLET = "0x" + "ab".repeat(20); +const NONCE = "a".repeat(32); +const SIG = "0x" + "c".repeat(130); + +function seedNonce(used = false) { + const m = getMock(); + m.nonceRows[NONCE] = { + nonce: NONCE, + used_at: used ? "2026-01-01T00:00:00Z" : null, + wallet: WALLET.toLowerCase(), + token: TOKEN.toLowerCase(), + chain_id: 8453, + expires_at: new Date(Date.now() + 300_000).toISOString(), + }; +} + +function seedLauncher() { + const m = getMock(); + m.launcherRows[TOKEN.toLowerCase()] = { + launcher: WALLET.toLowerCase(), + name: "Test", + symbol: "TST", + }; +} + +function editRequest(overrides: Record = {}) { + return { + chain: CHAIN, + token: TOKEN, + wallet: WALLET, + nonce: NONCE, + expiresAt: Date.now() + 300_000, + signature: SIG, + fields: { description: "hello" }, + ...overrides, + }; +} + +// ── Finding 2: nonce consumed before verifyMessage ── + +test("applySignedEdit: RPC error during verify does NOT consume the nonce (returns 503)", async () => { + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.verifyShouldThrow = true; // simulate RPC blip + m.verifyResult = false; + + const r = await ed.applySignedEdit(editRequest()); + + // BUG (current code): returns 401 "signature does not match" and nonce is consumed. + // FIX (expected): returns 503, nonce stays reusable. + assert.equal(r.ok, false, "should fail"); + assert.equal(r.status, 503, "RPC error should be 503, not 401"); + // The nonce must NOT be consumed + assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must not be consumed on RPC error"); + assert.equal(m.metaInserted, false, "metadata must not be written on RPC error"); +}); + +test("applySignedEdit: invalid signature does NOT consume the nonce (returns 401)", async () => { + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.verifyShouldThrow = false; + m.verifyResult = false; // signature is genuinely invalid + + const r = await ed.applySignedEdit(editRequest()); + + assert.equal(r.ok, false, "should fail"); + assert.equal(r.status, 401, "invalid signature should be 401"); + // The nonce must NOT be consumed when the signature is invalid + assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must not be consumed on invalid signature"); +}); + +test("applySignedEdit: valid signature consumes the nonce and writes metadata", async () => { + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.verifyShouldThrow = false; + m.verifyResult = true; + + const r = await ed.applySignedEdit(editRequest()); + + assert.equal(r.ok, true, "should succeed"); + assert.notEqual(m.nonceRows[NONCE].used_at, null, "nonce should be consumed on success"); + assert.equal(m.metaInserted, true, "metadata should be written on success"); +}); + +test("applySignedEdit: already-used nonce is rejected (401)", async () => { + resetMock(); + seedNonce(true); // already used + seedLauncher(); + const m = getMock(); + m.verifyResult = true; + + const r = await ed.applySignedEdit(editRequest()); + + assert.equal(r.ok, false, "should fail"); + assert.equal(r.status, 401, "reused nonce should be 401"); +}); + +// ── Finding 1: rateLimited keyed on unproven wallet ── + +test("rateLimited: 10 hits on a wallet key 429s the 11th (the DoS mechanism)", () => { + const key = `test:wallet:doz-${Math.random()}`; + for (let i = 0; i < 10; i++) { + assert.equal(ed.rateLimited(key, 10), false, `hit ${i + 1} should be allowed`); + } + assert.equal(ed.rateLimited(key, 10), true, "11th hit should be rate-limited"); +}); + +test("rateLimited: a different wallet key is unaffected (cross-wallet isolation)", () => { + const victim = `test:wallet:vic-${Math.random()}`; + const attacker = `test:wallet:atk-${Math.random()}`; + for (let i = 0; i < 10; i++) ed.rateLimited(victim, 10); + assert.equal(ed.rateLimited(victim, 10), true, "victim frozen"); + assert.equal(ed.rateLimited(attacker, 10), false, "attacker key unaffected"); +}); + +// ── Finding 1: wallet rate limit must not be spent before the signature is verified ── + +test("applySignedEdit: 10 requests with an invalid signature do NOT freeze the wallet (rate limit only after verify)", async () => { + // An attacker sends 10 edit requests with wallet=victim but a bad signature. + // Before the fix, the route spent the wallet bucket before applySignedEdit, + // so the victim's next real edit got 429. After the fix, the wallet bucket + // is only spent after the signature is verified, so the victim is unaffected. + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.verifyShouldThrow = false; + m.verifyResult = false; // invalid signature + + for (let i = 0; i < 10; i++) { + const r = await ed.applySignedEdit(editRequest()); + assert.equal(r.ok, false, `attempt ${i + 1} should fail`); + assert.equal(r.status, 401, `attempt ${i + 1} should be 401 (invalid sig)`); + } + + // The nonce must still be unused (verify failed, so nonce was not consumed) + assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must not be consumed after 10 invalid-sig attempts"); + + // Now the real creator sends a valid signed edit — it must NOT be rate-limited + m.verifyResult = true; + const r = await ed.applySignedEdit(editRequest()); + assert.equal(r.ok, true, "creator's valid edit must succeed (wallet bucket not frozen by attacker)"); + assert.equal(m.metaInserted, true, "metadata should be written"); +}); + +test("issueNonce: non-creator requests do NOT spend the wallet rate limit", async () => { + // An attacker asks for a nonce with wallet=victim but is not the creator. + // Before the fix, the route spent the wallet bucket before issueNonce, + // so the victim's next real nonce request got 429. After the fix, the + // wallet bucket is only spent after the creator check passes. + resetMock(); + // No launcher row seeded → issueNonce returns null (not the creator) + + for (let i = 0; i < 10; i++) { + const n = await ed.issueNonce(CHAIN, TOKEN, WALLET); + assert.equal(n, null, `attempt ${i + 1} by non-creator should return null`); + } + + // Now seed the launcher and verify the real creator can still get a nonce + seedLauncher(); + const n = await ed.issueNonce(CHAIN, TOKEN, WALLET); + assert.ok(n && "nonce" in n, "creator must still be able to get a nonce (bucket not frozen)"); +}); diff --git a/app/src/test-loader.mjs b/app/src/test-loader.mjs new file mode 100644 index 00000000..a66469a2 --- /dev/null +++ b/app/src/test-loader.mjs @@ -0,0 +1,21 @@ +// Test loader that: +// 1. Maps @/* tsconfig path alias to ./src/* +// 2. Replaces @/lib/db and @/lib/chain with mocks when TEST_MOCK_DB=1 +// 3. Adds react-server condition so server-only resolves to empty.js +import { fileURLToPath } from "node:url"; +const root = fileURLToPath(new URL("./", import.meta.url)); +const mockDb = process.env.TEST_MOCK_DB === "1"; + +export async function resolve(specifier, context, next) { + if (typeof specifier === "string" && specifier.startsWith("@/")) { + const rel = specifier.slice(2); + if (mockDb && (rel === "lib/db" || rel === "lib/db.ts")) { + return { url: new URL("./src/lib/db-mock.ts", root).href, shortCircuit: true }; + } + if (mockDb && (rel === "lib/chain" || rel === "lib/chain.ts")) { + return { url: new URL("./src/lib/chain-mock.ts", root).href, shortCircuit: true }; + } + return next(new URL("./src/" + rel, root).href, context); + } + return next(specifier, context); +} diff --git a/app/tsconfig.test.json b/app/tsconfig.test.json new file mode 100644 index 00000000..7b8b18ac --- /dev/null +++ b/app/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "@/lib/db": ["./src/lib/db-mock.ts"], + "@/lib/chain": ["./src/lib/chain-mock.ts"] + } + } +} From c01409e376c7bf0b758a93210a76b040443b27a3 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:47:04 -0500 Subject: [PATCH 3/5] test: add e2e test for edit nonce/rate-limit fix Hits the real Next.js dev server with a real Postgres DB to verify the fix end-to-end. Covers: non-creator rejection, creator nonce issuance, 10 non-creator requests not freezing the creator's budget (finding 1), invalid signature not burning the nonce (finding 2), valid signature consuming the nonce and writing metadata, nonce reuse rejection, 10 invalid-sig edit requests not freezing the creator's edit budget (finding 1), non-creator edit rejection, and IP-keyed rate limiting. RED-GREEN verified: 3 tests fail on the original code for the right reasons (nonce burned on invalid sig, wallet frozen by attacker, wallet bucket spent before creator check), all 17 pass after the fix. --- app/scripts/e2e-edit-fix.mjs | 259 +++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 app/scripts/e2e-edit-fix.mjs diff --git a/app/scripts/e2e-edit-fix.mjs b/app/scripts/e2e-edit-fix.mjs new file mode 100644 index 00000000..5de772c1 --- /dev/null +++ b/app/scripts/e2e-edit-fix.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node +/** + * E2E test for the edit nonce/rate-limit fix (findings 1 and 2). + * Hits the real Next.js dev server at http://localhost:3000 with a real Postgres DB. + * Run: node --import tsx/esm --conditions react-server --no-warnings scripts/e2e-edit-fix.mjs + * + * Prerequisites: + * - Next.js dev server running on :3000 with DATABASE_URL pointing at a test DB + * - A bb_launches row for token 0x11...11 with launcher = TEST_WALLET + * - BASE_RPC_URL reachable (for signature verification) + */ +import { privateKeyToAccount } from "viem/accounts"; +import { buildEditMessage, validateEdit } from "../src/lib/launchpad/editAuth.ts"; + +const BASE = "http://localhost:3000"; +const TEST_PRIVATE_KEY = "0xa92669a8ef46ba60f3327b15ba8d737b562c7260d445fa2e5d8b43c577358a45"; +const TEST_WALLET = privateKeyToAccount(TEST_PRIVATE_KEY).address; +const TOKEN = "0x1111111111111111111111111111111111111111"; +const ATTACKER_WALLET = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +let pass = 0, fail = 0; +const failures = []; + +function check(name, cond, detail = "") { + if (cond) { + pass++; + console.log(` ok - ${name}`); + } else { + fail++; + failures.push(name); + console.log(` FAIL - ${name}${detail ? " :: " + detail : ""}`); + } +} + +async function post(path, body, headers = {}) { + const res = await fetch(`${BASE}${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json; + try { json = JSON.parse(text); } catch { json = null; } + return { status: res.status, json, text }; +} + +async function getNonce(wallet = TEST_WALLET) { + const r = await post("/api/launch/edit/nonce", { chain: "base", token: TOKEN, wallet }); + return r; +} + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Build the signed message the same way the server does: validateEdit normalizes +// fields (e.g. URL trailing slash), then buildEditMessage uses the normalized +// values. Signing with raw fields would mismatch after normalization. +function serverMessage({ nonce, expiresAt, fields }) { + const v = validateEdit(fields); + if (!v.ok) throw new Error(`invalid fields: ${v.error}`); + return buildEditMessage({ chain: "base", token: TOKEN, wallet: TEST_WALLET, nonce, expiresAt, fields: v.value }); +} + +async function signEdit({ nonce, expiresAt, fields }) { + const account = privateKeyToAccount(TEST_PRIVATE_KEY); + const message = serverMessage({ nonce, expiresAt, fields }); + return account.signMessage({ message }); +} + +// ── 1. Non-creator cannot get a nonce ── +console.log("\n[1] Non-creator cannot get a nonce"); +{ + const r = await getNonce(ATTACKER_WALLET); + check("non-creator gets 403", r.status === 403, `got ${r.status}`); + check("non-creator gets 'not the creator'", r.json?.error === "not the creator", `got ${r.json?.error}`); +} + +// ── 2. Creator can get a nonce ── +console.log("\n[2] Creator can get a nonce"); +let nonce, expiresAt; +{ + const r = await getNonce(); + check("creator gets 200", r.status === 200, `got ${r.status}`); + check("creator gets a nonce", typeof r.json?.nonce === "string" && r.json.nonce.length === 32, `got ${r.json?.nonce}`); + check("creator gets expiresAt", typeof r.json?.expiresAt === "number", `got ${r.json?.expiresAt}`); + nonce = r.json?.nonce; + expiresAt = r.json?.expiresAt; +} + +// ── 3. Finding 1: 10 non-creator nonce requests do NOT freeze the creator ── +console.log("\n[3] Finding 1: 10 non-creator nonce requests do not freeze the creator's budget"); +{ + // Attacker tries to freeze the creator's nonce budget by sending 10 requests + // with the creator's wallet but wrong token (or as non-creator). + // Before the fix, the route spent the wallet bucket before the creator check. + for (let i = 0; i < 10; i++) { + await post("/api/launch/edit/nonce", { chain: "base", token: TOKEN, wallet: ATTACKER_WALLET }); + } + // The creator must still be able to get a nonce + const r = await getNonce(); + check("creator still gets a nonce after 10 attacker requests", r.status === 200 && typeof r.json?.nonce === "string", `got ${r.status} ${r.json?.error}`); +} + +// ── 4. Finding 2: invalid signature does NOT burn the nonce ── +console.log("\n[4] Finding 2: invalid signature does not burn the nonce (returns 401, nonce reusable)"); +{ + const r = await getNonce(); + check("got a fresh nonce for invalid-sig test", r.status === 200, `got ${r.status}`); + const n = r.json.nonce; + const exp = r.json.expiresAt; + + // Submit an edit with a bogus signature (valid hex, wrong sig) + const badSig = "0x" + "d".repeat(130); + const edit = await post("/api/launch/edit", { + chain: "base", + token: TOKEN, + wallet: TEST_WALLET, + nonce: n, + expiresAt: exp, + signature: badSig, + fields: { description: "test edit" }, + }); + check("invalid signature gets 401", edit.status === 401, `got ${edit.status}`); + check("invalid signature error is 'signature does not match'", edit.json?.error === "signature does not match", `got ${edit.json?.error}`); + + // The nonce must still be usable: submit with the CORRECT signature + const goodSig = await signEdit({ nonce: n, expiresAt: exp, fields: { description: "real edit" } }); + const edit2 = await post("/api/launch/edit", { + chain: "base", + token: TOKEN, + wallet: TEST_WALLET, + nonce: n, + expiresAt: exp, + signature: goodSig, + fields: { description: "real edit" }, + }); + check("same nonce works with valid signature (nonce was not burned)", edit2.status === 200, `got ${edit2.status} ${edit2.json?.error}`); +} + +// ── 5. Valid signature consumes the nonce and writes metadata ── +console.log("\n[5] Valid signature consumes the nonce and writes metadata"); +{ + const r = await getNonce(); + const n = r.json.nonce; + const exp = r.json.expiresAt; + + const sig = await signEdit({ nonce: n, expiresAt: exp, fields: { description: "hello e2e", website: "https://example.com" } }); + const edit = await post("/api/launch/edit", { + chain: "base", + token: TOKEN, + wallet: TEST_WALLET, + nonce: n, + expiresAt: exp, + signature: sig, + fields: { description: "hello e2e", website: "https://example.com" }, + }); + check("valid edit gets 200", edit.status === 200, `got ${edit.status} ${edit.json?.error}`); + + // Reuse the nonce with the SAME fields and signature (so the signature + // still verifies) → the nonce consume must fail because it's already used + const reuse = await post("/api/launch/edit", { + chain: "base", + token: TOKEN, + wallet: TEST_WALLET, + nonce: n, + expiresAt: exp, + signature: sig, + fields: { description: "hello e2e", website: "https://example.com" }, + }); + check("reused nonce is rejected (401)", reuse.status === 401, `got ${reuse.status} ${reuse.json?.error}`); + check("reused nonce error mentions invalid or already used", /invalid or already used/.test(reuse.json?.error ?? ""), `got ${reuse.json?.error}`); +} + +// ── 6. Finding 1 (edit side): 10 invalid-sig edit requests do NOT freeze the creator ── +console.log("\n[6] Finding 1: 10 invalid-sig edit requests do not freeze the creator's edit budget"); +{ + // Get a nonce (the creator's budget should not be frozen by the attacker) + const r = await getNonce(); + check("creator can get a nonce for the edit-DoS test", r.status === 200, `got ${r.status}`); + const n = r.json.nonce; + const exp = r.json.expiresAt; + + // Attacker sends 10 edit requests with the creator's wallet but bad signatures. + // Before the fix, the route spent the wallet bucket before applySignedEdit, + // so the creator's next real edit got 429. + const badSig = "0x" + "e".repeat(130); + for (let i = 0; i < 10; i++) { + await post("/api/launch/edit", { + chain: "base", + token: TOKEN, + wallet: TEST_WALLET, + nonce: n, + expiresAt: exp, + signature: badSig, + fields: { description: "attacker" }, + }); + } + + // The creator's valid edit must NOT be rate-limited + const sig = await signEdit({ nonce: n, expiresAt: exp, fields: { description: "creator real edit" } }); + const edit = await post("/api/launch/edit", { + chain: "base", + token: TOKEN, + wallet: TEST_WALLET, + nonce: n, + expiresAt: exp, + signature: sig, + fields: { description: "creator real edit" }, + }); + check("creator's valid edit succeeds after 10 attacker attempts (not 429)", edit.status === 200, `got ${edit.status} ${edit.json?.error}`); +} + +// ── 7. Non-creator cannot edit (even with a valid signature from someone else) ── +console.log("\n[7] Non-creator cannot edit"); +{ + const r = await getNonce(); + // This should fail because the nonce was issued to TEST_WALLET, but + // even if we craft a request with ATTACKER_WALLET, the launcher check fails. + const edit = await post("/api/launch/edit", { + chain: "base", + token: TOKEN, + wallet: ATTACKER_WALLET, + nonce: r.json.nonce, + expiresAt: r.json.expiresAt, + signature: "0x" + "f".repeat(130), + fields: { description: "attacker edit" }, + }); + check("non-creator edit is rejected", edit.status === 403 || edit.status === 401 || edit.status === 400, `got ${edit.status}`); +} + +// ── 8. IP rate limit still works (sanity check) ── +console.log("\n[8] IP rate limit still works (sanity)"); +{ + // The IP limiter on the nonce route is 30/min. Hit it 31 times with a + // unique X-Forwarded-For to confirm the IP bucket still fires. + let lastStatus = 0; + for (let i = 0; i < 32; i++) { + const r = await post("/api/launch/edit/nonce", { chain: "base", token: TOKEN, wallet: ATTACKER_WALLET }, { "x-forwarded-for": `10.0.0.${i % 256}` }); + lastStatus = r.status; + } + // The attacker wallet gets 403 (not the creator), not 429, because the + // wallet bucket is now inside issueNonce (after the creator check). + // The IP bucket is separate and has a 30/min limit, but each request here + // uses a different IP, so no 429. This confirms the IP limiter is keyed + // on IP, not wallet. + check("IP-keyed requests don't 429 on wallet bucket", lastStatus === 403, `got ${lastStatus}`); +} + +// ── Summary ── +console.log(`\n${"=".repeat(60)}`); +console.log(`E2E results: ${pass} pass, ${fail} fail`); +if (fail > 0) { + console.log("Failures:"); + for (const f of failures) console.log(` - ${f}`); + process.exit(1); +} +console.log("All e2e checks passed."); +process.exit(0); From 11b70df124076a74031f2f916a49b89417d254a5 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:27:41 -0500 Subject: [PATCH 4/5] fix: remove wallet rate limit from nonce endpoint; strengthen test mock CodeRabbit review caught two issues: 1. The wallet rate limit added to issueNonce was still abusable. The nonce endpoint is unauthenticated (no signature), so the launcher check only confirms the supplied wallet matches the public on-chain launcher address. An attacker who knows that address passes the launcher check and spends the wallet bucket. Removed the wallet rate limit from issueNonce entirely; the IP limit in the route is the only limit on this endpoint. 2. The db-mock only checked nonce + used_at, not the full predicate set (chain_id, token, wallet, expires_at). The begin() did not snapshot/restore on rollback, and the metadata branch could not fail, so nonce rollback on a failed INSERT was untested. Fixed: the mock now checks all predicates, snapshots state before the transaction callback and restores on rejection, and supports a configurable metadata-write failure. Updated tests: the nonce DoS test now uses the creator's wallet (the real attack vector), added predicate-match tests (wrong wallet, wrong chain_id, expired), and a transaction rollback test. --- app/scripts/e2e-edit-fix.mjs | 20 +++-- app/src/app/api/launch/edit/nonce/route.ts | 1 - app/src/lib/db-mock.ts | 64 ++++++++++++-- app/src/lib/launchpad/editServer.test.ts | 97 +++++++++++++++++++--- app/src/lib/launchpad/editServer.ts | 11 +-- 5 files changed, 158 insertions(+), 35 deletions(-) diff --git a/app/scripts/e2e-edit-fix.mjs b/app/scripts/e2e-edit-fix.mjs index 5de772c1..eaaa54ad 100644 --- a/app/scripts/e2e-edit-fix.mjs +++ b/app/scripts/e2e-edit-fix.mjs @@ -88,18 +88,22 @@ let nonce, expiresAt; expiresAt = r.json?.expiresAt; } -// ── 3. Finding 1: 10 non-creator nonce requests do NOT freeze the creator ── -console.log("\n[3] Finding 1: 10 non-creator nonce requests do not freeze the creator's budget"); +// ── 3. Finding 1: 11 nonce requests with the creator's wallet do NOT freeze the creator ── +console.log("\n[3] Finding 1: 11 nonce requests with the creator's public address do not freeze the creator"); { - // Attacker tries to freeze the creator's nonce budget by sending 10 requests - // with the creator's wallet but wrong token (or as non-creator). - // Before the fix, the route spent the wallet bucket before the creator check. - for (let i = 0; i < 10; i++) { - await post("/api/launch/edit/nonce", { chain: "base", token: TOKEN, wallet: ATTACKER_WALLET }); + // The nonce endpoint is unauthenticated (no signature). An attacker who + // knows the creator's public address can pass the launcher check. Before + // the fix, the route spent a wallet-keyed bucket, so 10 requests with the + // creator's address froze the creator's next nonce request. The fix removes + // the wallet rate limit from the nonce endpoint entirely; the IP limit is + // the only limit. + for (let i = 0; i < 11; i++) { + const r = await post("/api/launch/edit/nonce", { chain: "base", token: TOKEN, wallet: TEST_WALLET }); + check(`attacker attempt ${i + 1} with creator address returns 200`, r.status === 200, `got ${r.status} ${r.json?.error}`); } // The creator must still be able to get a nonce const r = await getNonce(); - check("creator still gets a nonce after 10 attacker requests", r.status === 200 && typeof r.json?.nonce === "string", `got ${r.status} ${r.json?.error}`); + check("creator still gets a nonce after 11 attacker requests", r.status === 200 && typeof r.json?.nonce === "string", `got ${r.status} ${r.json?.error}`); } // ── 4. Finding 2: invalid signature does NOT burn the nonce ── diff --git a/app/src/app/api/launch/edit/nonce/route.ts b/app/src/app/api/launch/edit/nonce/route.ts index dc0f1447..32d7a287 100644 --- a/app/src/app/api/launch/edit/nonce/route.ts +++ b/app/src/app/api/launch/edit/nonce/route.ts @@ -18,6 +18,5 @@ export async function POST(req: Request) { if (!isChainKey(b.chain) || typeof b.token !== "string" || !isAddress(b.token) || typeof b.wallet !== "string" || !isAddress(b.wallet)) return NextResponse.json({ error: "bad params" }, { status: 400 }); const n = await issueNonce(b.chain, b.token, b.wallet); if (!n) return NextResponse.json({ error: "not the creator" }, { status: 403 }); - if ("ok" in n && !n.ok) return NextResponse.json({ error: n.error }, { status: n.status }); return NextResponse.json(n, { headers: { "cache-control": "no-store" } }); } diff --git a/app/src/lib/db-mock.ts b/app/src/lib/db-mock.ts index edb15b3f..87d8712e 100644 --- a/app/src/lib/db-mock.ts +++ b/app/src/lib/db-mock.ts @@ -2,10 +2,20 @@ // function whose behavior is driven by a global mock state set by the test. import type { Db } from "./db.ts"; +type NonceRow = { + nonce: string; + used_at: string | null; + wallet: string; + token: string; + chain_id: number; + expires_at: string; +}; + type MockState = { - nonceRows: Record; + nonceRows: Record; launcherRows: Record; metaInserted: boolean; + metaInsertShouldThrow: boolean; verifyShouldThrow: boolean; verifyResult: boolean; }; @@ -20,6 +30,7 @@ export function resetMock() { nonceRows: {}, launcherRows: {}, metaInserted: false, + metaInsertShouldThrow: false, verifyShouldThrow: false, verifyResult: true, }; @@ -30,16 +41,44 @@ export function getMock(): MockState { return globalThis.__editMock!; } +// Deep-snapshot the mutable parts of mock state so begin() can restore on rollback. +function snapshot(m: MockState) { + return { + nonceRows: Object.fromEntries( + Object.entries(m.nonceRows).map(([k, v]) => [k, { ...v }]), + ), + metaInserted: m.metaInserted, + }; +} + +function restore(m: MockState, snap: { nonceRows: Record; metaInserted: boolean }) { + m.nonceRows = snap.nonceRows; + m.metaInserted = snap.metaInserted; +} + // A fake db tagged-template function. It inspects the SQL text to decide what to return. function fakeDb(strings: TemplateStringsArray, ...values: unknown[]): Promise { const sql = strings.join("?"); const m = getMock(); // UPDATE bb_edit_nonces SET used_at = now() ... RETURNING nonce + // Checks all predicates from applySignedEdit: nonce, chain_id, token, + // wallet, used_at IS NULL, expires_at > now(), and the exact signed expiry. if (sql.includes("UPDATE bb_edit_nonces") && sql.includes("used_at")) { const nonce = String(values[0]); + const cid = Number(values[1]); + const token = String(values[2]).toLowerCase(); + const wallet = String(values[3]).toLowerCase(); + const signedExpiry = String(values[4]); const row = m.nonceRows[nonce]; - if (!row || row.used_at !== null) return Promise.resolve([] as any[]); + if (!row) return Promise.resolve([] as any[]); + if (row.used_at !== null) return Promise.resolve([] as any[]); + if (row.chain_id !== cid) return Promise.resolve([] as any[]); + if (row.token !== token) return Promise.resolve([] as any[]); + if (row.wallet !== wallet) return Promise.resolve([] as any[]); + if (row.expires_at !== signedExpiry) return Promise.resolve([] as any[]); + // expires_at > now(): check the stored expiry is in the future + if (new Date(row.expires_at).getTime() <= Date.now()) return Promise.resolve([] as any[]); row.used_at = new Date().toISOString(); return Promise.resolve([{ nonce }] as any[]); } @@ -55,16 +94,16 @@ function fakeDb(strings: TemplateStringsArray, ...values: unknown[]): Promise Promise) => fn(fakeDb); +// begin() snapshots mock state before the transaction callback and restores +// it on rejection, so a failed metadata write rolls back the nonce consume +// (including reverting used_at), matching the real db.begin() semantics. +(fakeDb as any).begin = async (fn: (tx: any) => Promise) => { + const m = getMock(); + const snap = snapshot(m); + try { + return await fn(fakeDb); + } catch (e) { + restore(m, snap); + throw e; + } +}; export function maybeDb(): any | null { return fakeDb as any; diff --git a/app/src/lib/launchpad/editServer.test.ts b/app/src/lib/launchpad/editServer.test.ts index feff3c66..d095961d 100644 --- a/app/src/lib/launchpad/editServer.test.ts +++ b/app/src/lib/launchpad/editServer.test.ts @@ -11,6 +11,9 @@ const TOKEN = "0x" + "11".repeat(20); const WALLET = "0x" + "ab".repeat(20); const NONCE = "a".repeat(32); const SIG = "0x" + "c".repeat(130); +// Fixed expiry so seedNonce and editRequest produce the same ISO string +const EXPIRES_AT = Date.now() + 300_000; +const EXPIRES_AT_ISO = new Date(EXPIRES_AT).toISOString(); function seedNonce(used = false) { const m = getMock(); @@ -20,7 +23,7 @@ function seedNonce(used = false) { wallet: WALLET.toLowerCase(), token: TOKEN.toLowerCase(), chain_id: 8453, - expires_at: new Date(Date.now() + 300_000).toISOString(), + expires_at: EXPIRES_AT_ISO, }; } @@ -39,7 +42,7 @@ function editRequest(overrides: Record = {}) { token: TOKEN, wallet: WALLET, nonce: NONCE, - expiresAt: Date.now() + 300_000, + expiresAt: EXPIRES_AT, signature: SIG, fields: { description: "hello" }, ...overrides, @@ -98,6 +101,69 @@ test("applySignedEdit: valid signature consumes the nonce and writes metadata", assert.equal(m.metaInserted, true, "metadata should be written on success"); }); +test("applySignedEdit: metadata write failure rolls back the nonce consume (transaction)", async () => { + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.verifyResult = true; + m.metaInsertShouldThrow = true; // metadata INSERT fails + + // The metadata write throws inside db.begin(). The transaction rolls back + // (nonce used_at restored to null) and the error propagates. In production + // the route handler or Next.js catches it as a 500. + await assert.rejects(ed.applySignedEdit(editRequest()), /mock metadata write failed/); + // The nonce must be rolled back: used_at restored to null + assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must be rolled back when metadata write fails"); + assert.equal(m.metaInserted, false, "metadata must not be written"); +}); + +test("applySignedEdit: wrong wallet in nonce row is rejected (predicate match)", async () => { + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.verifyResult = true; + // Corrupt the wallet in the nonce row so it doesn't match the request + m.nonceRows[NONCE].wallet = "0x" + "99".repeat(20); + + const r = await ed.applySignedEdit(editRequest()); + + assert.equal(r.ok, false, "should fail when nonce wallet doesn't match"); + assert.equal(r.status, 401, "mismatched nonce should be 401"); + assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must not be consumed on wallet mismatch"); +}); + +test("applySignedEdit: wrong chain_id in nonce row is rejected (predicate match)", async () => { + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.verifyResult = true; + m.nonceRows[NONCE].chain_id = 999; + + const r = await ed.applySignedEdit(editRequest()); + + assert.equal(r.ok, false, "should fail when nonce chain_id doesn't match"); + assert.equal(r.status, 401, "mismatched chain_id should be 401"); +}); + +test("applySignedEdit: expired expiresAt is rejected at input validation (400)", async () => { + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.verifyResult = true; + + // An expired expiresAt is rejected at line 54 before reaching the nonce + // consume. The SQL expires_at > now() predicate is defense-in-depth. + const r = await ed.applySignedEdit(editRequest({ expiresAt: Date.now() - 1000 })); + + assert.equal(r.ok, false, "should fail when expiresAt is in the past"); + assert.equal(r.status, 400, "expired expiresAt should be 400 (input validation)"); + assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must not be consumed on expired request"); +}); + test("applySignedEdit: already-used nonce is rejected (401)", async () => { resetMock(); seedNonce(true); // already used @@ -159,21 +225,26 @@ test("applySignedEdit: 10 requests with an invalid signature do NOT freeze the w assert.equal(m.metaInserted, true, "metadata should be written"); }); -test("issueNonce: non-creator requests do NOT spend the wallet rate limit", async () => { - // An attacker asks for a nonce with wallet=victim but is not the creator. - // Before the fix, the route spent the wallet bucket before issueNonce, - // so the victim's next real nonce request got 429. After the fix, the - // wallet bucket is only spent after the creator check passes. +test("issueNonce: creator can get nonces without a wallet-keyed limit (no unauthenticated wallet bucket)", async () => { + // The nonce endpoint is unauthenticated (no signature), so a wallet-keyed + // rate limit after the launcher check would still be abusable: an attacker + // who knows the creator's public address passes the launcher check and + // spends the wallet bucket. The fix removes the wallet limit entirely; + // the IP limit in the route is the only limit on this endpoint. resetMock(); - // No launcher row seeded → issueNonce returns null (not the creator) + seedLauncher(); - for (let i = 0; i < 10; i++) { + // 11 requests with the creator's wallet must all succeed (no wallet bucket) + for (let i = 0; i < 11; i++) { const n = await ed.issueNonce(CHAIN, TOKEN, WALLET); - assert.equal(n, null, `attempt ${i + 1} by non-creator should return null`); + assert.ok(n && "nonce" in n, `attempt ${i + 1} by creator should return a nonce`); } +}); - // Now seed the launcher and verify the real creator can still get a nonce +test("issueNonce: non-creator requests return null", async () => { + resetMock(); seedLauncher(); - const n = await ed.issueNonce(CHAIN, TOKEN, WALLET); - assert.ok(n && "nonce" in n, "creator must still be able to get a nonce (bucket not frozen)"); + // A wallet that is not the launcher gets null + const n = await ed.issueNonce(CHAIN, TOKEN, "0x" + "ff".repeat(20)); + assert.equal(n, null, "non-creator should get null"); }); diff --git a/app/src/lib/launchpad/editServer.ts b/app/src/lib/launchpad/editServer.ts index 58755390..b8cef2a5 100644 --- a/app/src/lib/launchpad/editServer.ts +++ b/app/src/lib/launchpad/editServer.ts @@ -26,15 +26,16 @@ export function rateLimited(key: string, max = 10, windowMs = 60_000): boolean { return b.n > max; } -export async function issueNonce(chain: ChainKey, token: string, wallet: string): Promise<{ nonce: string; expiresAt: number } | { ok: false; error: string; status: number } | null> { +export async function issueNonce(chain: ChainKey, token: string, wallet: string): Promise<{ nonce: string; expiresAt: number } | null> { const db = maybeDb(); if (!db) return null; const launcher = await db<{ launcher: string }[]>`SELECT launcher FROM bb_launches WHERE chain_id = ${chainIdOf(chain)} AND token = ${token.toLowerCase()}`; if (!launcher[0] || launcher[0].launcher !== wallet.toLowerCase()) return null; // only the creator gets a nonce - // wallet rate limit is applied AFTER the creator check, so an attacker - // cannot freeze a creator's nonce budget by sending requests with the - // creator's public address (the wallet is proven before the bucket is spent) - if (rateLimited(`nonce:wallet:${wallet.toLowerCase()}`, 10)) return { ok: false, error: "slow down", status: 429 }; + // No wallet rate limit here: the nonce endpoint is unauthenticated (no + // signature), so the launcher check only confirms the supplied wallet + // matches the public on-chain launcher address. An attacker who knows that + // address could spend a wallet-keyed bucket by passing the launcher check. + // The IP rate limit in the route is the only limit on this endpoint. const nonce = randomBytes(16).toString("hex"); const expiresAt = Date.now() + EDIT_TTL_MS; await db`INSERT INTO bb_edit_nonces (nonce, chain_id, token, wallet, expires_at) VALUES (${nonce}, ${chainIdOf(chain)}, ${token.toLowerCase()}, ${wallet.toLowerCase()}, ${new Date(expiresAt).toISOString()})`; From a6e4407a948ed17b043d647c5f7e12f2278cc82d Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:08:27 -0500 Subject: [PATCH 5/5] Address PR #10 review: cross-platform tests, RPC outage 503, e2e gaps Four points from the CHANGES_REQUESTED review, plus the test-key concern: 1. Cross-platform test command. `npm test` used inline `TEST_MOCK_DB=1 NODE_OPTIONS=...` which fails on Windows (cmd does not expand inline env vars). The mock swap is actually done by tsconfig.test.json paths, so TEST_MOCK_DB was vestigial; the only need is the react-server condition for `server-only`. Pass `--conditions=react-server` straight to tsx, which is portable with no env var and no new dependency. Removed `test:unit`: plain `node --test` cannot run the server test (needs tsconfig path mocks + react-server), so it was broken, and `test` now runs the whole suite cross-platform. 2. RPC outage classification. viem 2.55.19's verifyMessage returns false (rather than throwing) when the RPC transport is unreachable, so an outage surfaced as 401 "signature does not match" instead of 503. Added a getChainId liveness probe on the false branch: if the probe throws, the false came from an outage (503); otherwise it is a genuine mismatch (401). The nonce is intact either way. The probe only runs on the failure path, so the happy path keeps its cost. 3. Transport-level outage test through viem itself. The chain mock now returns a real viem client with an unreachable transport when transportOutage is set, so the outage path runs through viem's actual verifyErc6492 path (the same one smart-wallet verification uses) rather than a hand-rolled stub. Added a unit test asserting 503 + nonce intact. 4. e2e IP-limit test. The IP check used a fresh IP per request, so it passed even with no limiter. Kept the cross-IP isolation check and added a single repeated IP past the 30/min limit, asserting it eventually gets 429. Test key: replaced the hardcoded TEST_PRIVATE_KEY with a freshly generated disposable key (never funded, public by design) and labelled it. The old key is in git history; if it was ever used for real funds those should be moved, since it is now public. --- README.md | 2 +- app/package.json | 3 +-- app/scripts/e2e-edit-fix.mjs | 23 +++++++++++++++++--- app/src/lib/chain-mock.ts | 27 ++++++++++++++++++++++-- app/src/lib/db-mock.ts | 5 +++++ app/src/lib/launchpad/editServer.test.ts | 21 ++++++++++++++++++ app/src/lib/launchpad/editServer.ts | 13 +++++++++++- 7 files changed, 85 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a10be2dc..43d88c3e 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ cd app && npm install cp .env.example .env.local # RPCs, factory / locker addresses, DATABASE_URL (a local Postgres is enough) npm run migrate # applies db/schema.sql; reads DATABASE_URL from .env.local npm run dev # http://localhost:3000 -npm test # unit tests (node --test) +npm test # unit tests (tsx + node --test) ``` `app/README.md` covers local development against anvil forks of both chains; `app/LAUNCH.md` is the diff --git a/app/package.json b/app/package.json index 09df1d11..2b39fd35 100644 --- a/app/package.json +++ b/app/package.json @@ -8,8 +8,7 @@ "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", - "test": "TEST_MOCK_DB=1 NODE_OPTIONS=--conditions=react-server tsx --tsconfig tsconfig.test.json --test \"src/**/*.test.ts\"", - "test:unit": "node --experimental-strip-types --no-warnings --test \"src/**/*.test.ts\"", + "test": "tsx --conditions=react-server --tsconfig tsconfig.test.json --test \"src/**/*.test.ts\"", "migrate": "node scripts/migrate.mjs" }, "dependencies": { diff --git a/app/scripts/e2e-edit-fix.mjs b/app/scripts/e2e-edit-fix.mjs index eaaa54ad..e84fbc94 100644 --- a/app/scripts/e2e-edit-fix.mjs +++ b/app/scripts/e2e-edit-fix.mjs @@ -7,13 +7,17 @@ * Prerequisites: * - Next.js dev server running on :3000 with DATABASE_URL pointing at a test DB * - A bb_launches row for token 0x11...11 with launcher = TEST_WALLET + * (0x3A90168A6bA7c975C34B064f35523cd9FFeff85a, derived from TEST_PRIVATE_KEY below) * - BASE_RPC_URL reachable (for signature verification) */ import { privateKeyToAccount } from "viem/accounts"; import { buildEditMessage, validateEdit } from "../src/lib/launchpad/editAuth.ts"; const BASE = "http://localhost:3000"; -const TEST_PRIVATE_KEY = "0xa92669a8ef46ba60f3327b15ba8d737b562c7260d445fa2e5d8b43c577358a45"; +// Disposable test-only key generated solely for this fixture. It has never +// been and must never be funded or granted any permissions; it is public by +// design. Do not reuse it for anything real. +const TEST_PRIVATE_KEY = "0xa29ac736defede5a8eadc689e4ae754da133c9eb9de0706a5c476c06c9457981"; const TEST_WALLET = privateKeyToAccount(TEST_PRIVATE_KEY).address; const TOKEN = "0x1111111111111111111111111111111111111111"; const ATTACKER_WALLET = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; @@ -236,8 +240,8 @@ console.log("\n[7] Non-creator cannot edit"); // ── 8. IP rate limit still works (sanity check) ── console.log("\n[8] IP rate limit still works (sanity)"); { - // The IP limiter on the nonce route is 30/min. Hit it 31 times with a - // unique X-Forwarded-For to confirm the IP bucket still fires. + // The IP limiter on the nonce route is 30/min. Hit it 32 times with a + // unique X-Forwarded-For to confirm the IP bucket is keyed on IP, not wallet. let lastStatus = 0; for (let i = 0; i < 32; i++) { const r = await post("/api/launch/edit/nonce", { chain: "base", token: TOKEN, wallet: ATTACKER_WALLET }, { "x-forwarded-for": `10.0.0.${i % 256}` }); @@ -249,6 +253,19 @@ console.log("\n[8] IP rate limit still works (sanity)"); // uses a different IP, so no 429. This confirms the IP limiter is keyed // on IP, not wallet. check("IP-keyed requests don't 429 on wallet bucket", lastStatus === 403, `got ${lastStatus}`); + + // Now repeat a single IP past the 30/min limit. The IP check runs before + // issueNonce, so the 31st request must get 429 regardless of wallet. This + // would pass even with no limiter if every request used a fresh IP, so a + // repeated single IP is the only way to prove the limiter actually fires. + let saw429 = false; + let singleIpLast = 0; + for (let i = 0; i < 32; i++) { + const r = await post("/api/launch/edit/nonce", { chain: "base", token: TOKEN, wallet: ATTACKER_WALLET }, { "x-forwarded-for": "10.1.2.3" }); + singleIpLast = r.status; + if (r.status === 429) { saw429 = true; break; } + } + check("a single repeated IP eventually gets 429", saw429, `last status ${singleIpLast}`); } // ── Summary ── diff --git a/app/src/lib/chain-mock.ts b/app/src/lib/chain-mock.ts index 60ed5101..85497ac6 100644 --- a/app/src/lib/chain-mock.ts +++ b/app/src/lib/chain-mock.ts @@ -1,5 +1,11 @@ -// Mock @/lib/chain for tests. publicClient returns a fake client whose verifyMessage -// behavior is driven by the global mock state from db-mock.ts. +// Mock @/lib/chain for tests. publicClient returns either a fake client whose +// verifyMessage behavior is driven by the global mock state from db-mock.ts, +// or, when transportOutage is set, a real viem client with an unreachable +// transport. The real client lets the RPC-outage path run through viem itself +// (verifyMessage returns false on an offline transport; getChainId throws) +// instead of a hand-rolled stub. +import { createPublicClient, fallback, http } from "viem"; +import { base } from "viem/chains"; import type { ChainKey } from "./chainPublic.ts"; import { getMock } from "./db-mock.ts"; @@ -13,12 +19,29 @@ export function b20RpcUrl(): string { return "http://mock"; } +// A port nothing listens on: connection refused is instant and deterministic, +// so the outage test stays fast with no real network dependency. +const DEAD_RPC = "http://127.0.0.1:1"; + export function publicClient(_key: ChainKey): any { + const m = getMock(); + if (m.transportOutage) { + return createPublicClient({ + chain: base, + transport: fallback( + [http(DEAD_RPC, { retryCount: 0 }), http(DEAD_RPC, { retryCount: 0 })], + { rank: false }, + ), + }); + } return { verifyMessage: async (_args: any): Promise => { const m = getMock(); if (m.verifyShouldThrow) throw new Error("mock RPC error"); return m.verifyResult; }, + // Liveness probe used by applySignedEdit to distinguish an RPC outage + // (throws) from a genuinely invalid signature when verifyMessage is false. + getChainId: async (): Promise => 8453, }; } diff --git a/app/src/lib/db-mock.ts b/app/src/lib/db-mock.ts index 87d8712e..406de4ff 100644 --- a/app/src/lib/db-mock.ts +++ b/app/src/lib/db-mock.ts @@ -18,6 +18,10 @@ type MockState = { metaInsertShouldThrow: boolean; verifyShouldThrow: boolean; verifyResult: boolean; + // When set, publicClient (chain-mock) returns a real viem client with an + // unreachable transport, so the RPC-outage path is exercised through viem + // itself rather than a hand-rolled stub. + transportOutage: boolean; }; declare global { @@ -33,6 +37,7 @@ export function resetMock() { metaInsertShouldThrow: false, verifyShouldThrow: false, verifyResult: true, + transportOutage: false, }; } diff --git a/app/src/lib/launchpad/editServer.test.ts b/app/src/lib/launchpad/editServer.test.ts index d095961d..a2f8ff8a 100644 --- a/app/src/lib/launchpad/editServer.test.ts +++ b/app/src/lib/launchpad/editServer.test.ts @@ -86,6 +86,27 @@ test("applySignedEdit: invalid signature does NOT consume the nonce (returns 401 assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must not be consumed on invalid signature"); }); +test("applySignedEdit: transport-level outage returns 503, not 401 (verifyMessage returns false on an offline transport)", async () => { + // viem 2.55.19's verifyMessage returns false (rather than throwing) when the + // RPC transport is unreachable, so without a liveness probe an outage would + // surface as 401 "signature does not match". This drives a REAL viem client + // with a dead transport through applySignedEdit (transportOutage swaps the + // chain mock for createPublicClient with an unreachable URL), exercising the + // same RPC-dependent verifyErc6492 path used for smart-wallet verification. + resetMock(); + seedNonce(); + seedLauncher(); + const m = getMock(); + m.transportOutage = true; + + const r = await ed.applySignedEdit(editRequest()); + + assert.equal(r.ok, false, "should fail"); + assert.equal(r.status, 503, "transport outage should be 503, not 401"); + assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must not be consumed on outage"); + assert.equal(m.metaInserted, false, "metadata must not be written on outage"); +}); + test("applySignedEdit: valid signature consumes the nonce and writes metadata", async () => { resetMock(); seedNonce(); diff --git a/app/src/lib/launchpad/editServer.ts b/app/src/lib/launchpad/editServer.ts index b8cef2a5..03dd1e77 100644 --- a/app/src/lib/launchpad/editServer.ts +++ b/app/src/lib/launchpad/editServer.ts @@ -75,7 +75,18 @@ export async function applySignedEdit(r: EditRequest): Promise<{ ok: true } | { } catch { return { ok: false, error: "signature verification unavailable, try again", status: 503 }; } - if (!valid) return { ok: false, error: "signature does not match", status: 401 }; + if (!valid) { + // viem's verifyMessage returns false (rather than throwing) when the RPC + // transport is unreachable, so an outage would otherwise look like an + // invalid signature (401). Probe liveness to tell an outage (503) from a + // genuine mismatch (401); the nonce is still intact either way. + try { + await publicClient(r.chain).getChainId(); + } catch { + return { ok: false, error: "signature verification unavailable, try again", status: 503 }; + } + return { ok: false, error: "signature does not match", status: 401 }; + } // wallet rate limit is applied AFTER the signature is verified, so an // attacker cannot freeze a creator's edit budget by sending requests with