diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..689504a3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + groups: + mesh-sdk: + patterns: + - "@meshsdk/*" + next: + patterns: + - "next" + - "next-*" + - "@next/*" + prisma: + patterns: + - "prisma" + - "@prisma/*" + trpc: + patterns: + - "@trpc/*" + types: + patterns: + - "@types/*" + update-types: + - "minor" + - "patch" + labels: + - "dependencies" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + - "ci" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 00000000..6f9b268f --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,47 @@ +name: PR Checks + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: pr-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + # Lint stays non-blocking until the rule set is cleaned up; tracked separately. + - name: Lint + run: npm run lint + continue-on-error: true + + # Typecheck, test, and build are gates — failures must fail the PR. + - name: Type check + run: npx tsc --noEmit + + - name: Test + run: npm run test:ci + + - name: Build + run: npm run build + env: + SKIP_ENV_VALIDATION: 'true' diff --git a/README.md b/README.md index efee7585..bf2eb9da 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ A comprehensive, enterprise-grade multi-signature wallet solution built on Carda - Secure multi-sig staking operations ### Collaboration -- Real-time Nostr-based chat - Discord integration for notifications - Signer verification via message signing - Automated transaction alerts @@ -188,11 +187,11 @@ graph TD ### Database Schema ```prisma model User { - id String @id @default(cuid()) - address String @unique - stakeAddress String @unique - nostrKey String @unique - discordId String @default("") + id String @id @default(cuid()) + address String @unique + stakeAddress String @unique + nostrKey String? @unique + discordId String @default("") } model Wallet { diff --git a/jest.config.mjs b/jest.config.mjs index 3cb3583f..a5409725 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -16,7 +16,11 @@ export default { }, moduleNameMapper: { '^@/(.*)$': '/src/$1', + '\\.(css|less|scss|sass)$': '/src/__tests__/__mocks__/styleMock.cjs', }, + transformIgnorePatterns: [ + '/node_modules/(?!(superjson|copy-anything|is-what|@trpc|@meshsdk|@noble|@sidan-lab|nanoid|jose|uuid)/)', + ], collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', @@ -28,6 +32,7 @@ export default { coverageProvider: 'v8', coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'html'], + setupFiles: ['/src/__tests__/setupEnv.cjs'], setupFilesAfterEnv: ['/src/__tests__/setup.ts'], testTimeout: 10000, verbose: true, diff --git a/next.config.js b/next.config.js index fb9abebd..46b5efd9 100644 --- a/next.config.js +++ b/next.config.js @@ -55,13 +55,16 @@ const config = { layers: true, }; - // Optimize tree-shaking by ensuring proper module resolution + // Optimize tree-shaking by ensuring proper module resolution. + // Note: do NOT set `sideEffects: false` globally — it tells webpack that + // every file is side-effect-free, which silently strips CSS imports, + // polyfills, and other modules that exist purely for their side effects. + // Per-package sideEffects flags in package.json are the correct surface. config.optimization = { ...config.optimization, usedExports: true, - sideEffects: false, }; - + // Handle CommonJS modules that don't support named exports config.resolve = { ...config.resolve, @@ -75,6 +78,30 @@ const config = { // External packages for server components to avoid bundling issues serverExternalPackages: ["@fabianbormann/cardano-peer-connect"], + + async rewrites() { + return [ + { source: "/llms.txt", destination: "/api/llms-txt" }, + ]; + }, + + // Basic security headers applied to all routes. + // NOTE: Content-Security-Policy and Strict-Transport-Security are intentionally + // omitted — CSP would break inline scripts/styles and HSTS locks browsers to + // HTTPS for max-age and should only be enabled after team review. + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + ], + }, + ]; + }, }; // Bundle analyzer - only enable when ANALYZE env var is set diff --git a/package-lock.json b/package-lock.json index 506f37ff..277b7631 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,6 @@ "hasInstallScript": true, "dependencies": { "@auth/prisma-adapter": "^2.11.1", - "@hookform/resolvers": "^3.9.0", - "@jinglescode/nostr-chat-plugin": "^0.0.11", "@meshsdk/core": "^1.9.0-beta.87", "@meshsdk/core-csl": "^1.9.0-beta.87", "@meshsdk/core-cst": "^1.9.0-beta.87", @@ -44,7 +42,6 @@ "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", "@utxos/sdk": "^0.0.78", - "busboy": "^1.6.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cors": "^2.8.5", @@ -62,9 +59,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", - "react-hook-form": "^7.53.0", "react-markdown": "^10.1.0", - "recharts": "^2.12.7", "remark-gfm": "^4.0.1", "superjson": "^2.2.1", "swagger-jsdoc": "^6.2.8", @@ -74,7 +69,6 @@ "three": "^0.168.0", "three-globe": "^2.31.1", "uuid": "^11.1.0", - "yaml": "^2.8.2", "zod": "^3.23.8", "zustand": "^4.5.5" }, @@ -85,7 +79,6 @@ "@next/bundle-analyzer": "^16.0.10", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.7", - "@types/busboy": "^1.5.4", "@types/cors": "^2.8.18", "@types/eslint": "^8.56.10", "@types/formidable": "^3.4.5", @@ -1402,14 +1395,6 @@ "@harmoniclabs/plutus-data": "^1.2.4" } }, - "node_modules/@hookform/resolvers": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", - "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", - "peerDependencies": { - "react-hook-form": "^7.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2343,17 +2328,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jinglescode/nostr-chat-plugin": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@jinglescode/nostr-chat-plugin/-/nostr-chat-plugin-0.0.11.tgz", - "integrity": "sha512-teAjUTPqbfI353M+Ip/QcT7LnA4VpUh4jHbbo0xryhajAXlNIYpUDs5bbTa0M4IroU/aMA65Ba8iaqhIta0D0g==", - "dependencies": { - "nostr-tools": "^2.8.0" - }, - "peerDependencies": { - "react": ">=17.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -6247,15 +6221,6 @@ "@types/node": "*" } }, - "node_modules/@types/busboy": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz", - "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -6265,60 +6230,6 @@ "@types/node": "*" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -9284,17 +9195,6 @@ "node": ">=6.14.2" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -10093,14 +9993,6 @@ "node": ">=12" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -10150,14 +10042,6 @@ "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==" }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -10185,17 +10069,6 @@ "node": ">=12" } }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", @@ -10218,14 +10091,6 @@ "node": ">=12" } }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-tricontour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", @@ -10341,11 +10206,6 @@ } } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" - }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -10702,15 +10562,6 @@ "node": ">=0.10.0" } }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dom-walk": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", @@ -11689,14 +11540,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, - "node_modules/fast-equals": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", - "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -16445,52 +16288,6 @@ "node": ">=0.10.0" } }, - "node_modules/nostr-tools": { - "version": "2.23.3", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz", - "integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==", - "dependencies": { - "@noble/ciphers": "2.1.1", - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "@scure/base": "2.0.0", - "@scure/bip32": "2.0.1", - "@scure/bip39": "2.0.1", - "nostr-wasm": "0.1.0" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/nostr-tools/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/nostr-tools/node_modules/@scure/base": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", - "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/nostr-wasm": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", - "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==" - }, "node_modules/npm": { "version": "9.9.4", "resolved": "https://registry.npmjs.org/npm/-/npm-9.9.4.tgz", @@ -20934,21 +20731,6 @@ "react": ">= 16.8 || 18.0.0" } }, - "node_modules/react-hook-form": { - "version": "7.72.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", - "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19" - } - }, "node_modules/react-immutable-proptypes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.2.0.tgz", @@ -20981,7 +20763,8 @@ "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -21092,20 +20875,6 @@ } } }, - "node_modules/react-smooth": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -21146,21 +20915,6 @@ "react": ">= 0.14.0" } }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, "node_modules/react-use-measure": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", @@ -21208,41 +20962,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/recharts": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", - "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", - "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, - "node_modules/recharts/node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" - }, "node_modules/record-cache": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/record-cache/-/record-cache-1.2.0.tgz", @@ -22253,14 +21972,6 @@ "node": ">= 0.4" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/streamx": { "version": "2.22.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", @@ -23127,11 +22838,6 @@ "integrity": "sha512-Mz0CX4vBGM5lj8ttbIFt7o4ZMxk/9rgudJRh76EvB7xXZMur7T/cjRiH2w4Fmkq0zxf2QpM8IFvOSRn8FEu3gA==", "optional": true }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" - }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -24312,27 +24018,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -24787,20 +24472,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index ef470275..61411188 100644 --- a/package.json +++ b/package.json @@ -14,19 +14,20 @@ "dev": "next dev", "postinstall": "prisma generate", "lint": "next lint", + "typecheck": "tsc --noEmit", + "format": "prettier --write .", + "format:check": "prettier --check .", "prestart": "prisma migrate deploy", "start": "next start", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "test:ci": "jest --ci --coverage --watchAll=false", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", + "test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --ci --coverage --watchAll=false", "analyze": "ANALYZE=true npm run build", "apply-project": "node scripts/apply-project-to-github.mjs" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", - "@hookform/resolvers": "^3.9.0", - "@jinglescode/nostr-chat-plugin": "^0.0.11", "@meshsdk/core": "^1.9.0-beta.87", "@meshsdk/core-csl": "^1.9.0-beta.87", "@meshsdk/core-cst": "^1.9.0-beta.87", @@ -59,7 +60,6 @@ "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", "@utxos/sdk": "^0.0.78", - "busboy": "^1.6.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cors": "^2.8.5", @@ -77,9 +77,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", - "react-hook-form": "^7.53.0", "react-markdown": "^10.1.0", - "recharts": "^2.12.7", "remark-gfm": "^4.0.1", "superjson": "^2.2.1", "swagger-jsdoc": "^6.2.8", @@ -89,7 +87,6 @@ "three": "^0.168.0", "three-globe": "^2.31.1", "uuid": "^11.1.0", - "yaml": "^2.8.2", "zod": "^3.23.8", "zustand": "^4.5.5" }, @@ -100,7 +97,6 @@ "@next/bundle-analyzer": "^16.0.10", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.7", - "@types/busboy": "^1.5.4", "@types/cors": "^2.8.18", "@types/eslint": "^8.56.10", "@types/formidable": "^3.4.5", diff --git a/prisma/migrations/20260510160404_audit_log_and_indexes/migration.sql b/prisma/migrations/20260510160404_audit_log_and_indexes/migration.sql new file mode 100644 index 00000000..058fae30 --- /dev/null +++ b/prisma/migrations/20260510160404_audit_log_and_indexes/migration.sql @@ -0,0 +1,88 @@ +-- AlterTable +ALTER TABLE "Ballot" ALTER COLUMN "anchorUrls" SET DEFAULT ARRAY[]::TEXT[], +ALTER COLUMN "anchorHashes" SET DEFAULT ARRAY[]::TEXT[]; + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "actorAddress" TEXT, + "actorType" TEXT NOT NULL, + "action" TEXT NOT NULL, + "resourceType" TEXT, + "resourceId" TEXT, + "ip" TEXT, + "userAgent" TEXT, + "outcome" TEXT NOT NULL, + "reason" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AuditLog_actorAddress_idx" ON "AuditLog"("actorAddress"); + +-- CreateIndex +CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action"); + +-- CreateIndex +CREATE INDEX "AuditLog_resourceType_resourceId_idx" ON "AuditLog"("resourceType", "resourceId"); + +-- CreateIndex +CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt"); + +-- CreateIndex +CREATE INDEX "AuditLog_actorAddress_createdAt_idx" ON "AuditLog"("actorAddress", "createdAt"); + +-- CreateIndex +CREATE INDEX "BalanceSnapshot_walletId_idx" ON "BalanceSnapshot"("walletId"); + +-- CreateIndex +CREATE INDEX "BalanceSnapshot_walletId_snapshotDate_idx" ON "BalanceSnapshot"("walletId", "snapshotDate"); + +-- CreateIndex +CREATE INDEX "Ballot_walletId_idx" ON "Ballot"("walletId"); + +-- CreateIndex +CREATE INDEX "NewWallet_ownerAddress_idx" ON "NewWallet"("ownerAddress"); + +-- CreateIndex +CREATE INDEX "NewWallet_signersAddresses_idx" ON "NewWallet" USING GIN ("signersAddresses" array_ops); + +-- CreateIndex +CREATE INDEX "Proxy_walletId_idx" ON "Proxy"("walletId"); + +-- CreateIndex +CREATE INDEX "Proxy_userId_idx" ON "Proxy"("userId"); + +-- CreateIndex +CREATE INDEX "Proxy_walletId_isActive_idx" ON "Proxy"("walletId", "isActive"); + +-- CreateIndex +CREATE INDEX "Proxy_userId_isActive_idx" ON "Proxy"("userId", "isActive"); + +-- CreateIndex +CREATE INDEX "Signable_walletId_idx" ON "Signable"("walletId"); + +-- CreateIndex +CREATE INDEX "Signable_state_idx" ON "Signable"("state"); + +-- CreateIndex +CREATE INDEX "Signable_walletId_state_idx" ON "Signable"("walletId", "state"); + +-- CreateIndex +CREATE INDEX "Transaction_walletId_idx" ON "Transaction"("walletId"); + +-- CreateIndex +CREATE INDEX "Transaction_state_idx" ON "Transaction"("state"); + +-- CreateIndex +CREATE INDEX "Transaction_walletId_state_idx" ON "Transaction"("walletId", "state"); + +-- CreateIndex +CREATE INDEX "Wallet_ownerAddress_idx" ON "Wallet"("ownerAddress"); + +-- CreateIndex +CREATE INDEX "Wallet_signersAddresses_idx" ON "Wallet" USING GIN ("signersAddresses" array_ops); + diff --git a/prisma/migrations/20260510170000_make_user_nostrkey_optional/migration.sql b/prisma/migrations/20260510170000_make_user_nostrkey_optional/migration.sql new file mode 100644 index 00000000..7d33c422 --- /dev/null +++ b/prisma/migrations/20260510170000_make_user_nostrkey_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "nostrKey" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 23d45677..537b6f34 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,12 +16,12 @@ datasource db { } model User { - id String @id @default(cuid()) - address String @unique - stakeAddress String @unique - drepKeyHash String @default("") - nostrKey String @unique - discordId String @default("") + id String @id @default(cuid()) + address String @unique + stakeAddress String @unique + drepKeyHash String @default("") + nostrKey String? @unique + discordId String @default("") } model Wallet { @@ -43,6 +43,9 @@ model Wallet { migrationTargetWalletId String? profileImageIpfsUrl String? ownerAddress String? + + @@index([ownerAddress]) + @@index([signersAddresses(ops: ArrayOps)], type: Gin) } model Transaction { @@ -77,6 +80,10 @@ model Signable { updatedAt DateTime @updatedAt callbackUrl String? remoteOrigin String? + + @@index([walletId]) + @@index([state]) + @@index([walletId, state]) } model NewWallet { @@ -95,6 +102,9 @@ model NewWallet { paymentCbor String? stakeCbor String? rawImportBodies Json? + + @@index([ownerAddress]) + @@index([signersAddresses(ops: ArrayOps)], type: Gin) } model Nonce { @@ -105,18 +115,36 @@ model Nonce { } model Ballot { - id String @id @default(cuid()) - walletId String - description String? - items String[] - itemDescriptions String[] - choices String[] - anchorUrls String[] @default([]) - anchorHashes String[] @default([]) + id String @id @default(cuid()) + walletId String + description String? + items String[] + itemDescriptions String[] + choices String[] + anchorUrls String[] @default([]) + anchorHashes String[] @default([]) rationaleComments String[] @default([]) - type Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + type Int + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@index([walletId]) +} + +// Retained to preserve the existing production table; feature is currently unused +// in app code. Do not drop without an explicit data-migration plan. +model Crowdfund { + id String @id @default(cuid()) + name String + description String? + proposerKeyHashR0 String + authTokenId String? + datum String? + address String? + paramUtxo String? + govDatum String? + govAddress String? + createdAt DateTime @default(now()) } model Proxy { @@ -146,6 +174,9 @@ model BalanceSnapshot { assetBalances Json isArchived Boolean snapshotDate DateTime @default(now()) + + @@index([walletId]) + @@index([walletId, snapshotDate]) } model Migration { @@ -195,13 +226,13 @@ model BotKey { } model BotUser { - id String @id @default(cuid()) - botKeyId String @unique - paymentAddress String @unique // One bot, one address - stakeAddress String? - displayName String? - createdAt DateTime @default(now()) - botKey BotKey @relation(fields: [botKeyId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + botKeyId String @unique + paymentAddress String @unique // One bot, one address + stakeAddress String? + displayName String? + createdAt DateTime @default(now()) + botKey BotKey @relation(fields: [botKeyId], references: [id], onDelete: Cascade) @@index([paymentAddress]) } @@ -213,11 +244,11 @@ enum BotWalletRole { model WalletBotAccess { walletId String - botId String - role BotWalletRole - createdAt DateTime @default(now()) + botId String + role BotWalletRole + createdAt DateTime @default(now()) - @@unique([walletId, botId]) + @@id([walletId, botId]) @@index([walletId]) @@index([botId]) } @@ -232,10 +263,10 @@ model PendingBot { name String paymentAddress String stakeAddress String? - requestedScopes String // JSON array of requested scopes + requestedScopes String // JSON array of requested scopes status PendingBotStatus @default(UNCLAIMED) - claimedBy String? // ownerAddress of the claiming human - secretCipher String? // Encrypted secret (set on claim, cleared on pickup) + claimedBy String? // ownerAddress of the claiming human + secretCipher String? // Encrypted secret (set on claim, cleared on pickup) pickedUp Boolean @default(false) expiresAt DateTime createdAt DateTime @default(now()) @@ -249,7 +280,7 @@ model BotClaimToken { id String @id @default(cuid()) pendingBotId String @unique pendingBot PendingBot @relation(fields: [pendingBotId], references: [id], onDelete: Cascade) - tokenHash String // SHA-256 hash of the claim code + tokenHash String // SHA-256 hash of the claim code attempts Int @default(0) expiresAt DateTime consumedAt DateTime? @@ -257,3 +288,27 @@ model BotClaimToken { @@index([tokenHash]) } + +// Append-only security audit trail. Writers should never UPDATE existing rows. +// Events are emitted from auth flows, wallet/transaction mutations, and +// privilege-changing actions (bot grants, signer changes, ownership transfers). +model AuditLog { + id String @id @default(cuid()) + actorAddress String? // Wallet address that performed the action (null for system/anonymous) + actorType String // "user" | "bot" | "system" + action String // e.g. "wallet.create", "tx.sign", "bot.grant", "auth.login" + resourceType String? // "wallet" | "transaction" | "bot" | "ballot" | etc. + resourceId String? + ip String? + userAgent String? + outcome String // "success" | "denied" | "error" + reason String? // Short reason on denied/error + metadata Json? // Additional context (no secrets, redacted) + createdAt DateTime @default(now()) + + @@index([actorAddress]) + @@index([action]) + @@index([resourceType, resourceId]) + @@index([createdAt]) + @@index([actorAddress, createdAt]) +} diff --git a/src/__tests__/__mocks__/styleMock.cjs b/src/__tests__/__mocks__/styleMock.cjs new file mode 100644 index 00000000..f053ebf7 --- /dev/null +++ b/src/__tests__/__mocks__/styleMock.cjs @@ -0,0 +1 @@ +module.exports = {}; diff --git a/src/__tests__/apiSecurity.test.ts b/src/__tests__/apiSecurity.test.ts index 667c6959..891567a8 100644 --- a/src/__tests__/apiSecurity.test.ts +++ b/src/__tests__/apiSecurity.test.ts @@ -1,3 +1,4 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { TRPCError } from "@trpc/server"; import { applyRateLimit, enforceBodySize } from "@/lib/security/requestGuards"; @@ -67,6 +68,8 @@ describe("wallet router authorization", () => { db: baseDb as any, session: null, sessionAddress: null, + sessionWallets: [], + primaryWallet: null, ip: "3.3.3.3", }); @@ -93,12 +96,14 @@ describe("wallet router authorization", () => { isArchived: false, verified: [], migrationTargetWalletId: null, - }); + } as never); const caller = createCaller({ db: baseDb as any, session: { user: { id: "addr1" }, expires: new Date().toISOString() } as any, sessionAddress: "addr1", + sessionWallets: ["addr1"], + primaryWallet: "addr1", ip: "4.4.4.4", }); @@ -126,12 +131,14 @@ describe("wallet router authorization", () => { verified: [], migrationTargetWalletId: null, }; - baseDb.wallet.findUnique.mockResolvedValueOnce(wallet); + baseDb.wallet.findUnique.mockResolvedValueOnce(wallet as never); const caller = createCaller({ db: baseDb as any, session: { user: { id: "addr1" }, expires: new Date().toISOString() } as any, sessionAddress: "addr1", + sessionWallets: ["addr1"], + primaryWallet: "addr1", ip: "5.5.5.5", }); diff --git a/src/__tests__/botBallotsUpsert.test.ts b/src/__tests__/botBallotsUpsert.test.ts index 1d487b77..b2423af9 100644 --- a/src/__tests__/botBallotsUpsert.test.ts +++ b/src/__tests__/botBallotsUpsert.test.ts @@ -6,36 +6,35 @@ const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise< const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); const enforceBodySizeMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean>(); -const verifyJwtMock = jest.fn(); -const isBotJwtMock = jest.fn(); -const assertBotWalletAccessMock = jest.fn(); -const findBotUserMock = jest.fn(); -const transactionMock = jest.fn(); -const parseScopeMock = jest.fn(); -const scopeIncludesMock = jest.fn(); -const isValidChoiceMock = jest.fn(); -const parseProposalIdMock = jest.fn(); +const verifyJwtMock = jest.fn<(...args: any[]) => any>(); +const isBotJwtMock = jest.fn<(...args: any[]) => any>(); +const assertBotWalletAccessMock = jest.fn<(...args: any[]) => any>(); +const findBotUserMock = jest.fn<(...args: any[]) => any>(); +const transactionMock = jest.fn<(...args: any[]) => any>(); +const parseScopeMock = jest.fn<(...args: any[]) => any>(); +const scopeIncludesMock = jest.fn<(...args: any[]) => any>(); +const isValidChoiceMock = jest.fn<(...args: any[]) => any>(); +const parseProposalIdMock = jest.fn<(...args: any[]) => any>(); const txMock = { ballot: { - findUnique: jest.fn(), - findMany: jest.fn(), - create: jest.fn(), - updateMany: jest.fn(), + findUnique: jest.fn<(...args: any[]) => any>(), + findMany: jest.fn<(...args: any[]) => any>(), + create: jest.fn<(...args: any[]) => any>(), + updateMany: jest.fn<(...args: any[]) => any>(), }, }; -jest.mock( +jest.unstable_mockModule( "@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/security/requestGuards", () => ({ __esModule: true, @@ -43,49 +42,44 @@ jest.mock( applyBotRateLimit: applyBotRateLimitMock, enforceBodySize: enforceBodySizeMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/verifyJwt", () => ({ __esModule: true, verifyJwt: verifyJwtMock, isBotJwt: isBotJwtMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/governance", () => ({ __esModule: true, isValidChoice: isValidChoiceMock, parseProposalId: parseProposalIdMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/auth/botKey", () => ({ __esModule: true, parseScope: parseScopeMock, scopeIncludes: scopeIncludesMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/auth/botAccess", () => ({ __esModule: true, assertBotWalletAccess: assertBotWalletAccessMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/server/db", () => ({ __esModule: true, @@ -96,7 +90,6 @@ jest.mock( $transaction: transactionMock, }, }), - { virtual: true }, ); type ResponseMock = NextApiResponse & { statusCode?: number }; diff --git a/src/__tests__/governanceActiveProposals.test.ts b/src/__tests__/governanceActiveProposals.test.ts index 3fdfb895..7b1558f3 100644 --- a/src/__tests__/governanceActiveProposals.test.ts +++ b/src/__tests__/governanceActiveProposals.test.ts @@ -5,64 +5,59 @@ const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>() const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); -const verifyJwtMock = jest.fn(); -const isBotJwtMock = jest.fn(); -const findBotUserMock = jest.fn(); -const providerGetMock = jest.fn(); -const parseScopeMock = jest.fn(); -const scopeIncludesMock = jest.fn(); -const getProposalStatusMock = jest.fn(); - -jest.mock( +const verifyJwtMock = jest.fn<(...args: any[]) => any>(); +const isBotJwtMock = jest.fn<(...args: any[]) => any>(); +const findBotUserMock = jest.fn<(...args: any[]) => any>(); +const providerGetMock = jest.fn<(...args: any[]) => any>(); +const parseScopeMock = jest.fn<(...args: any[]) => any>(); +const scopeIncludesMock = jest.fn<(...args: any[]) => any>(); +const getProposalStatusMock = jest.fn<(...args: any[]) => any>(); + +jest.unstable_mockModule( "@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/security/requestGuards", () => ({ __esModule: true, applyRateLimit: applyRateLimitMock, applyBotRateLimit: applyBotRateLimitMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/verifyJwt", () => ({ __esModule: true, verifyJwt: verifyJwtMock, isBotJwt: isBotJwtMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/governance", () => ({ __esModule: true, getProposalStatus: getProposalStatusMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/auth/botKey", () => ({ __esModule: true, parseScope: parseScopeMock, scopeIncludes: scopeIncludesMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/server/db", () => ({ __esModule: true, @@ -72,10 +67,9 @@ jest.mock( }, }, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/utils/get-provider", () => ({ __esModule: true, @@ -83,7 +77,6 @@ jest.mock( get: providerGetMock, }), }), - { virtual: true }, ); type ResponseMock = NextApiResponse & { statusCode?: number }; @@ -214,7 +207,7 @@ describe("governanceActiveProposals API", () => { await handler(req, res); expect(res.status).toHaveBeenCalledWith(200); - const payload = res.json.mock.calls[0]?.[0] as any; + const payload = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; expect(Array.isArray(payload.proposals)).toBe(true); expect(payload.proposals).toHaveLength(1); expect(payload.proposals[0]).toMatchObject({ diff --git a/src/__tests__/multisigSDK.test.ts b/src/__tests__/multisigSDK.test.ts index dbef91da..487e8cc9 100644 --- a/src/__tests__/multisigSDK.test.ts +++ b/src/__tests__/multisigSDK.test.ts @@ -39,9 +39,9 @@ describe('MultisigWallet', () => { ]; const testWallet = new MultisigWallet('Test', unsortedKeys); - expect(testWallet.keys[0].keyHash).toBe('aaaa'); - expect(testWallet.keys[1].keyHash).toBe('mmmm'); - expect(testWallet.keys[2].keyHash).toBe('zzzz'); + expect(testWallet.keys[0]!.keyHash).toBe('aaaa'); + expect(testWallet.keys[1]!.keyHash).toBe('mmmm'); + expect(testWallet.keys[2]!.keyHash).toBe('zzzz'); }); it('should filter out invalid keys', () => { @@ -54,7 +54,7 @@ describe('MultisigWallet', () => { const testWallet = new MultisigWallet('Test', keysWithInvalid); expect(testWallet.keys).toHaveLength(1); - expect(testWallet.keys[0].keyHash).toBe(mockKeyHashes.payment1); + expect(testWallet.keys[0]!.keyHash).toBe(mockKeyHashes.payment1); }); it('should use default values when optional parameters are not provided', () => { @@ -86,7 +86,7 @@ describe('MultisigWallet', () => { it('should return drep keys (role 3)', () => { const drepKeys = wallet.getKeysByRole(3); expect(drepKeys).toHaveLength(1); - expect(drepKeys?.[0].role).toBe(3); + expect(drepKeys?.[0]!.role).toBe(3); }); it('should return undefined for non-existent role', () => { diff --git a/src/__tests__/og.test.ts b/src/__tests__/og.test.ts new file mode 100644 index 00000000..afa0a471 --- /dev/null +++ b/src/__tests__/og.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; + +// SSRF tripwire suite for /api/v1/og +// +// The handler must reject: +// - non-https URLs +// - hosts not on the allowlist +// - hosts that resolve to private / loopback / link-local addresses +// - upstream redirects (no auto-follow) +// +// The most important regression is the IMDS URL case: +// http://169.254.169.254/latest/meta-data/ (AWS instance metadata) +// — historically the canonical SSRF target. If this ever returns 200, an +// attacker who can hit our public OG endpoint can pivot into cloud metadata. + +const dnsLookupMock = jest.fn() as jest.MockedFunction< + (host: string, opts?: unknown) => Promise> +>; + +jest.unstable_mockModule("dns", () => ({ + __esModule: true, + default: { promises: { lookup: dnsLookupMock } }, + promises: { lookup: dnsLookupMock }, +})); + +const envState: { OG_ALLOWED_HOSTS?: string } = {}; +jest.unstable_mockModule("@/env", () => ({ + __esModule: true, + env: new Proxy({}, { + get(_t, key: string) { + if (key === "OG_ALLOWED_HOSTS") return envState.OG_ALLOWED_HOSTS; + return undefined; + }, + }), +})); + +const fetchMock = jest.fn() as jest.MockedFunction; +const realFetch = global.fetch; + +function makeRes() { + const status = jest.fn(); + const json = jest.fn(); + const setHeader = jest.fn(); + const res = { + status: status.mockImplementation(() => res), + json: json.mockImplementation(() => res), + setHeader, + } as unknown as NextApiResponse; + return { res, status, json }; +} + +function makeReq(url: string | undefined): NextApiRequest { + return { + query: url === undefined ? {} : { url }, + method: "GET", + headers: {}, + } as unknown as NextApiRequest; +} + +const handlerPromise = import("../pages/api/v1/og"); + +beforeEach(() => { + dnsLookupMock.mockReset(); + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + envState.OG_ALLOWED_HOSTS = undefined; +}); + +afterEach(() => { + global.fetch = realFetch; +}); + +describe("og handler — SSRF defense", () => { + it("rejects missing url with 400", async () => { + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq(undefined), res); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/missing/i) })); + }); + + it("rejects http:// URLs with 400", async () => { + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("http://github.com/example"), res); + expect(status).toHaveBeenCalledWith(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects IMDS URL (http://169.254.169.254/...) — TRIPWIRE", async () => { + // This test is the one we never let regress. AWS instance metadata URL. + // Even if someone allowlists `*` for OG_ALLOWED_HOSTS, the http:// scheme + // check rejects this immediately. No DNS lookup, no fetch. + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("http://169.254.169.254/latest/meta-data/"), res); + expect(status).toHaveBeenCalledWith(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects https IMDS-style URL when wildcard hosts but private IP", async () => { + // Even with OG_ALLOWED_HOSTS=*, the DNS / address-class check must reject + // direct private-IP literals, including the link-local 169.254.0.0/16. + envState.OG_ALLOWED_HOSTS = "*"; + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq("https://169.254.169.254/"), res); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/private|loopback/i) })); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects host not on the allowlist with 400", async () => { + envState.OG_ALLOWED_HOSTS = "github.com,x.com"; + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("https://evil.example.com/"), res); + expect(status).toHaveBeenCalledWith(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects when DNS resolves to an RFC1918 address", async () => { + envState.OG_ALLOWED_HOSTS = "internal.example.com"; + dnsLookupMock.mockResolvedValueOnce([{ address: "10.0.0.5", family: 4 }]); + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq("https://internal.example.com/"), res); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/private|loopback/i) })); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects when upstream returns a redirect (no auto-follow)", async () => { + envState.OG_ALLOWED_HOSTS = "github.com"; + dnsLookupMock.mockResolvedValueOnce([{ address: "140.82.114.4", family: 4 }]); + fetchMock.mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: "http://169.254.169.254/" } }), + ); + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("https://github.com/example"), res); + expect(status).toHaveBeenCalledWith(500); + }); + + it("returns 200 with extracted OG metadata for an allowlisted public host", async () => { + envState.OG_ALLOWED_HOSTS = "example.com"; + dnsLookupMock.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]); + const html = ` + + + + + `; + fetchMock.mockResolvedValueOnce(new Response(html, { status: 200 })); + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq("https://example.com/page"), res); + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Hello", + description: "World", + image: "https://example.com/img.png", + siteName: "Example", + }), + ); + }); +}); diff --git a/src/__tests__/pendingTransactions.test.ts b/src/__tests__/pendingTransactions.test.ts index bfa54ab8..bef748cc 100644 --- a/src/__tests__/pendingTransactions.test.ts +++ b/src/__tests__/pendingTransactions.test.ts @@ -4,47 +4,73 @@ import type { NextApiRequest, NextApiResponse } from 'next'; const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); -jest.mock( +jest.unstable_mockModule( '@/lib/cors', () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); +type JwtPayloadLike = { address: string; botId?: string; type?: string }; +const verifyJwtMock = jest.fn<(token: string | undefined) => JwtPayloadLike | null>(); +const isBotJwtMock = jest.fn<(payload: JwtPayloadLike) => boolean>( + (payload) => Boolean(payload && (payload as JwtPayloadLike).type === "bot"), +); -jest.mock( +jest.unstable_mockModule( '@/lib/verifyJwt', () => ({ __esModule: true, verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, + }), +); + +const applyRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(() => true); +const applyBotRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, botId: string, maxRequests?: number) => boolean +>(() => true); +const applyStrictRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(() => true); +const enforceBodySizeMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean +>(() => true); + +jest.unstable_mockModule( + '@/lib/security/requestGuards', + () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, + applyStrictRateLimit: applyStrictRateLimitMock, + enforceBodySize: enforceBodySizeMock, + isBodyTooLarge: jest.fn(() => false), }), - { virtual: true }, ); const createCallerMock = jest.fn(); -jest.mock( +jest.unstable_mockModule( '@/server/api/root', () => ({ __esModule: true, createCaller: createCallerMock, }), - { virtual: true }, ); const dbMock = { __type: 'dbMock' }; -jest.mock( +jest.unstable_mockModule( '@/server/db', () => ({ __esModule: true, db: dbMock, }), - { virtual: true }, ); type ResponseMock = NextApiResponse & { statusCode?: number }; @@ -164,13 +190,16 @@ describe('pendingTransactions API route', () => { expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res); expect(corsMock).toHaveBeenCalledWith(req, res); expect(verifyJwtMock).toHaveBeenCalledWith(token); - expect(createCallerMock).toHaveBeenCalledWith({ - db: dbMock, - session: expect.objectContaining({ - user: { id: address }, - expires: expect.any(String), + expect(createCallerMock).toHaveBeenCalledWith( + expect.objectContaining({ + db: dbMock, + session: expect.objectContaining({ + user: { id: address }, + expires: expect.any(String), + }), + sessionAddress: address, }), - }); + ); expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address }); expect(transactionGetPendingTransactionsMock).toHaveBeenCalledWith({ walletId }); expect(res.status).toHaveBeenCalledWith(200); diff --git a/src/__tests__/reviewSignersCardKey.test.ts b/src/__tests__/reviewSignersCardKey.test.ts new file mode 100644 index 00000000..c55c5f9c --- /dev/null +++ b/src/__tests__/reviewSignersCardKey.test.ts @@ -0,0 +1,289 @@ +/** + * Regression test for Finding 1.1 (rocksolid/harden-pr-233): + * React key collision when multiple empty signer rows exist. + * + * The fix introduced a parallel `signerIds: string[]` array in + * useWalletFlowState / useMigrationWalletFlowState that is used as the + * React `key` on each signer row, instead of the (possibly empty, + * possibly duplicated) address string. + * + * This test pins the invariant on two layers: + * 1. The data-shape invariants of the React-key fix — exercised here + * by inline simulation of the hook's add/remove array logic. We + * cannot drive the hook itself in a node-environment jest run + * (the hook depends on next/router, zustand, tRPC, and toast + * providers), so this layer pins the *shape* the hook is + * contracted to maintain: synthetic ids stay distinct across + * empty-row collisions, and removal preserves index alignment + * across all five parallel arrays. + * 2. A source-level tripwire (separate describe block below) that + * catches anyone reverting the JSX back to address-as-key, or + * removing the parallel signerIds array from either hook. The + * React-key behavior itself — that the JSX consumes signerIds + * and that the hook exposes it — is pinned by that tripwire. + * + * Why no `renderHook`: `useWalletFlowState` depends on next/router, + * zustand, tRPC, and toast providers, none of which exist in jest's + * `node` test environment. Adding `@testing-library/react` + jsdom + * would be substantial scaffolding and out of scope for this change. + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { makeSignerId } from "@/components/pages/homepage/wallets/new-wallet-flow/shared/signerRows"; + +// ESM equivalent of CJS __dirname. Tests run under +// node --experimental-vm-modules so CJS globals are unavailable. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Mirror the splice-based array logic that addSigner / removeSigner +// implement inside the two flow-state hooks. We cannot import the hooks +// directly because they pull in next/router, zustand, tRPC, and toast +// providers — none of which exist in jest's node test environment. So +// we replicate the data flow at the same level of abstraction the hook +// uses, and pin the invariant: after add/remove cycles, each parallel +// array stays index-aligned with the others. +type SignerRow = { + address: string; + description: string; + stakeKey: string; + drepKey: string; + id: string; +}; + +function applyAdd(rows: SignerRow[], newId: string): SignerRow[] { + // Mirrors addSigner: pushes empty fields with a fresh synthetic id. + return [ + ...rows, + { address: "", description: "", stakeKey: "", drepKey: "", id: newId }, + ]; +} + +function applyRemove(rows: SignerRow[], index: number): SignerRow[] { + // Mirrors removeSigner: splices the same index out of every parallel + // array. With address-as-key, two empty rows would collide on key="" + // and React could splice the wrong one — synthetic ids prevent that. + const next = rows.slice(); + next.splice(index, 1); + return next; +} + +describe("ReviewSignersCard signer-row key invariant", () => { + test("removing the middle of three signers keeps remaining rows aligned", () => { + const initial: SignerRow[] = [ + { + address: "addr1qx_creator", + description: "Alice", + stakeKey: "stake1_alice", + drepKey: "drep_alice", + id: "id-creator", + }, + ]; + + // User clicks "Add Signer" twice. Both new rows have address "" — + // the bug case. Synthetic ids must still differ. + const afterAdd1 = applyAdd(initial, "id-bob"); + const afterAdd2 = applyAdd(afterAdd1, "id-carol"); + + expect(afterAdd2).toHaveLength(3); + // Two empty addresses, but ids are distinct. + expect(afterAdd2[1]!.address).toBe(""); + expect(afterAdd2[2]!.address).toBe(""); + expect(afterAdd2[1]!.id).not.toBe(afterAdd2[2]!.id); + + // Fill them in so we can tell which row is which. + afterAdd2[1] = { ...afterAdd2[1]!, address: "addr1_bob", description: "Bob", stakeKey: "stake1_bob" }; + afterAdd2[2] = { ...afterAdd2[2]!, address: "addr1_carol", description: "Carol", stakeKey: "stake1_carol" }; + + // Remove Bob (index 1). + const afterRemove = applyRemove(afterAdd2, 1); + + expect(afterRemove).toHaveLength(2); + // Alice still at index 0, Carol now at index 1 — descriptions and + // stake keys must follow the same row, NOT slip out of alignment. + expect(afterRemove[0]!.description).toBe("Alice"); + expect(afterRemove[0]!.stakeKey).toBe("stake1_alice"); + expect(afterRemove[1]!.description).toBe("Carol"); + expect(afterRemove[1]!.stakeKey).toBe("stake1_carol"); + expect(afterRemove[1]!.address).toBe("addr1_carol"); + }); + + test("two consecutive Add Signer clicks produce distinct synthetic ids", () => { + // The exact bug case: with key={signer} (address) two empty rows + // would collide. Synthetic ids must differ regardless of address. + let rows: SignerRow[] = []; + rows = applyAdd(rows, "id-1"); + rows = applyAdd(rows, "id-2"); + + const ids = rows.map((r) => r.id); + expect(new Set(ids).size).toBe(rows.length); + }); + + test( + "addSigner-then-removeSigner: surviving row keeps its synthetic id, " + + "data-shape invariant pinned via inline simulation", + () => { + // Mirrors the array-manipulation contract the hook's `addSigner` + // / `removeSigner` setters apply, inline. `makeSignerId` runs + // unmocked so we exercise real id minting (crypto.randomUUID + // under Node >= 14.17, fallback otherwise). + // + // Scenario: start with one creator-seeded row (the first-user + // effect), append two empty rows, capture the id of the third + // row (the survivor), then splice index 1 — a mid-array empty + // remove. Assert that ids[1] is the survivor's *original* id + // (not a re-issued one) and that every parallel array stays + // index-aligned with ids. + + type Arrays = { + addresses: string[]; + descriptions: string[]; + stakeKeys: string[]; + drepKeys: string[]; + ids: string[]; + }; + + // Seed three rows. Index 0 is the creator (Alice). Index 1 is an + // empty row (Bob, never filled in) — preserves the empty-row + // collision scenario this test was originally written for. Index + // 2 (Carol, the survivor) carries distinct, non-empty values in + // every parallel array. After removing index 1, index 1 must + // hold Carol's distinct values; an off-by-one that mis-spliced + // any single non-id array would leave "" in that array's slot + // and fail the assertion. + let state: Arrays = { + addresses: ["addr1_creator", "", "addr1_carol"], + descriptions: ["Alice", "", "Carol"], + stakeKeys: ["stake1_creator", "", "stake1_carol"], + drepKeys: ["", "", "drep1_carol"], + ids: [makeSignerId(), makeSignerId(), makeSignerId()], + }; + + expect(state.ids).toHaveLength(3); + // Three distinct synthetic ids — the bug case (two empty rows + // sharing key="") cannot reproduce when ids are minted per-row. + expect(new Set(state.ids).size).toBe(3); + + // Survivor: the row currently at index 2. Its id must follow the + // row, not the index, after we remove index 1. + const survivorOriginalId = state.ids[2]!; + + // Splice index 1 out of every parallel array, mirroring + // removeSigner. + const spliceOut = (arr: T[], i: number): T[] => { + const next = arr.slice(); + next.splice(i, 1); + return next; + }; + state = { + addresses: spliceOut(state.addresses, 1), + descriptions: spliceOut(state.descriptions, 1), + stakeKeys: spliceOut(state.stakeKeys, 1), + drepKeys: spliceOut(state.drepKeys, 1), + ids: spliceOut(state.ids, 1), + }; + + expect(state.ids).toHaveLength(2); + // The id now at index 1 must be the survivor's original id — + // proving identity follows the row, not the position. If the hook + // re-minted ids on remove (the broken pattern), this would fail. + expect(state.ids[1]).toBe(survivorOriginalId); + // Every parallel array stays index-aligned with ids AND the + // survivor's distinct values land at index 1. An off-by-one that + // spliced index 2 instead of index 1 would leave "" here in any + // single array — making the splice direction directly testable. + expect(state.addresses[1]).toBe("addr1_carol"); + expect(state.descriptions[1]).toBe("Carol"); + expect(state.stakeKeys[1]).toBe("stake1_carol"); + expect(state.drepKeys[1]).toBe("drep1_carol"); + // Creator row at index 0 untouched. + expect(state.ids[0]).not.toBe(survivorOriginalId); + expect(state.addresses[0]).toBe("addr1_creator"); + expect(state.descriptions[0]).toBe("Alice"); + expect(state.stakeKeys[0]).toBe("stake1_creator"); + }, + ); +}); + +describe("ReviewSignersCard tripwire on source", () => { + // Pin the source-level fix: anyone reverting back to address-as-key + // (or removing the parallel signerIds) will see this test fail. + const SOURCE_PATH = path.resolve( + __dirname, + "../components/pages/homepage/wallets/new-wallet-flow/create/ReviewSignersCard.tsx", + ); + const HOOK_PATH = path.resolve( + __dirname, + "../components/pages/homepage/wallets/new-wallet-flow/shared/useWalletFlowState.tsx", + ); + const MIGRATION_HOOK_PATH = path.resolve( + __dirname, + "../components/pages/wallet/info/migration/useMigrationWalletFlowState.tsx", + ); + + test("ReviewSignersCard never uses the raw address as a React key", () => { + const src = fs.readFileSync(SOURCE_PATH, "utf8"); + + // ---- Negative tripwire ---- + // + // Nothing within `key={ ... }` may start with the bare identifier + // `signer` (the per-iteration address). The boundary class + // `(\s|\}|[^a-zA-Z_0-9])` after `signer` rejects the entire + // address-as-key family: + // - `key={signer}` (the original bug) + // - `key={ signer }` (whitespace variant) + // - `key={signer ?? ""}` (nullish-coalescing fallback) + // - `key={signer.address}` (member-access — different revert) + // - `key={String(signer)}` ('(' is non-alphanumeric) + // + // It deliberately does NOT trip on the synthetic forms + // `key={signerIds[index]}` or `key={signerIds[index] ?? "..."}` + // because the `I` in `Ids` is a-zA-Z and falls outside the + // boundary class — `signer` followed by a word char is fine. + expect(src).not.toMatch(/key=\{\s*signer(\s|\}|[^a-zA-Z_0-9])/); + + // ---- Positive tripwire ---- + // + // Within 200 chars of an opening ` + // without breaking this test (the synthetic-id behavior survives). + // It still fails if anyone reverts to the bare address or to a + // bare index-as-key. + expect(src).toMatch( + //g) ?? []; + const mobileCardBlock = divMobileBlocks.find( + (block) => + /rounded-lg border/.test(block) && + /key=\{[\s\S]{0,80}(signerIds|rowKey\()/.test(block), + ); + expect(mobileCardBlock).toBeDefined(); + }); + + test("useWalletFlowState exposes signerIds parallel to signersAddresses", () => { + const src = fs.readFileSync(HOOK_PATH, "utf8"); + expect(src).toMatch(/signerIds/); + expect(src).toMatch(/setSignerIds/); + }); + + test("useMigrationWalletFlowState exposes signerIds parallel to signersAddresses", () => { + const src = fs.readFileSync(MIGRATION_HOOK_PATH, "utf8"); + expect(src).toMatch(/signerIds/); + expect(src).toMatch(/setSignerIds/); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 8c3f3826..d9260246 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,5 +1,5 @@ // Test setup file for Jest -import '@jest/globals'; +import { jest, beforeEach, afterEach } from '@jest/globals'; // Mock console methods to reduce noise in tests global.console = { @@ -14,3 +14,34 @@ global.console = { // Global test timeout jest.setTimeout(10000); + +// Determinism: freeze the wall clock (`Date.now` / `new Date()`) so tests are +// byte-identical across runs. Timer APIs (setTimeout/setInterval/etc) stay +// real — many tests in this suite hit tRPC's timing middleware and other +// real-async paths that hang under faked timers. Tests that specifically +// exercise timer behavior can opt in via `jest.useFakeTimers()` in `beforeAll`. +beforeEach(() => { + jest.useFakeTimers({ + now: new Date('2026-01-01T00:00:00Z'), + doNotFake: [ + 'nextTick', + 'setImmediate', + 'clearImmediate', + 'queueMicrotask', + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'requestIdleCallback', + 'cancelIdleCallback', + 'hrtime', + 'performance', + ], + }); +}); + +afterEach(() => { + jest.useRealTimers(); +}); diff --git a/src/__tests__/setupEnv.cjs b/src/__tests__/setupEnv.cjs new file mode 100644 index 00000000..1314d88b --- /dev/null +++ b/src/__tests__/setupEnv.cjs @@ -0,0 +1,21 @@ +// @ts-nocheck — env bootstrap; checkJs flags `NODE_ENV` as read-only +// because @types/node narrows it to a literal union, but writing it here +// is intentional and safe (runs before any test module is imported). +// +// Sets dummy env vars so that `src/env.js` (t3-oss validate) does not throw +// when test files import server modules transitively. +// Tests that need real values can override per-test with `process.env.X = ...` +// inside `beforeEach`. + +process.env['NODE_ENV'] = process.env['NODE_ENV'] || 'test'; +process.env.SKIP_ENV_VALIDATION = '1'; + +process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://test:test@localhost:5432/test'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'a'.repeat(48); +process.env.PINATA_JWT = process.env.PINATA_JWT || 'test-pinata-jwt'; + +process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET = + process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET || 'test-blockfrost-mainnet'; +process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD = + process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD || 'test-blockfrost-preprod'; +process.env.NEXT_PUBLIC_NETWORK_ID = process.env.NEXT_PUBLIC_NETWORK_ID || '0'; diff --git a/src/__tests__/signTransaction.test.ts b/src/__tests__/signTransaction.test.ts index f4b2e873..41167d64 100644 --- a/src/__tests__/signTransaction.test.ts +++ b/src/__tests__/signTransaction.test.ts @@ -4,64 +4,73 @@ import type { NextApiRequest, NextApiResponse } from 'next'; const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); -jest.mock( +jest.unstable_mockModule( '@/lib/cors', () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); +type JwtPayloadLike = { address: string; botId?: string; type?: string }; +const verifyJwtMock = jest.fn<(token: string | undefined) => JwtPayloadLike | null>(); +const isBotJwtMock = jest.fn<(payload: JwtPayloadLike) => boolean>( + (payload) => Boolean(payload && (payload as JwtPayloadLike).type === "bot"), +); -jest.mock( +jest.unstable_mockModule( '@/lib/verifyJwt', () => ({ __esModule: true, verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, }), - { virtual: true }, ); const applyRateLimitMock = jest.fn< (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean >(); +const applyStrictRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(); +const applyBotRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, botId: string, maxRequests?: number) => boolean +>(); const enforceBodySizeMock = jest.fn< (req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean >(); -jest.mock( +jest.unstable_mockModule( '@/lib/security/requestGuards', () => ({ __esModule: true, applyRateLimit: applyRateLimitMock, + applyStrictRateLimit: applyStrictRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, enforceBodySize: enforceBodySizeMock, + isBodyTooLarge: jest.fn(() => false), }), - { virtual: true }, ); const getClientIPMock = jest.fn<(req: NextApiRequest) => string>(); -jest.mock( +jest.unstable_mockModule( '@/lib/security/rateLimit', () => ({ __esModule: true, getClientIP: getClientIPMock, }), - { virtual: true }, ); const createCallerMock = jest.fn(); -jest.mock( +jest.unstable_mockModule( '@/server/api/root', () => ({ __esModule: true, createCaller: createCallerMock, }), - { virtual: true }, ); const dbTransactionFindUniqueMock = jest.fn<(args: unknown) => Promise>(); @@ -79,35 +88,32 @@ const dbMock = { }, }; -jest.mock( +jest.unstable_mockModule( '@/server/db', () => ({ __esModule: true, db: dbMock, }), - { virtual: true }, ); const getProviderMock = jest.fn<(network: number) => unknown>(); -jest.mock( +jest.unstable_mockModule( '@/utils/get-provider', () => ({ __esModule: true, getProvider: getProviderMock, }), - { virtual: true }, ); const addressToNetworkMock = jest.fn<(address: string) => number>(); -jest.mock( +jest.unstable_mockModule( '@/utils/multisigSDK', () => ({ __esModule: true, addressToNetwork: addressToNetworkMock, }), - { virtual: true }, ); const shouldSubmitMultisigTxMock = jest.fn< @@ -132,7 +138,7 @@ const addUniqueVkeyWitnessToTxMock = jest.fn< } >(); -jest.mock( +jest.unstable_mockModule( '@/utils/txSignUtils', () => ({ __esModule: true, @@ -141,18 +147,16 @@ jest.mock( shouldSubmitMultisigTx: shouldSubmitMultisigTxMock, submitTxWithScriptRecovery: submitTxWithScriptRecoveryMock, }), - { virtual: true }, ); const resolvePaymentKeyHashMock = jest.fn<(address: string) => string>(); -jest.mock( +jest.unstable_mockModule( '@meshsdk/core', () => ({ __esModule: true, resolvePaymentKeyHash: resolvePaymentKeyHashMock, }), - { virtual: true }, ); const witnessKeyHashHex = '00112233'; @@ -355,14 +359,13 @@ const cslMock = { Vkeywitnesses: MockVkeywitnesses, }; -jest.mock( +jest.unstable_mockModule( '@meshsdk/core-csl', () => ({ __esModule: true, csl: cslMock, calculateTxHash: calculateTxHashMock, }), - { virtual: true }, ); const consoleErrorSpy = jest @@ -420,7 +423,15 @@ beforeEach(() => { addCorsCacheBustingHeadersMock.mockReset(); createCallerMock.mockReset(); verifyJwtMock.mockReset(); + isBotJwtMock.mockReset(); + isBotJwtMock.mockImplementation( + (payload) => Boolean(payload && (payload as JwtPayloadLike).type === "bot"), + ); applyRateLimitMock.mockReset(); + applyStrictRateLimitMock.mockReset(); + applyBotRateLimitMock.mockReset(); + applyBotRateLimitMock.mockReturnValue(true); + applyStrictRateLimitMock.mockReturnValue(true); enforceBodySizeMock.mockReset(); getClientIPMock.mockReset(); @@ -456,7 +467,7 @@ beforeEach(() => { for (let i = 0; i < existingWitnessCount; i++) { const existingWitness = mergedWitnesses.get(i); const existingKeyHash = Buffer.from( - existingWitness.vkey().public_key().hash().to_bytes(), + existingWitness!.vkey().public_key().hash().to_bytes(), ).toString('hex').toLowerCase(); if (existingKeyHash === incomingKeyHash) { diff --git a/src/__tests__/signing.test.ts b/src/__tests__/signing.test.ts new file mode 100644 index 00000000..81d46b3d --- /dev/null +++ b/src/__tests__/signing.test.ts @@ -0,0 +1,107 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, it, jest } from "@jest/globals"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Tripwire: the "broken" pattern — `return true ? signature : undefined;` +// must never reappear in `src/utils/signing.ts`. It both throws away the +// `checkSignature` result and obscures the actual signing contract. The +// regression we fixed here was that a failed signature verification still +// returned a (forged-looking) signature to the caller because the ternary +// was always truthy. +// --------------------------------------------------------------------------- +describe("signing.ts source contract", () => { + it("never returns the always-true ternary on the verification result", () => { + const src = fs.readFileSync( + path.resolve(__dirname, "../utils/signing.ts"), + "utf8", + ); + expect(src).not.toMatch(/return\s+true\s*\?/); + // Positive: the verified result must drive an explicit `if (!verified)` + // throw. The exact identifier we use is `verified` — accept either name + // so a future rename doesn't trip the tripwire. + expect(src).toMatch(/if\s*\(\s*!\s*(verified|result)\b/); + }); +}); + +// --------------------------------------------------------------------------- +// Behavioural: import the real `sign` and exercise every role plus the +// failure path. We mock the @meshsdk/core helpers because they pull in +// CSL/serialization which is heavyweight for a unit test. +// --------------------------------------------------------------------------- +const checkSignatureMock = jest.fn< + (nonce: string, signature: { signature: string; key: string }, address?: string) => Promise +>(); +const generateNonceMock = jest.fn<(payload: string) => string>(); + +jest.unstable_mockModule("@meshsdk/core", () => ({ + __esModule: true, + checkSignature: checkSignatureMock, + generateNonce: generateNonceMock, +})); + +const { sign } = await import("../utils/signing"); + +type MockWallet = { + signData: jest.Mock<(payload: string, address?: string) => Promise<{ signature: string; key: string }>>; + getRewardAddresses: jest.Mock<() => Promise>; +}; + +function createWallet(overrides?: Partial): MockWallet { + return { + signData: jest.fn<(payload: string, address?: string) => Promise<{ signature: string; key: string }>>( + async () => ({ signature: "deadbeef", key: "cafe" }), + ), + getRewardAddresses: jest.fn<() => Promise>(async () => ["stake_addr"]), + ...overrides, + } as MockWallet; +} + +describe("sign", () => { + beforeEach(() => { + checkSignatureMock.mockReset(); + generateNonceMock.mockReset(); + generateNonceMock.mockReturnValue("nonce-payload"); + }); + + it("role=0 signs with the user payment address and returns the signature", async () => { + checkSignatureMock.mockResolvedValueOnce(true); + const wallet = createWallet(); + const sig = await sign("payload", wallet as never, 0, "addr_test_user"); + expect(sig).toEqual({ signature: "deadbeef", key: "cafe" }); + expect(wallet.signData).toHaveBeenCalledWith("payload", "addr_test_user"); + }); + + it("role=2 signs with the wallet's reward (stake) address", async () => { + checkSignatureMock.mockResolvedValueOnce(true); + const wallet = createWallet(); + await sign("payload", wallet as never, 2); + expect(wallet.getRewardAddresses).toHaveBeenCalled(); + expect(wallet.signData).toHaveBeenCalledWith("payload", "stake_addr"); + }); + + it("role=3 requires an explicit dRepAddress and uses it", async () => { + checkSignatureMock.mockResolvedValueOnce(true); + const wallet = createWallet(); + await sign("payload", wallet as never, 3, undefined, "drep_xxx"); + expect(wallet.signData).toHaveBeenCalledWith("payload", "drep_xxx"); + }); + + it("throws when the chosen role has no resolved address", async () => { + const wallet = createWallet(); + await expect(sign("payload", wallet as never, 0, undefined)).rejects.toThrow( + /missing address/i, + ); + }); + + it("throws when checkSignature returns false (no silent ternary fallback)", async () => { + checkSignatureMock.mockResolvedValueOnce(false); + const wallet = createWallet(); + await expect(sign("payload", wallet as never, 0, "addr_test_user")).rejects.toThrow( + /Signature failed verification/i, + ); + }); +}); diff --git a/src/components/common/ImgDragAndDrop.tsx b/src/components/common/ImgDragAndDrop.tsx index d5cfdbd3..227a69e3 100644 --- a/src/components/common/ImgDragAndDrop.tsx +++ b/src/components/common/ImgDragAndDrop.tsx @@ -225,6 +225,7 @@ export default function ImgDragAndDrop({ onImageUpload, initialUrl }: ImgDragAnd state.setUserAssetMetadata, ); const { user, isLoading: isUserLoading } = useUser(); - const { generateNsec } = useNostrChat(); const userAddress = useUserStore((state) => state.userAddress); const setUserAddress = useUserStore((state) => state.setUserAddress); const { toast } = useToast(); @@ -472,12 +470,10 @@ function ConnectWalletContent({ // 4) Create or update user (same as normal wallet) if (!isUserLoading) { - const nostrKey = generateNsec(); createUser({ address, stakeAddress, drepKeyHash, - nostrKey: JSON.stringify(nostrKey), }); } @@ -487,7 +483,7 @@ function ConnectWalletContent({ utxosInitializedRef.current = false; } })(); - }, [isUtxosEnabled, utxosWallet, isUserLoading, createUser, generateNsec, setUserAddress, netId]); + }, [isUtxosEnabled, utxosWallet, isUserLoading, createUser, setUserAddress, netId]); // Handle UTXOS wallet assets and network useEffect(() => { diff --git a/src/components/common/cardano-objects/resolve-adahandle.tsx b/src/components/common/cardano-objects/resolve-adahandle.tsx index d1ba72d8..51ee040b 100644 --- a/src/components/common/cardano-objects/resolve-adahandle.tsx +++ b/src/components/common/cardano-objects/resolve-adahandle.tsx @@ -1,8 +1,14 @@ import { toast } from "@/hooks/use-toast"; import { getProvider } from "@/utils/get-provider"; -//AdaHandle look up provider only supports mainnnet -const provider = getProvider(1) +// AdaHandle lookup is mainnet-only. Lazy-init the provider so the module +// can be imported during SSR / page-data collection without requiring +// NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET to be defined at module-load time. +let cachedProvider: ReturnType | null = null; +const getMainnetProvider = () => { + if (!cachedProvider) cachedProvider = getProvider(1); + return cachedProvider; +}; export const resolveAdaHandle = async ( setAdaHandle: (value: string) => void, @@ -18,7 +24,7 @@ export const resolveAdaHandle = async ( return; } - const address = await provider.fetchHandleAddress(handleName); + const address = await getMainnetProvider().fetchHandleAddress(handleName); if (address) { const newAddresses = [...recipientAddresses]; diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index 2f2da2f8..3efd643d 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -1,7 +1,6 @@ import React, { useEffect, Component, ReactNode, useMemo, useCallback, useState, useRef } from "react"; import { useRouter } from "next/router"; import Link from "next/link"; -import { useNostrChat } from "@jinglescode/nostr-chat-plugin"; import { useWallet, useAddress } from "@meshsdk/react"; import { publicRoutes } from "@/data/public-routes"; import { api } from "@/utils/api"; @@ -94,56 +93,6 @@ class WalletErrorBoundary extends Component< } } -// Component to track layout content changes -function LayoutContentTracker({ - children, - router, - pageIsPublic, - userAddress -}: { - children: ReactNode; - router: ReturnType; - pageIsPublic: boolean; - userAddress: string | undefined; -}) { - const prevPathRef = useRef(router.pathname); - const prevQueryRef = useRef(JSON.stringify(router.query)); - - useEffect(() => { - const handleRouteChangeStart = (url: string) => { - // Route change started - }; - - const handleRouteChangeComplete = (url: string) => { - prevPathRef.current = router.pathname; - prevQueryRef.current = JSON.stringify(router.query); - }; - - const handleRouteChangeError = (err: Error, url: string) => { - // Route change error - }; - - router.events.on('routeChangeStart', handleRouteChangeStart); - router.events.on('routeChangeComplete', handleRouteChangeComplete); - router.events.on('routeChangeError', handleRouteChangeError); - - return () => { - router.events.off('routeChangeStart', handleRouteChangeStart); - router.events.off('routeChangeComplete', handleRouteChangeComplete); - router.events.off('routeChangeError', handleRouteChangeError); - }; - }, [router]); - - useEffect(() => { - if (router.pathname !== prevPathRef.current || JSON.stringify(router.query) !== prevQueryRef.current) { - prevPathRef.current = router.pathname; - prevQueryRef.current = JSON.stringify(router.query); - } - }, [router.pathname, router.query]); - - return <>{children}; -} - export default function RootLayout({ children, }: { @@ -156,7 +105,6 @@ export default function RootLayout({ const router = useRouter(); const { appWallet } = useAppWallet(); const { multisigWallet } = useMultisigWallet(); - const { generateNsec } = useNostrChat(); const { isEnabled: isUtxosEnabled } = useUTXOS(); const userAddress = useUserStore((state) => state.userAddress); @@ -229,7 +177,6 @@ export default function RootLayout({ address: variables.address, stakeAddress: variables.stakeAddress, drepKeyHash: variables.drepKeyHash ?? "", - nostrKey: variables.nostrKey, } ); } @@ -361,12 +308,10 @@ export default function RootLayout({ } // Create or update user - const nostrKey = generateNsec(); createUser({ address: walletAddress, stakeAddress, drepKeyHash, - nostrKey: JSON.stringify(nostrKey), }); } catch (error) { if (error instanceof Error && error.message.includes("account changed")) { @@ -376,7 +321,7 @@ export default function RootLayout({ } initializeWallet(); - }, [connected, activeWallet, user, userAddress, address, createUser, generateNsec]); + }, [connected, activeWallet, user, userAddress, address, createUser]); // Check wallet session and show authorization modal for first-time connections // Check session as soon as wallet is connected and address is available (don't wait for user) @@ -552,6 +497,13 @@ export default function RootLayout({ return (
+ {/* Skip link for keyboard users */} + + Skip to main content + {(shouldShowBackgroundLoading || showPostAuthLoading) && (
@@ -677,7 +629,11 @@ export default function RootLayout({ )} {/* Main content */} -
+
@@ -712,9 +668,7 @@ export default function RootLayout({
} > - - {pageIsPublic || userAddress ? children : } - + {pageIsPublic || userAddress ? children : }
diff --git a/src/components/common/overall-layout/menus/multisig-wallet.tsx b/src/components/common/overall-layout/menus/multisig-wallet.tsx index 4a29fdbc..24148ff5 100644 --- a/src/components/common/overall-layout/menus/multisig-wallet.tsx +++ b/src/components/common/overall-layout/menus/multisig-wallet.tsx @@ -3,7 +3,6 @@ import { useRouter } from "next/router"; import MenuLink from "./menu-link"; import usePendingTransactions from "@/hooks/usePendingTransactions"; import { Badge } from "@/components/ui/badge"; -import { ChatBubbleIcon } from "@radix-ui/react-icons"; import usePendingSignables from "@/hooks/usePendingSignables"; import useMultisigWallet from "@/hooks/useMultisigWallet"; @@ -101,15 +100,6 @@ export default function MenuWallet({ walletId, stakingEnabled }: MenuWalletProps Assets - - - Chat - (null); const [isProxySetup, setIsProxySetup] = useState(false); - const [, setLocalLoading] = useState(false); const [tvlLoading, setTvlLoading] = useState(false); // Setup flow state @@ -149,10 +148,6 @@ export default function ProxyControl() { { address: "", unit: "lovelace", amount: "" } ]); - // UTxO selection state (UI only). We will still pass all UTxOs from provider to contract. - const [, setSelectedUtxos] = useState([]); - const [, setManualSelected] = useState(false); - // Helper to resolve inputs for multisig controlled txs const getMsInputs = useCallback(async (): Promise<{ utxos: UTxO[]; walletAddress: string }> => { if (!appWallet?.address) { @@ -264,8 +259,7 @@ export default function ProxyControl() { try { setSetupLoading(true); - setLocalLoading(true); - + // Reset setup data to prevent conflicts with previous attempts setSetupData({}); setSetupStep(0); @@ -301,7 +295,6 @@ export default function ProxyControl() { }); } finally { setSetupLoading(false); - setLocalLoading(false); } }, [proxyContract, isWalletReady, getMsInputs, newTransaction, toast]); @@ -318,7 +311,6 @@ export default function ProxyControl() { try { setSetupLoading(true); - setLocalLoading(true); // If msCbor is set, route through useTransaction hook to create a signable if (appWallet?.scriptCbor && setupData.txHex) { @@ -395,7 +387,6 @@ export default function ProxyControl() { }); } finally { setSetupLoading(false); - setLocalLoading(false); } }, [setupData, activeWallet, appWallet, createProxy, refetchProxies, getMsInputs, newTransaction, userAddress, toast]); @@ -600,7 +591,6 @@ export default function ProxyControl() { try { setSpendLoading(true); - setLocalLoading(true); // Get the selected proxy const proxy = proxies?.find((p: { id: string }) => p.id === selectedProxyId); @@ -672,7 +662,6 @@ export default function ProxyControl() { }); } finally { setSpendLoading(false); - setLocalLoading(false); } }, [proxyContract, isWalletReady, spendOutputs, selectedProxyId, proxies, network, activeWallet, handleProxySelection, getMsInputs, newTransaction, appWallet?.scriptCbor, toast]); @@ -840,9 +829,9 @@ export default function ProxyControl() { { - setSelectedUtxos(utxos); - setManualSelected(manual); + onSelectionChange={() => { + // Selection is for user visibility only; + // contract uses all UTxOs from the multisig wallet. }} /> diff --git a/src/components/multisig/proxy/ProxyControlExample.tsx b/src/components/multisig/proxy/ProxyControlExample.tsx deleted file mode 100644 index 7c110a04..00000000 --- a/src/components/multisig/proxy/ProxyControlExample.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Info } from "lucide-react"; -import ProxyControl from "./ProxyControl"; - -/** - * Example page demonstrating how to use the ProxyControl component - * - * This component shows how to integrate the ProxyControl into your application - * and provides context about what the proxy system does. - */ -export default function ProxyControlExample() { - return ( -
-
-

Proxy Control System

-

- Manage your Cardano proxy contract for automated and controlled transactions. -

-
- - - - - What is a Proxy Contract?
- A proxy contract allows you to create a controlled address that can be managed through auth tokens. - This enables automated transactions while maintaining security through your multisig wallet. - The proxy can hold assets and execute transactions when you have the required auth tokens. -
-
- - - - How it Works - - Understanding the proxy system workflow - - - -
-
-

1. Setup

-

- Initialize the proxy by minting 10 auth tokens. These tokens are sent to your multisig wallet. -

-
-
-

2. Control

-

- Use auth tokens to authorize spending from the proxy address. Each spend consumes one auth token. -

-
-
-

3. Automate

-

- The proxy can hold assets and execute transactions automatically when properly authorized. -

-
-
-
-
- - - - - - Integration Example - - How to use the ProxyControl component in your application - - - -
-

Basic Usage

-
-{`import ProxyControl from "@/components/multisig/proxy/ProxyControl";
-
-export default function MyPage() {
-  return (
-    
-

My Proxy Management

- -
- ); -}`} -
- -

Key Features

-
    -
  • Automatic wallet connection detection
  • -
  • Proxy setup with auth token minting
  • -
  • Real-time balance monitoring
  • -
  • Multi-output spending capabilities
  • -
  • Integration with multisig transaction system
  • -
  • Error handling and loading states
  • -
  • Responsive design for mobile and desktop
  • -
-
-
-
-
- ); -} - - - diff --git a/src/components/multisig/proxy/index.ts b/src/components/multisig/proxy/index.ts index e7c46daa..c328f688 100644 --- a/src/components/multisig/proxy/index.ts +++ b/src/components/multisig/proxy/index.ts @@ -1,3 +1,2 @@ export { default as ProxyControl } from "./ProxyControl"; -export { default as ProxyControlExample } from "./ProxyControlExample"; export { MeshProxyContract } from "./offchain"; \ No newline at end of file diff --git a/src/components/pages/homepage/governance/drep/index.tsx b/src/components/pages/homepage/governance/drep/index.tsx index 5c7360a8..2a5430a8 100644 --- a/src/components/pages/homepage/governance/drep/index.tsx +++ b/src/components/pages/homepage/governance/drep/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import SectionTitle from "@/components/ui/section-title"; import Pagination from "@/components/common/overall-layout/pagination"; import { getProvider } from "@/utils/get-provider"; @@ -10,6 +10,7 @@ import RowLabelInfo from "@/components/common/row-label-info"; import { TooltipProvider } from "@/components/ui/tooltip"; import ActiveIndicator from "./activeIndicator"; import ScriptIndicator from "./scriptIndicator"; +import { Button } from "@/components/ui/button"; export default function DrepOverviewPage() { const [drepList, setDrepList] = useState< @@ -22,6 +23,7 @@ export default function DrepOverviewPage() { const [order, setOrder] = useState<"asc" | "desc">("asc"); const [isLastPage, setIsLastPage] = useState(false); const [network, setNetwork] = useState(3); // Default to mainnet + const [filter, setFilter] = useState<"all" | "active" | "inactive">("all"); useEffect(() => { async function fetchNetwork() { @@ -112,11 +114,79 @@ export default function DrepOverviewPage() { } }; + const aggregate = useMemo(() => { + let active = 0; + let totalLovelace = 0; + for (const { details } of drepList) { + if (details?.active) active += 1; + const amt = details?.amount ? parseInt(details.amount, 10) : 0; + if (Number.isFinite(amt)) totalLovelace += amt; + } + return { + total: drepList.length, + active, + inactive: drepList.length - active, + totalAda: totalLovelace / 1_000_000, + }; + }, [drepList]); + + const visibleDreps = useMemo(() => { + if (filter === "all") return drepList; + if (filter === "active") return drepList.filter((d) => d.details?.active); + return drepList.filter((d) => !d.details?.active); + }, [drepList, filter]); + return (
DREP Overview + {/* Aggregate stats for current page */} +
+
+
On this page
+
+ {aggregate.total} +
+
+
+
Active
+
+ {aggregate.active} +
+
+
+
Inactive
+
+ {aggregate.inactive} +
+
+
+
ADA delegated
+
+ {aggregate.totalAda >= 1_000_000 + ? `${(aggregate.totalAda / 1_000_000).toFixed(2)}M ₳` + : aggregate.totalAda >= 1_000 + ? `${(aggregate.totalAda / 1_000).toFixed(1)}k ₳` + : `${aggregate.totalAda.toFixed(0)} ₳`} +
+
+
+ + {/* Filter controls */} +
+ {(["all", "active", "inactive"] as const).map((f) => ( + + ))} +
+ {/* Pagination Component */} Loading DREP information...

) : ( - drepList.map(({ details, metadata }) => { + visibleDreps.map(({ details, metadata }) => { const drepId = details.drep_id; const givenName = typeof metadata?.json_metadata?.body?.givenName === "object" @@ -194,6 +264,14 @@ export default function DrepOverviewPage() { copyString={drepId} className="text-sm text-gray-400" /> +
+ {details?.active_epoch != null && ( + Active since epoch {details.active_epoch} + )} + {details?.hex && ( + hex: {details.hex.slice(0, 16)}… + )} +
{/* ADA Amount (Larger, Aligned Right) */} @@ -211,6 +289,11 @@ export default function DrepOverviewPage() { {!loading && drepList.length === 0 && (

No DREP information available.

)} + {!loading && drepList.length > 0 && visibleDreps.length === 0 && ( +

+ No DReps match the {filter} filter on this page. +

+ )}
diff --git a/src/components/pages/homepage/governance/index.tsx b/src/components/pages/homepage/governance/index.tsx index ea91afd0..43e63c9c 100644 --- a/src/components/pages/homepage/governance/index.tsx +++ b/src/components/pages/homepage/governance/index.tsx @@ -3,6 +3,7 @@ import SectionTitle from "@/components/ui/section-title"; import CardUI from "@/components/ui/card-content"; import Button from "@/components/common/button"; import Link from "next/link"; +import GovernanceNetworkStats from "./network-stats"; export default function PageGovernance() { const governanceFeatures = [ @@ -81,6 +82,8 @@ export default function PageGovernance() { wallet experience.

+ + {governanceFeatures.map((feature, index) => ( diff --git a/src/components/pages/homepage/governance/network-stats.tsx b/src/components/pages/homepage/governance/network-stats.tsx new file mode 100644 index 00000000..f6abee25 --- /dev/null +++ b/src/components/pages/homepage/governance/network-stats.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import CardUI from "@/components/ui/card-content"; +import { Users, FileText, Coins } from "lucide-react"; +import { getProvider } from "@/utils/get-provider"; +import { useWallet } from "@meshsdk/react"; +import type { BlockfrostDrepInfo } from "@/types/governance"; + +type Stats = { + drepCount: number | null; + activeDrepCount: number | null; + totalDelegatedAda: number | null; + activeProposals: number | null; +}; + +const INITIAL: Stats = { + drepCount: null, + activeDrepCount: null, + totalDelegatedAda: null, + activeProposals: null, +}; + +function formatNumber(n: number | null): string { + if (n == null) return "…"; + return n.toLocaleString(); +} + +function formatAda(n: number | null): string { + if (n == null) return "…"; + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B ₳`; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M ₳`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k ₳`; + return `${n.toFixed(0)} ₳`; +} + +export default function GovernanceNetworkStats() { + const { wallet, connected } = useWallet(); + const [network, setNetwork] = useState(1); + const [stats, setStats] = useState(INITIAL); + + useEffect(() => { + let cancelled = false; + const fetchNet = async () => { + if (connected && wallet) { + try { + const n = await wallet.getNetworkId(); + if (!cancelled) setNetwork(n); + } catch { + /* default to mainnet */ + } + } + }; + void fetchNet(); + return () => { + cancelled = true; + }; + }, [connected, wallet]); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const provider = getProvider(network); + const [drepsPage, proposalsPage] = await Promise.all([ + provider + .get(`/governance/dreps/?count=100&page=1&order=desc`) + .catch(() => [] as BlockfrostDrepInfo[]), + provider + .get(`/governance/proposals?count=100&page=1&order=desc`) + .catch(() => [] as Array<{ tx_hash: string; cert_index: number }>), + ]); + const dreps = Array.isArray(drepsPage) ? (drepsPage as BlockfrostDrepInfo[]) : []; + const totalLovelace = dreps.reduce((acc, d) => { + const amt = d?.amount ? parseInt(String(d.amount), 10) : 0; + return acc + (Number.isFinite(amt) ? amt : 0); + }, 0); + const activeCount = dreps.filter((d) => Boolean(d?.active)).length; + const proposals = Array.isArray(proposalsPage) ? proposalsPage : []; + + if (!cancelled) { + setStats({ + drepCount: dreps.length, + activeDrepCount: activeCount, + totalDelegatedAda: totalLovelace / 1_000_000, + activeProposals: proposals.length, + }); + } + } catch { + if (!cancelled) setStats(INITIAL); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, [network]); + + return ( + +
+ } + label="DReps tracked" + value={formatNumber(stats.drepCount)} + hint={ + stats.activeDrepCount != null + ? `${stats.activeDrepCount} active` + : "…" + } + /> + } + label="ADA delegated" + value={formatAda(stats.totalDelegatedAda)} + hint="To these DReps" + /> + } + label="Recent proposals" + value={formatNumber(stats.activeProposals)} + hint="Latest 100" + /> + } + label="Network" + value={network === 0 ? "Preprod" : "Mainnet"} + hint="From your wallet, if connected" + /> +
+
+ ); +} + +function Tile({ + icon, + label, + value, + hint, +}: { + icon: React.ReactNode; + label: string; + value: string; + hint?: string; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+ {hint &&
{hint}
} +
+ ); +} diff --git a/src/components/pages/homepage/index.tsx b/src/components/pages/homepage/index.tsx index da2fdd4e..3597dc4d 100644 --- a/src/components/pages/homepage/index.tsx +++ b/src/components/pages/homepage/index.tsx @@ -331,22 +331,6 @@ export function PageHomepage() {
- {/* Chat and Collaborate */} - -
- Team chat -
-
diff --git a/src/components/pages/homepage/wallets/import-transfer-dialog.tsx b/src/components/pages/homepage/wallets/import-transfer-dialog.tsx new file mode 100644 index 00000000..41c620f9 --- /dev/null +++ b/src/components/pages/homepage/wallets/import-transfer-dialog.tsx @@ -0,0 +1,195 @@ +import { useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Loader, Upload, CheckCircle } from "lucide-react"; +import { toast } from "@/hooks/use-toast"; +import { + WALLET_TRANSFER_FORMAT, + WALLET_TRANSFER_VERSION, + type WalletTransferPayloadV1, +} from "@/types/walletTransfer"; + +export function ImportTransferDialog() { + const router = useRouter(); + const fileRef = useRef(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [fileName, setFileName] = useState(null); + const [payload, setPayload] = useState(null); + const [error, setError] = useState(null); + + const reset = () => { + setBusy(false); + setFileName(null); + setPayload(null); + setError(null); + if (fileRef.current) fileRef.current.value = ""; + }; + + const onFile = async (file: File | undefined) => { + if (!file) return; + setError(null); + setFileName(file.name); + try { + const text = await file.text(); + const parsed = JSON.parse(text) as WalletTransferPayloadV1; + if (parsed.format !== WALLET_TRANSFER_FORMAT) { + throw new Error(`Unexpected format: ${String(parsed.format)}`); + } + if (parsed.version !== WALLET_TRANSFER_VERSION) { + throw new Error(`Unsupported payload version: ${String(parsed.version)}`); + } + if (!parsed.wallet?.scriptCbor || !Array.isArray(parsed.wallet.signersAddresses)) { + throw new Error("Payload is missing wallet definition fields"); + } + setPayload(parsed); + } catch (e) { + setPayload(null); + setError(e instanceof Error ? e.message : "Invalid JSON file"); + } + }; + + const submit = async () => { + if (!payload) return; + setBusy(true); + try { + const res = await fetch("/api/v1/wallet/transfer/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error((body as { error?: string })?.error ?? `Import failed (${res.status})`); + } + const result = body as { newWalletId: string; inviteUrl: string }; + toast({ + title: "Wallet imported", + description: "Redirecting to the invite page so signers can claim.", + }); + setOpen(false); + reset(); + await router.push(`/wallets/invite/${result.newWalletId}`); + } catch (e) { + const msg = e instanceof Error ? e.message : "Import failed"; + setError(msg); + toast({ title: "Import failed", description: msg, variant: "destructive" }); + } finally { + setBusy(false); + } + }; + + return ( + <> + + { + if (!o) reset(); + setOpen(o); + }} + > + + + Import wallet transfer + + Upload a wallet transfer JSON exported from another Multisig + instance. + + + +
+
+ + onFile(e.target.files?.[0])} + /> + {fileName && ( +

{fileName}

+ )} +
+ + {error && ( + + {error} + + )} + + {payload && !error && ( + + + +
+
+ {payload.wallet.name} +
+
+ {payload.wallet.signersAddresses.length} signer + {payload.wallet.signersAddresses.length === 1 ? "" : "s"} + {" · "} + type: {payload.wallet.type} + {payload.contacts ? ` · ${payload.contacts.length} contacts` : ""} + {payload.ballots ? ` · ${payload.ballots.length} ballots` : ""} +
+ {payload.exportedFromOrigin && ( +
+ from {payload.exportedFromOrigin} +
+ )} +
+
+
+ )} +
+ + + + + +
+
+ + ); +} diff --git a/src/components/pages/homepage/wallets/index.tsx b/src/components/pages/homepage/wallets/index.tsx index e6a6d379..8ef52bcf 100644 --- a/src/components/pages/homepage/wallets/index.tsx +++ b/src/components/pages/homepage/wallets/index.tsx @@ -25,6 +25,7 @@ import SectionExplanation from "./SectionExplanation"; import WalletCardSkeleton from "./WalletCardSkeleton"; import WalletInviteCardSkeleton from "./WalletInviteCardSkeleton"; import IPFSImage from "@/components/common/ipfs-image"; +import { ImportTransferDialog } from "./import-transfer-dialog"; export default function PageWallets() { @@ -123,6 +124,7 @@ export default function PageWallets() { + {wallets && wallets.some((wallet) => wallet.isArchived) && ( + +
Bots