Files
openlaunch/app/scripts/e2e-edit-fix.mjs
beardthelion a6e4407a94 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.
2026-09-08 01:08:27 -05:00

281 lines
12 KiB
JavaScript

#!/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
* (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";
// 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";
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: 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");
{
// 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 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 ──
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 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}` });
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}`);
// 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 ──
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);