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/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 54710abd..97ecbbdf 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -34,6 +34,7 @@ "eslint": "^9", "eslint-config-next": "^16.2.9", "tailwindcss": "^4", + "tsx": "^4.23.13", "typescript": "^5" } }, @@ -706,6 +707,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", @@ -3923,6 +4366,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", @@ -4570,6 +5055,21 @@ } } }, + "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", @@ -7395,6 +7895,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 552a6ec4..7f72fbfb 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", - "test": "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": { @@ -38,6 +38,7 @@ "eslint": "^9", "eslint-config-next": "^16.2.9", "tailwindcss": "^4", + "tsx": "^4.23.13", "typescript": "^5" }, "overrides": { diff --git a/app/scripts/e2e-edit-fix.mjs b/app/scripts/e2e-edit-fix.mjs new file mode 100644 index 00000000..e84fbc94 --- /dev/null +++ b/app/scripts/e2e-edit-fix.mjs @@ -0,0 +1,280 @@ +#!/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); diff --git a/app/src/app/api/launch/edit/nonce/route.ts b/app/src/app/api/launch/edit/nonce/route.ts index 74fe12c7..32d7a287 100644 --- a/app/src/app/api/launch/edit/nonce/route.ts +++ b/app/src/app/api/launch/edit/nonce/route.ts @@ -16,7 +16,6 @@ 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 }); 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/chain-mock.ts b/app/src/lib/chain-mock.ts new file mode 100644 index 00000000..85497ac6 --- /dev/null +++ b/app/src/lib/chain-mock.ts @@ -0,0 +1,47 @@ +// 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"; + +export * from "./chainPublic.ts"; + +export function rpcUrl(_key: ChainKey): string | undefined { + return undefined; +} + +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 new file mode 100644 index 00000000..406de4ff --- /dev/null +++ b/app/src/lib/db-mock.ts @@ -0,0 +1,146 @@ +// 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 NonceRow = { + nonce: string; + used_at: string | null; + wallet: string; + token: string; + chain_id: number; + expires_at: string; +}; + +type MockState = { + nonceRows: Record; + launcherRows: Record; + metaInserted: boolean; + 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 { + // eslint-disable-next-line no-var + var __editMock: MockState | undefined; +} + +export function resetMock() { + globalThis.__editMock = { + nonceRows: {}, + launcherRows: {}, + metaInserted: false, + metaInsertShouldThrow: false, + verifyShouldThrow: false, + verifyResult: true, + transportOutage: false, + }; +} + +export function getMock(): MockState { + if (!globalThis.__editMock) resetMock(); + 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) 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[]); + } + + // 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")) { + if (m.metaInsertShouldThrow) throw new Error("mock metadata write failed"); + m.metaInserted = true; + return Promise.resolve([] as any[]); + } + + // nonce insert (issueNonce) + if (sql.includes("bb_edit_nonces") && sql.includes("VALUES")) { + return Promise.resolve([] as any[]); + } + + // expired nonce cleanup + if (sql.includes("DELETE") && sql.includes("bb_edit_nonces")) { + return Promise.resolve([] as any[]); + } + + return Promise.resolve([] as any[]); +} + +// 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; +} + +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..a2f8ff8a --- /dev/null +++ b/app/src/lib/launchpad/editServer.test.ts @@ -0,0 +1,271 @@ +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); +// 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(); + 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: EXPIRES_AT_ISO, + }; +} + +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: EXPIRES_AT, + 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: 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(); + 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: 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 + 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: 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(); + seedLauncher(); + + // 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.ok(n && "nonce" in n, `attempt ${i + 1} by creator should return a nonce`); + } +}); + +test("issueNonce: non-creator requests return null", async () => { + resetMock(); + seedLauncher(); + // 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 32ddaf16..03dd1e77 100644 --- a/app/src/lib/launchpad/editServer.ts +++ b/app/src/lib/launchpad/editServer.ts @@ -31,6 +31,11 @@ export async function issueNonce(chain: ChainKey, token: string, wallet: string) 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 + // 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()})`; @@ -54,30 +59,57 @@ 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) { + // 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 }; } - 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; } 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"] + } + } +}