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 */}
-
-
-
-
-
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 (
+ <>
+
+
+ >
+ );
+}
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) && (