From a149b64965c7d9b1e77627b758aecc60eaac34d9 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:12:58 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(governance):=20schema=20v2=20+=20domai?= =?UTF-8?q?n=20core=20=E2=80=94=20humans=20and=20agents=20as=20first-class?= =?UTF-8?q?=20voting=20members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1+S2 of the Solon v1 plan: replace the facade data model with an honest one and put the governance logic under test for the first time. Schema v2 (drop-and-rebuild baseline; prod schema was verified empty and pre-reset on bitbaum so the new baseline applies cleanly): - Member carries memberType HUMAN|AGENT, its own bitcoinAddress + publicKeyHex, votingWeight, and optional ocActorId (OrangeCat identity link) - VotingSession snapshots its rules at open (electorate, threshold, quorum, eligibleCount, eligibleWeight) so past decisions stay explainable forever - Vote stores the exact signedMessage + signature — every tally is re-verifiable - Policy versions are unique per (org, key, version); only an APPROVED session can create v2+ (approvedBySessionId null is reserved for the v1 bootstrap) - Append-only AuditEvent, watch-only TreasurySource, sha256-hashed AgentApiKey - Idempotent seed migration: org `orangecat` + allocation_policy v1 ACTIVE Domain layer (lib/domain, no HTTP, no UI): - config/governance.ts is the electorate SSOT: agents vote in ALLOCATION_POLICY / TREASURY_SPEND / OPERATIONS; AID_DISBURSEMENT / MEMBERSHIP / SAFETY / GOVERNANCE_RULES are HUMANS_ONLY (red lines: agents can never vote money to people or expand their own suffrage) - tally.ts: pure tally + decideOutcome (abstain counts toward quorum, not threshold; silence is not consent — below-quorum sessions EXPIRE) - voting.ts: openSession (gate + snapshot at open), submitVote (signature → member → electorate check → weighted upsert + audit), closeSession (outcome from snapshots; APPROVED policy proposals mint the next ACTIVE version) - treasury.ts: live mempool.space balances, null on failure — never a guess Bitcoin message layer extended (load-bearing for real wallets): - BIP137 headers 27-42 accepted; claimed address matched against P2PKH, P2WPKH (bech32), and P2SH-P2WPKH derivations, so Sparrow/Electrum/Core signatures all verify — including Electrum's legacy-header-on-segwit quirk - proposalMessage() canonical form alongside voteMessage() Vitest wired into verify (lint + typecheck + test); 25 tests cover golden message strings, sign/verify roundtrips incl. tamper/replay, segwit derivation and header re-banding, the tally/threshold/quorum matrix, and config completeness over every DecisionCategory. Co-Authored-By: Claude Fable 5 --- .eslintrc.json | 1 + package-lock.json | 1188 ++++++++++++++++- package.json | 10 +- prisma/migrations/0_init/migration.sql | 231 ++-- prisma/migrations/1_seed_org1/migration.sql | 48 + prisma/migrations/migration_lock.toml | 1 - prisma/schema.prisma | 404 ++++-- .../(dashboard)/dashboard/treasury/page.tsx | 29 +- src/app/(dashboard)/dashboard/voting/page.tsx | 23 +- src/app/api/bitcoin/wallet/[orgId]/route.ts | 60 +- .../[sessionId]/cryptographic-vote/route.ts | 14 +- .../bitcoin/signature-validator.tsx | 10 - src/components/bitcoin/transaction-viewer.tsx | 19 - src/components/dashboard/bitcoin-treasury.tsx | 64 +- src/components/dashboard/voting-interface.tsx | 4 +- src/lib/bitcoin/__tests__/message.test.ts | 109 ++ src/lib/bitcoin/message.ts | 69 +- src/lib/config/__tests__/governance.test.ts | 41 + src/lib/config/governance.ts | 53 + src/lib/domain/__tests__/tally.test.ts | 75 ++ src/lib/domain/tally.ts | 50 + src/lib/domain/treasury.ts | 48 + src/lib/domain/voting.ts | 280 ++++ src/lib/solon/democracy.ts | 78 -- src/lib/solon/types.ts | 71 - vitest.config.ts | 14 + 26 files changed, 2446 insertions(+), 548 deletions(-) create mode 100644 prisma/migrations/1_seed_org1/migration.sql delete mode 100644 prisma/migrations/migration_lock.toml delete mode 100644 src/components/bitcoin/signature-validator.tsx delete mode 100644 src/components/bitcoin/transaction-viewer.tsx create mode 100644 src/lib/bitcoin/__tests__/message.test.ts create mode 100644 src/lib/config/__tests__/governance.test.ts create mode 100644 src/lib/config/governance.ts create mode 100644 src/lib/domain/__tests__/tally.test.ts create mode 100644 src/lib/domain/tally.ts create mode 100644 src/lib/domain/treasury.ts create mode 100644 src/lib/domain/voting.ts delete mode 100644 src/lib/solon/democracy.ts delete mode 100644 src/lib/solon/types.ts create mode 100644 vitest.config.ts diff --git a/.eslintrc.json b/.eslintrc.json index bffb357..a2569c2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,4 @@ { + "root": true, "extends": "next/core-web-vitals" } diff --git a/package-lock.json b/package-lock.json index b786dc5..d5b2f19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,15 @@ "@noble/hashes": "^1.8.0", "@noble/secp256k1": "^2.3.0", "@prisma/client": "^5.17.0", + "@scure/base": "^2.2.0", "bs58check": "^3.0.1", "clsx": "2.1.1", "lucide-react": "0.441.0", "next": "14.2.5", "react": "18.3.1", "react-dom": "18.3.1", - "tailwindcss": "3.4.9" + "tailwindcss": "3.4.9", + "zod": "^4.4.3" }, "devDependencies": { "@playwright/test": "1.46.1", @@ -31,7 +33,8 @@ "prisma": "5.17.0", "puppeteer": "22.15.0", "ts-node": "10.9.2", - "typescript": "5.5.3" + "typescript": "5.5.3", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -623,6 +626,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -740,6 +753,269 @@ "node": ">=18" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -754,6 +1030,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -816,6 +1108,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -1351,10 +1668,96 @@ ], "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/acorn": { "version": "8.15.0", @@ -1655,6 +2058,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -2108,6 +2521,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2176,6 +2599,16 @@ "devtools-protocol": "*" } }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2271,6 +2704,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -2485,6 +2925,16 @@ "node": ">= 14" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1312386", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", @@ -2740,6 +3190,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3329,6 +3786,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3339,6 +3806,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -4690,31 +5167,304 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -4786,6 +5536,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -5197,6 +5957,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5415,6 +6189,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -6006,6 +6787,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6252,6 +7066,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6342,6 +7163,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6802,6 +7637,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6850,6 +7702,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7205,6 +8067,258 @@ "devOptional": true, "license": "MIT" }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7309,6 +8423,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7550,10 +8681,9 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 4fc91ab..de52032 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "verify": "npm run lint && npm run typecheck", + "verify": "npm run lint && npm run typecheck && npm run test", + "test": "vitest run", "test:e2e": "playwright test", "test:puppeteer": "BASE_URL=${BASE_URL:-http://localhost:3000} node tests/puppeteer/smoke.mjs", "test:puppeteer:mega": "BASE_URL=${BASE_URL:-http://localhost:3000} node tests/puppeteer/mega-menu.mjs", @@ -19,13 +20,15 @@ "@noble/hashes": "^1.8.0", "@noble/secp256k1": "^2.3.0", "@prisma/client": "^5.17.0", + "@scure/base": "^2.2.0", "bs58check": "^3.0.1", "clsx": "2.1.1", "lucide-react": "0.441.0", "next": "14.2.5", "react": "18.3.1", "react-dom": "18.3.1", - "tailwindcss": "3.4.9" + "tailwindcss": "3.4.9", + "zod": "^4.4.3" }, "devDependencies": { "@playwright/test": "1.46.1", @@ -39,6 +42,7 @@ "prisma": "5.17.0", "puppeteer": "22.15.0", "ts-node": "10.9.2", - "typescript": "5.5.3" + "typescript": "5.5.3", + "vitest": "^4.1.10" } } diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql index 74fa3f7..e1511b2 100644 --- a/prisma/migrations/0_init/migration.sql +++ b/prisma/migrations/0_init/migration.sql @@ -1,12 +1,45 @@ +-- CreateEnum +CREATE TYPE "MemberType" AS ENUM ('HUMAN', 'AGENT'); + +-- CreateEnum +CREATE TYPE "KeyCustody" AS ENUM ('SELF', 'SERVICE'); + +-- CreateEnum +CREATE TYPE "MemberStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'RETIRED'); + +-- CreateEnum +CREATE TYPE "DecisionCategory" AS ENUM ('ALLOCATION_POLICY', 'TREASURY_SPEND', 'OPERATIONS', 'AID_DISBURSEMENT', 'MEMBERSHIP', 'SAFETY', 'GOVERNANCE_RULES'); + +-- CreateEnum +CREATE TYPE "Electorate" AS ENUM ('ALL_MEMBERS', 'HUMANS_ONLY'); + +-- CreateEnum +CREATE TYPE "VoteThreshold" AS ENUM ('SIMPLE_MAJORITY', 'SUPERMAJORITY'); + +-- CreateEnum +CREATE TYPE "ProposalStatus" AS ENUM ('DRAFT', 'OPEN', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "SessionStatus" AS ENUM ('ACTIVE', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "SessionOutcome" AS ENUM ('APPROVED', 'REJECTED', 'EXPIRED'); + +-- CreateEnum +CREATE TYPE "VoteChoice" AS ENUM ('YES', 'NO', 'ABSTAIN'); + +-- CreateEnum +CREATE TYPE "PolicyStatus" AS ENUM ('ACTIVE', 'SUPERSEDED'); + +-- CreateEnum +CREATE TYPE "AuditEventType" AS ENUM ('ORG_CREATED', 'MEMBER_ADDED', 'MEMBER_STATUS_CHANGED', 'PROPOSAL_CREATED', 'SESSION_OPENED', 'VOTE_CAST', 'SESSION_CLOSED', 'POLICY_ACTIVATED'); + -- CreateTable CREATE TABLE "organizations" ( "id" TEXT NOT NULL, + "slug" TEXT NOT NULL, "name" TEXT NOT NULL, - "type" TEXT NOT NULL, - "bitcoin_wallet_xpub" TEXT NOT NULL, - "governance_model" TEXT NOT NULL DEFAULT 'democratic', - "country" TEXT NOT NULL DEFAULT 'CH', - "primary_language" TEXT NOT NULL DEFAULT 'en', + "description" TEXT, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") @@ -16,63 +49,53 @@ CREATE TABLE "organizations" ( CREATE TABLE "members" ( "id" TEXT NOT NULL, "organization_id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "bitcoin_address" VARCHAR(62), - "role" TEXT NOT NULL, - "voting_weight" DECIMAL(65,30) NOT NULL DEFAULT 1.0, + "display_name" TEXT NOT NULL, + "member_type" "MemberType" NOT NULL, + "key_custody" "KeyCustody" NOT NULL, + "bitcoin_address" VARCHAR(90) NOT NULL, + "public_key_hex" TEXT, + "voting_weight" DECIMAL(10,2) NOT NULL DEFAULT 1, + "status" "MemberStatus" NOT NULL DEFAULT 'ACTIVE', + "oc_actor_id" TEXT, + "system" TEXT, "joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "status" TEXT NOT NULL DEFAULT 'active', CONSTRAINT "members_pkey" PRIMARY KEY ("id") ); -- CreateTable -CREATE TABLE "bitcoin_transactions" ( - "id" TEXT NOT NULL, - "organization_id" TEXT NOT NULL, - "txid" TEXT NOT NULL, - "amount_sats" BIGINT NOT NULL, - "category" TEXT NOT NULL, - "description" TEXT, - "to_address" VARCHAR(62), - "from_address" VARCHAR(62), - "block_height" INTEGER, - "transaction_date" TIMESTAMP(3) NOT NULL, - "created_by" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "bitcoin_transactions_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "decisions" ( +CREATE TABLE "proposals" ( "id" TEXT NOT NULL, "organization_id" TEXT NOT NULL, + "category" "DecisionCategory" NOT NULL, "title" TEXT NOT NULL, - "description" TEXT, - "decision_type" TEXT NOT NULL, - "status" TEXT NOT NULL DEFAULT 'proposed', - "effectiveness_kpi" JSONB, - "origin_member_id" TEXT, - "bitcoin_signature" TEXT, + "body" TEXT NOT NULL, + "policy_key" TEXT, + "proposed_content" JSONB, + "target" TEXT, + "content_hash" TEXT, + "proposer_member_id" TEXT NOT NULL, + "proposer_signature" TEXT NOT NULL, + "status" "ProposalStatus" NOT NULL DEFAULT 'DRAFT', "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "voting_deadline" TIMESTAMP(3), - CONSTRAINT "decisions_pkey" PRIMARY KEY ("id") + CONSTRAINT "proposals_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "voting_sessions" ( "id" TEXT NOT NULL, - "organization_id" TEXT NOT NULL, - "decision_id" TEXT, - "title" TEXT NOT NULL, - "voting_type" TEXT NOT NULL, - "status" TEXT NOT NULL DEFAULT 'active', - "start_date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "end_date" TIMESTAMP(3), - "total_votes_cast" INTEGER NOT NULL DEFAULT 0, - "bitcoin_signature_required" BOOLEAN NOT NULL DEFAULT true, + "proposal_id" TEXT NOT NULL, + "status" "SessionStatus" NOT NULL DEFAULT 'ACTIVE', + "opens_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "closes_at" TIMESTAMP(3) NOT NULL, + "electorate" "Electorate" NOT NULL, + "threshold" "VoteThreshold" NOT NULL, + "quorum_percent" INTEGER NOT NULL, + "eligible_count" INTEGER NOT NULL, + "eligible_weight" DECIMAL(12,2) NOT NULL, + "outcome" "SessionOutcome", + "closed_at" TIMESTAMP(3), CONSTRAINT "voting_sessions_pkey" PRIMARY KEY ("id") ); @@ -80,94 +103,124 @@ CREATE TABLE "voting_sessions" ( -- CreateTable CREATE TABLE "votes" ( "id" TEXT NOT NULL, - "voting_session_id" TEXT NOT NULL, + "session_id" TEXT NOT NULL, "member_id" TEXT NOT NULL, - "vote_choice" TEXT NOT NULL, - "weight" DECIMAL(65,30) NOT NULL DEFAULT 1.0, - "bitcoin_signature" TEXT NOT NULL, - "signed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "choice" "VoteChoice" NOT NULL, + "weight" DECIMAL(10,2) NOT NULL, + "signed_message" TEXT NOT NULL, + "signature" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "votes_pkey" PRIMARY KEY ("id") ); -- CreateTable -CREATE TABLE "service_requests" ( +CREATE TABLE "policies" ( "id" TEXT NOT NULL, "organization_id" TEXT NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT, - "budget_sats" BIGINT, - "status" TEXT NOT NULL DEFAULT 'open', - "evaluation_criteria" JSONB, - "created_by" TEXT, + "key" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "content" JSONB NOT NULL, + "status" "PolicyStatus" NOT NULL, + "approved_by_session_id" TEXT, + "activated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "policies_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "treasury_sources" ( + "id" TEXT NOT NULL, + "organization_id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "address" VARCHAR(90) NOT NULL, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deadline" TIMESTAMP(3), - CONSTRAINT "service_requests_pkey" PRIMARY KEY ("id") + CONSTRAINT "treasury_sources_pkey" PRIMARY KEY ("id") ); -- CreateTable -CREATE TABLE "service_bids" ( +CREATE TABLE "audit_events" ( "id" TEXT NOT NULL, - "service_request_id" TEXT NOT NULL, - "bidder_name" TEXT NOT NULL, - "bitcoin_address" VARCHAR(62) NOT NULL, - "bid_amount_sats" BIGINT NOT NULL, - "proposal" TEXT, - "submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "service_bids_pkey" PRIMARY KEY ("id") + "organization_id" TEXT NOT NULL, + "event_type" "AuditEventType" NOT NULL, + "actor_member_id" TEXT, + "subject_type" TEXT NOT NULL, + "subject_id" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id") ); -- CreateTable -CREATE TABLE "budget_allocations" ( +CREATE TABLE "agent_api_keys" ( "id" TEXT NOT NULL, - "organization_id" TEXT NOT NULL, - "category" TEXT NOT NULL, - "allocated_sats" BIGINT NOT NULL, - "spent_sats" BIGINT NOT NULL DEFAULT 0, - "year" INTEGER NOT NULL, - "approved_by_vote" TEXT, + "member_id" TEXT NOT NULL, + "key_hash" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMP(3), - CONSTRAINT "budget_allocations_pkey" PRIMARY KEY ("id") + CONSTRAINT "agent_api_keys_pkey" PRIMARY KEY ("id") ); -- CreateIndex -CREATE UNIQUE INDEX "bitcoin_transactions_txid_key" ON "bitcoin_transactions"("txid"); +CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug"); -- CreateIndex -CREATE UNIQUE INDEX "votes_voting_session_id_member_id_key" ON "votes"("voting_session_id", "member_id"); +CREATE UNIQUE INDEX "members_oc_actor_id_key" ON "members"("oc_actor_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "members_organization_id_bitcoin_address_key" ON "members"("organization_id", "bitcoin_address"); + +-- CreateIndex +CREATE UNIQUE INDEX "voting_sessions_proposal_id_key" ON "voting_sessions"("proposal_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "votes_session_id_member_id_key" ON "votes"("session_id", "member_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "policies_organization_id_key_version_key" ON "policies"("organization_id", "key", "version"); + +-- CreateIndex +CREATE UNIQUE INDEX "treasury_sources_organization_id_address_key" ON "treasury_sources"("organization_id", "address"); + +-- CreateIndex +CREATE INDEX "audit_events_organization_id_created_at_idx" ON "audit_events"("organization_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "agent_api_keys_key_hash_key" ON "agent_api_keys"("key_hash"); -- AddForeignKey ALTER TABLE "members" ADD CONSTRAINT "members_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "bitcoin_transactions" ADD CONSTRAINT "bitcoin_transactions_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "proposals" ADD CONSTRAINT "proposals_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "decisions" ADD CONSTRAINT "decisions_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "proposals" ADD CONSTRAINT "proposals_proposer_member_id_fkey" FOREIGN KEY ("proposer_member_id") REFERENCES "members"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "decisions" ADD CONSTRAINT "decisions_origin_member_id_fkey" FOREIGN KEY ("origin_member_id") REFERENCES "members"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "voting_sessions" ADD CONSTRAINT "voting_sessions_proposal_id_fkey" FOREIGN KEY ("proposal_id") REFERENCES "proposals"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "voting_sessions" ADD CONSTRAINT "voting_sessions_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "votes" ADD CONSTRAINT "votes_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "voting_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "voting_sessions" ADD CONSTRAINT "voting_sessions_decision_id_fkey" FOREIGN KEY ("decision_id") REFERENCES "decisions"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "votes" ADD CONSTRAINT "votes_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "votes" ADD CONSTRAINT "votes_voting_session_id_fkey" FOREIGN KEY ("voting_session_id") REFERENCES "voting_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "policies" ADD CONSTRAINT "policies_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "votes" ADD CONSTRAINT "votes_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "policies" ADD CONSTRAINT "policies_approved_by_session_id_fkey" FOREIGN KEY ("approved_by_session_id") REFERENCES "voting_sessions"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "service_requests" ADD CONSTRAINT "service_requests_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "treasury_sources" ADD CONSTRAINT "treasury_sources_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "service_bids" ADD CONSTRAINT "service_bids_service_request_id_fkey" FOREIGN KEY ("service_request_id") REFERENCES "service_requests"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "budget_allocations" ADD CONSTRAINT "budget_allocations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "agent_api_keys" ADD CONSTRAINT "agent_api_keys_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/1_seed_org1/migration.sql b/prisma/migrations/1_seed_org1/migration.sql new file mode 100644 index 0000000..d10b29a --- /dev/null +++ b/prisma/migrations/1_seed_org1/migration.sql @@ -0,0 +1,48 @@ +-- Seed organization #1: the OrangeCat platform, Solon's first governed workload. +-- Reference data ships as a migration so a deploy can never leave prod empty +-- ("deployed" must equal "working"). Idempotent via ON CONFLICT DO NOTHING. + +INSERT INTO "organizations" ("id", "slug", "name", "description") +VALUES ( + '84c9b96e-31a5-4b62-bb2f-0d05a53c31f7', + 'orangecat', + 'OrangeCat', + 'The OrangeCat platform — economic layer of the stack. Solon governs its platform-level allocation policy; the first governed artifact is the Cat''s own spending ceiling.' +) +ON CONFLICT ("slug") DO NOTHING; + +-- Bootstrap allocation policy v1. approved_by_session_id is NULL exactly once: +-- this version establishes the baseline, and domain code refuses to activate +-- any later version without an APPROVED voting session. +INSERT INTO "policies" ("id", "organization_id", "key", "version", "content", "status", "approved_by_session_id") +VALUES ( + 'f3b3ac5e-9f34-4f6e-9c8a-7b8f2a3d4e5f', + '84c9b96e-31a5-4b62-bb2f-0d05a53c31f7', + 'allocation_policy', + 1, + '{"max_cat_daily_spend_btc": 0.001, "max_cat_btc_per_action": 0.00025}', + 'ACTIVE', + NULL +) +ON CONFLICT ("organization_id", "key", "version") DO NOTHING; + +-- Genesis audit trail — the public record starts at the beginning. +INSERT INTO "audit_events" ("id", "organization_id", "event_type", "subject_type", "subject_id", "payload") +VALUES + ( + '11c0ffee-0001-4000-8000-000000000001', + '84c9b96e-31a5-4b62-bb2f-0d05a53c31f7', + 'ORG_CREATED', + 'organization', + '84c9b96e-31a5-4b62-bb2f-0d05a53c31f7', + '{"slug": "orangecat", "note": "seeded as Solon organization #1"}' + ), + ( + '11c0ffee-0002-4000-8000-000000000002', + '84c9b96e-31a5-4b62-bb2f-0d05a53c31f7', + 'POLICY_ACTIVATED', + 'policy', + 'f3b3ac5e-9f34-4f6e-9c8a-7b8f2a3d4e5f', + '{"key": "allocation_policy", "version": 1, "note": "bootstrap version — every later version requires an APPROVED Solon voting session"}' + ) +ON CONFLICT ("id") DO NOTHING; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml deleted file mode 100644 index 77b8de7..0000000 --- a/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1 +0,0 @@ -provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d42285..0a104d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,4 +1,14 @@ -// Prisma schema reflecting the Solon MVP database +// Solon governance schema v2 — the SSOT for all types (client generated from here). +// +// Design invariants: +// - Solon never holds private keys: members (human OR agent) register a Bitcoin +// address; every vote and proposal carries a Bitcoin signed-message signature. +// - VotingSession snapshots its rules (electorate/threshold/quorum/eligibility) +// at open, so a past decision stays explainable after policy changes. +// - AuditEvent is append-only: no code path may update or delete rows. +// - Policy versions: v1 is the seeded bootstrap; every later version requires an +// APPROVED voting session (enforced in src/lib/domain/voting.ts). + generator client { provider = "prisma-client-js" } @@ -8,143 +18,259 @@ datasource db { url = env("DATABASE_URL") } -model organizations { - id String @id @default(uuid()) - name String - type String - bitcoin_wallet_xpub String - governance_model String @default("democratic") - country String @default("CH") - primary_language String @default("en") - created_at DateTime @default(now()) - - members members[] - bitcoin_transactions bitcoin_transactions[] - decisions decisions[] - voting_sessions voting_sessions[] - service_requests service_requests[] - budget_allocations budget_allocations[] -} - -model members { - id String @id @default(uuid()) - organization_id String - user_id String - bitcoin_address String? @db.VarChar(62) - role String - voting_weight Decimal @default(1.0) - joined_at DateTime @default(now()) - status String @default("active") - - organization organizations @relation(fields: [organization_id], references: [id]) - votes votes[] - decisions_origin decisions[] @relation("decisions_origin_member") -} - -model bitcoin_transactions { - id String @id @default(uuid()) - organization_id String - txid String @unique - amount_sats BigInt - category String - description String? - to_address String? @db.VarChar(62) - from_address String? @db.VarChar(62) - block_height Int? - transaction_date DateTime - created_by String? - created_at DateTime @default(now()) - - organization organizations @relation(fields: [organization_id], references: [id]) -} - -model decisions { - id String @id @default(uuid()) - organization_id String - title String - description String? - decision_type String - status String @default("proposed") - effectiveness_kpi Json? - origin_member_id String? - bitcoin_signature String? - created_at DateTime @default(now()) - voting_deadline DateTime? - - organization organizations @relation(fields: [organization_id], references: [id]) - origin_member members? @relation("decisions_origin_member", fields: [origin_member_id], references: [id]) - voting_sessions voting_sessions[] -} - -model voting_sessions { - id String @id @default(uuid()) - organization_id String - decision_id String? - title String - voting_type String - status String @default("active") - start_date DateTime @default(now()) - end_date DateTime? - total_votes_cast Int @default(0) - bitcoin_signature_required Boolean @default(true) - - organization organizations @relation(fields: [organization_id], references: [id]) - decision decisions? @relation(fields: [decision_id], references: [id]) - votes votes[] -} - -model votes { - id String @id @default(uuid()) - voting_session_id String - member_id String - vote_choice String - weight Decimal @default(1.0) - bitcoin_signature String - signed_at DateTime @default(now()) - - voting_session voting_sessions @relation(fields: [voting_session_id], references: [id]) - member members @relation(fields: [member_id], references: [id]) - - @@unique([voting_session_id, member_id]) -} - -model service_requests { - id String @id @default(uuid()) - organization_id String - title String - description String? - budget_sats BigInt? - status String @default("open") - evaluation_criteria Json? - created_by String? - created_at DateTime @default(now()) - deadline DateTime? - - organization organizations @relation(fields: [organization_id], references: [id]) - service_bids service_bids[] -} - -model service_bids { - id String @id @default(uuid()) - service_request_id String - bidder_name String - bitcoin_address String @db.VarChar(62) - bid_amount_sats BigInt - proposal String? - submitted_at DateTime @default(now()) - - service_request service_requests @relation(fields: [service_request_id], references: [id]) -} - -model budget_allocations { - id String @id @default(uuid()) - organization_id String - category String - allocated_sats BigInt - spent_sats BigInt @default(0) - year Int - approved_by_vote String? - - organization organizations @relation(fields: [organization_id], references: [id]) +enum MemberType { + HUMAN + AGENT +} + +/// Who holds the member's private key. Never Solon — SELF is a human's own +/// wallet, SERVICE is an agent system's own environment (OC box, FC box). +enum KeyCustody { + SELF + SERVICE +} + +enum MemberStatus { + ACTIVE + SUSPENDED + RETIRED +} + +enum DecisionCategory { + ALLOCATION_POLICY + TREASURY_SPEND + OPERATIONS + AID_DISBURSEMENT + MEMBERSHIP + SAFETY + GOVERNANCE_RULES +} + +enum Electorate { + ALL_MEMBERS + HUMANS_ONLY +} + +enum VoteThreshold { + SIMPLE_MAJORITY + SUPERMAJORITY +} + +enum ProposalStatus { + DRAFT + OPEN + CLOSED +} + +enum SessionStatus { + ACTIVE + CLOSED +} + +enum SessionOutcome { + APPROVED + REJECTED + EXPIRED +} + +enum VoteChoice { + YES + NO + ABSTAIN +} + +enum PolicyStatus { + ACTIVE + SUPERSEDED +} + +enum AuditEventType { + ORG_CREATED + MEMBER_ADDED + MEMBER_STATUS_CHANGED + PROPOSAL_CREATED + SESSION_OPENED + VOTE_CAST + SESSION_CLOSED + POLICY_ACTIVATED +} + +model Organization { + id String @id @default(uuid()) + slug String @unique + name String + description String? + createdAt DateTime @default(now()) @map("created_at") + + members Member[] + proposals Proposal[] + policies Policy[] + treasurySources TreasurySource[] + auditEvents AuditEvent[] + + @@map("organizations") +} + +model Member { + id String @id @default(uuid()) + organizationId String @map("organization_id") + displayName String @map("display_name") + memberType MemberType @map("member_type") + keyCustody KeyCustody @map("key_custody") + /// The address votes must recover to. The only voting credential there is. + bitcoinAddress String @map("bitcoin_address") @db.VarChar(90) + publicKeyHex String? @map("public_key_hex") + votingWeight Decimal @default(1) @map("voting_weight") @db.Decimal(10, 2) + status MemberStatus @default(ACTIVE) + /// OrangeCat actor id once the member linked via OIDC login (humans only). + ocActorId String? @unique @map("oc_actor_id") + /// For agents: which system runs them, e.g. "orangecat:cat", "fleetcrown:loki". + system String? + joinedAt DateTime @default(now()) @map("joined_at") + + organization Organization @relation(fields: [organizationId], references: [id]) + proposals Proposal[] + votes Vote[] + apiKeys AgentApiKey[] + + @@unique([organizationId, bitcoinAddress]) + @@map("members") +} + +model Proposal { + id String @id @default(uuid()) + organizationId String @map("organization_id") + category DecisionCategory + title String + /// Markdown rationale — what is proposed and why. + body String + /// For policy proposals: which policy key this would change… + policyKey String? @map("policy_key") + /// …the exact proposed content… + proposedContent Json? @map("proposed_content") + /// …an external target ("orangecat:allocation_policy:")… + target String? + /// …and sha256 of the canonical JSON of proposedContent — what voters sign over. + contentHash String? @map("content_hash") + proposerMemberId String @map("proposer_member_id") + /// Bitcoin signed-message signature over proposalMessage() by the proposer. + proposerSignature String @map("proposer_signature") + status ProposalStatus @default(DRAFT) + createdAt DateTime @default(now()) @map("created_at") + + organization Organization @relation(fields: [organizationId], references: [id]) + proposer Member @relation(fields: [proposerMemberId], references: [id]) + session VotingSession? + + @@map("proposals") +} + +model VotingSession { + id String @id @default(uuid()) + proposalId String @unique @map("proposal_id") + status SessionStatus @default(ACTIVE) + opensAt DateTime @default(now()) @map("opens_at") + closesAt DateTime @map("closes_at") + + // Snapshot at open — a past decision must stay explainable after the + // governance config changes. + electorate Electorate + threshold VoteThreshold + quorumPercent Int @map("quorum_percent") + eligibleCount Int @map("eligible_count") + eligibleWeight Decimal @map("eligible_weight") @db.Decimal(12, 2) + + outcome SessionOutcome? + closedAt DateTime? @map("closed_at") + + proposal Proposal @relation(fields: [proposalId], references: [id]) + votes Vote[] + policies Policy[] + + @@map("voting_sessions") +} + +model Vote { + id String @id @default(uuid()) + sessionId String @map("session_id") + memberId String @map("member_id") + choice VoteChoice + /// Member's weight snapshotted at cast time. + weight Decimal @db.Decimal(10, 2) + /// The exact canonical message that was signed — stored so anyone can re-verify. + signedMessage String @map("signed_message") + signature String + createdAt DateTime @default(now()) @map("created_at") + + session VotingSession @relation(fields: [sessionId], references: [id]) + member Member @relation(fields: [memberId], references: [id]) + + @@unique([sessionId, memberId]) + @@map("votes") +} + +model Policy { + id String @id @default(uuid()) + organizationId String @map("organization_id") + key String + version Int + content Json + status PolicyStatus + /// Null only for the seeded bootstrap version. Every later version must + /// reference the APPROVED session that legitimated it. + approvedBySessionId String? @map("approved_by_session_id") + activatedAt DateTime @default(now()) @map("activated_at") + + organization Organization @relation(fields: [organizationId], references: [id]) + approvedBySession VotingSession? @relation(fields: [approvedBySessionId], references: [id]) + + @@unique([organizationId, key, version]) + @@map("policies") +} + +/// Watch-only treasury source. Solon never holds funds — it points at +/// independently verifiable on-chain addresses. +model TreasurySource { + id String @id @default(uuid()) + organizationId String @map("organization_id") + label String + address String @db.VarChar(90) + createdAt DateTime @default(now()) @map("created_at") + + organization Organization @relation(fields: [organizationId], references: [id]) + + @@unique([organizationId, address]) + @@map("treasury_sources") } +/// Append-only public audit log. No update or delete path exists in code. +model AuditEvent { + id String @id @default(uuid()) + organizationId String @map("organization_id") + eventType AuditEventType @map("event_type") + actorMemberId String? @map("actor_member_id") + subjectType String @map("subject_type") + subjectId String @map("subject_id") + payload Json + createdAt DateTime @default(now()) @map("created_at") + + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([organizationId, createdAt]) + @@map("audit_events") +} + +/// Transport auth for agent members calling the write API. The API key gets a +/// request in the door; the Bitcoin signature is the authorization artifact. +model AgentApiKey { + id String @id @default(uuid()) + memberId String @map("member_id") + /// sha256 hex of the plaintext key (plaintext shown once at mint). + keyHash String @unique @map("key_hash") + createdAt DateTime @default(now()) @map("created_at") + revokedAt DateTime? @map("revoked_at") + + member Member @relation(fields: [memberId], references: [id]) + + @@map("agent_api_keys") +} diff --git a/src/app/(dashboard)/dashboard/treasury/page.tsx b/src/app/(dashboard)/dashboard/treasury/page.tsx index fd084e1..45def7e 100644 --- a/src/app/(dashboard)/dashboard/treasury/page.tsx +++ b/src/app/(dashboard)/dashboard/treasury/page.tsx @@ -1,21 +1,14 @@ import BitcoinTreasury from "@/components/dashboard/bitcoin-treasury"; +import { treasuryReport } from "@/lib/domain/treasury"; import { prisma } from "@/lib/db"; export const dynamic = "force-dynamic"; export default async function TreasuryPage() { let org = null; - let txs: Awaited> = []; let dbError = false; try { - org = await prisma.organizations.findFirst(); - if (org) { - txs = await prisma.bitcoin_transactions.findMany({ - where: { organization_id: org.id }, - orderBy: { transaction_date: "desc" }, - take: 10, - }); - } + org = await prisma.organization.findFirst({ orderBy: { createdAt: "asc" } }); } catch { dbError = true; } @@ -33,24 +26,10 @@ export default async function TreasuryPage() { ); } - const balanceSats = txs.reduce((acc, t) => acc + Number(t.amount_sats), 0); + const report = await treasuryReport(org.id); return (
- ({ - ...t, - amount_sats: Number(t.amount_sats), - transaction_date: - t.transaction_date instanceof Date - ? t.transaction_date.toISOString() - : String(t.transaction_date), - description: t.description ?? undefined, - to_address: t.to_address ?? undefined, - from_address: t.from_address ?? undefined, - }))} - /> +
); } diff --git a/src/app/(dashboard)/dashboard/voting/page.tsx b/src/app/(dashboard)/dashboard/voting/page.tsx index 50f1181..0294cc7 100644 --- a/src/app/(dashboard)/dashboard/voting/page.tsx +++ b/src/app/(dashboard)/dashboard/voting/page.tsx @@ -1,5 +1,5 @@ import VotingInterface from "@/components/dashboard/voting-interface"; -import { Democracy } from "@/lib/solon/democracy"; +import { sessionTally } from "@/lib/domain/voting"; import { prisma } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -8,7 +8,10 @@ export default async function VotingPage() { let session = null; let dbError = false; try { - session = await prisma.voting_sessions.findFirst({ orderBy: { start_date: "desc" } }); + session = await prisma.votingSession.findFirst({ + orderBy: { opensAt: "desc" }, + include: { proposal: true }, + }); } catch { dbError = true; } @@ -36,12 +39,22 @@ export default async function VotingPage() { ); } - const tally = await new Democracy().tally(session.id); + const tally = await sessionTally(session.id); return (
-

Open Vote

- +

+ {session.status === "ACTIVE" ? "Open Vote" : "Latest Vote"} +

+
); } diff --git a/src/app/api/bitcoin/wallet/[orgId]/route.ts b/src/app/api/bitcoin/wallet/[orgId]/route.ts index 782d621..a843196 100644 --- a/src/app/api/bitcoin/wallet/[orgId]/route.ts +++ b/src/app/api/bitcoin/wallet/[orgId]/route.ts @@ -1,43 +1,33 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; -import { getAddressBalance } from "@/lib/bitcoin/mempool"; +import { treasuryReport } from "@/lib/domain/treasury"; +export const dynamic = "force-dynamic"; + +/** + * Watch-only treasury balances for an organization (by id or slug). Every + * number is read live from the chain; a failed lookup reports null, never a + * substitute. + */ export async function GET(_: Request, { params }: { params: { orgId: string } }) { const { orgId } = params; - const org = await prisma.organizations.findUnique({ where: { id: orgId } }); - if (!org) return NextResponse.json({ error: 'Organization not found' }, { status: 404 }); - - const txs = await prisma.bitcoin_transactions.findMany({ - where: { organization_id: orgId }, - orderBy: { transaction_date: 'desc' }, - take: 25, + const org = await prisma.organization.findFirst({ + where: { OR: [{ id: orgId }, { slug: orgId }] }, }); + if (!org) return NextResponse.json({ error: "Organization not found" }, { status: 404 }); - // The treasury address is held in the org's wallet field. Balance is read - // live from the chain (mempool.space) — never asserted, never custodied. - // Fall back to the recorded-transaction sum if the chain lookup fails. - const treasuryAddress = org.bitcoin_wallet_xpub; - let balance_sats = 0; - let balance_source: 'onchain' | 'recorded' = 'recorded'; - try { - if (treasuryAddress && /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{20,}$/.test(treasuryAddress)) { - const bal = await getAddressBalance(treasuryAddress); - balance_sats = bal.total_sats; - balance_source = 'onchain'; - } else { - throw new Error('no treasury address'); - } - } catch { - balance_sats = txs.reduce((sum, t) => sum + Number(t.amount_sats), 0); - } - - return NextResponse.json({ treasury_address: treasuryAddress, balance_sats, balance_source, transactions: txs }); -} - -export async function PUT(req: Request, { params }: { params: { orgId: string } }) { - const body = await req.json(); - const { txid, category } = body; - if (!txid || !category) return NextResponse.json({ error: 'txid and category required' }, { status: 400 }); - const updated = await prisma.bitcoin_transactions.update({ where: { txid }, data: { category } }); - return NextResponse.json(updated); + const report = await treasuryReport(org.id); + return NextResponse.json({ + organization: { id: org.id, slug: org.slug, name: org.name }, + balance_source: "onchain", + total_sats: report.totalSats, + all_sources_resolved: report.allSourcesResolved, + sources: report.sources.map((s) => ({ + label: s.label, + address: s.address, + total_sats: s.totalSats, + tx_count: s.txCount, + explorer: `https://mempool.space/address/${s.address}`, + })), + }); } diff --git a/src/app/api/voting/[sessionId]/cryptographic-vote/route.ts b/src/app/api/voting/[sessionId]/cryptographic-vote/route.ts index daa8d10..9e20257 100644 --- a/src/app/api/voting/[sessionId]/cryptographic-vote/route.ts +++ b/src/app/api/voting/[sessionId]/cryptographic-vote/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { Democracy, type VoteChoice } from "@/lib/solon/democracy"; +import { submitVote, sessionTally, type SubmitVoteInput } from "@/lib/domain/voting"; -const CHOICES: VoteChoice[] = ['yes', 'no', 'abstain']; +const CHOICES = ["yes", "no", "abstain"] as const; /** * Cast a cryptographically-signed vote. The body must carry the member's @@ -14,16 +14,16 @@ const CHOICES: VoteChoice[] = ['yes', 'no', 'abstain']; export async function POST(req: Request, { params }: { params: { sessionId: string } }) { const { sessionId } = params; const body = await req.json().catch(() => ({})); - const { choice, address, signature } = body || {}; + const { choice, address, signature } = (body ?? {}) as Partial; if (!choice || !address || !signature) { - return NextResponse.json({ error: 'choice, address and signature are required' }, { status: 400 }); + return NextResponse.json({ error: "choice, address and signature are required" }, { status: 400 }); } if (!CHOICES.includes(choice)) { - return NextResponse.json({ error: `choice must be one of ${CHOICES.join(', ')}` }, { status: 400 }); + return NextResponse.json({ error: `choice must be one of ${CHOICES.join(", ")}` }, { status: 400 }); } - const result = await new Democracy().submitVote(sessionId, { address, choice, signature }); + const result = await submitVote(sessionId, { address, choice, signature }); if (!result.stored) { // 401 when the signature itself failed; 422 when it verified but the @@ -34,6 +34,6 @@ export async function POST(req: Request, { params }: { params: { sessionId: stri } export async function GET(_: Request, { params }: { params: { sessionId: string } }) { - const tally = await new Democracy().tally(params.sessionId); + const tally = await sessionTally(params.sessionId); return NextResponse.json({ sessionId: params.sessionId, tally }); } diff --git a/src/components/bitcoin/signature-validator.tsx b/src/components/bitcoin/signature-validator.tsx deleted file mode 100644 index 382b2a9..0000000 --- a/src/components/bitcoin/signature-validator.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Vote } from "@/lib/solon/types"; - -export default function SignatureValidator({ vote }: { vote: Vote }) { - return ( -
- Signature: {vote.bitcoin_signature} -
- ); -} - diff --git a/src/components/bitcoin/transaction-viewer.tsx b/src/components/bitcoin/transaction-viewer.tsx deleted file mode 100644 index 0d5ed21..0000000 --- a/src/components/bitcoin/transaction-viewer.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { BitcoinTransaction } from "@/lib/solon/types"; - -export default function TransactionViewer({ txs }: { txs: BitcoinTransaction[] }) { - if (!txs?.length) return
No transactions yet.
; - return ( -
    - {txs.map((tx) => ( -
  • -
    - {tx.txid.slice(0, 18)}… - {tx.amount_sats} sats -
    -
    {new Date(tx.transaction_date).toLocaleString()}
    -
  • - ))} -
- ); -} - diff --git a/src/components/dashboard/bitcoin-treasury.tsx b/src/components/dashboard/bitcoin-treasury.tsx index c857eda..26d14b3 100644 --- a/src/components/dashboard/bitcoin-treasury.tsx +++ b/src/components/dashboard/bitcoin-treasury.tsx @@ -1,60 +1,68 @@ -import type { BitcoinTransaction } from "@/lib/solon/types"; +import type { TreasuryReport } from "@/lib/domain/treasury"; -export interface BitcoinTreasuryProps { - organizationId: string; - walletBalance: number; // sats - recentTransactions: BitcoinTransaction[]; -} +/** + * Watch-only treasury: every number is a live chain lookup and links to a + * public explorer so it can be verified independently. A failed lookup says + * "unavailable" — it never substitutes a guess. + */ +export default function BitcoinTreasury({ orgName, report }: { orgName: string; report: TreasuryReport }) { + const totalBtc = report.totalSats !== null ? (report.totalSats / 100_000_000).toFixed(8) : null; -export default function BitcoinTreasury({ organizationId, walletBalance, recentTransactions }: BitcoinTreasuryProps) { - const btc = (walletBalance / 100_000_000).toFixed(8); return (

Bitcoin Treasury

-

Organization: {organizationId}

+

Organization: {orgName}

-
Recorded balance (BTC) — sum of the transactions below
-
{btc}
+
+ On-chain balance (BTC){report.allSourcesResolved ? "" : " — some sources unavailable"} +
+
{totalBtc ?? "unavailable"}
-
-
-

Recent Transactions

+
+

Treasury sources (watch-only)

+ {report.sources.length === 0 ? ( +

+ No treasury source registered yet. When one is, its balance renders here from a + live chain lookup and links to a public explorer. +

+ ) : ( - - - - + + + + - {recentTransactions.slice(0, 8).map((tx) => ( - - - - - + + + + ))}
DateAmount (sats)CategoryTXLabelAddressBalance (sats)Verify
{new Date(tx.transaction_date).toLocaleString()}{tx.amount_sats}{tx.category} + {report.sources.map((s) => ( +
{s.label}{s.address} + {s.totalSats !== null ? s.totalSats.toLocaleString() : "unavailable"} + - View + mempool.space
-
+ )}
); } - diff --git a/src/components/dashboard/voting-interface.tsx b/src/components/dashboard/voting-interface.tsx index 7aa9eca..a29749b 100644 --- a/src/components/dashboard/voting-interface.tsx +++ b/src/components/dashboard/voting-interface.tsx @@ -7,7 +7,7 @@ type Choice = "yes" | "no" | "abstain"; const CHOICES: Choice[] = ["yes", "no", "abstain"]; export interface VotingInterfaceProps { - session: { id: string; title: string; voting_type: string; status: string }; + session: { id: string; title: string; rules: string; status: string }; tally: Record; } @@ -60,7 +60,7 @@ export default function VotingInterface({ session, tally }: VotingInterfaceProps

{session.title}

-

Voting type: {session.voting_type}

+

{session.rules}

Session: {session.status}
diff --git a/src/lib/bitcoin/__tests__/message.test.ts b/src/lib/bitcoin/__tests__/message.test.ts new file mode 100644 index 0000000..3acfdcf --- /dev/null +++ b/src/lib/bitcoin/__tests__/message.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + deriveAddresses, + generateKeyPair, + proposalMessage, + signMessage, + verifyMessage, + voteMessage, +} from "../message"; + +describe("voteMessage / proposalMessage canonical form", () => { + it("voteMessage matches the golden string exactly (wallets sign these bytes)", () => { + expect( + voteMessage({ sessionId: "sess-1", choice: "yes", memberAddress: "1BitcoinEaterAddressDontSendf59kuE" }), + ).toBe("Solon vote\nsession:sess-1\nchoice:yes\nvoter:1BitcoinEaterAddressDontSendf59kuE"); + }); + + it("proposalMessage binds the content hash when present", () => { + const base = proposalMessage({ + orgSlug: "orangecat", + category: "ALLOCATION_POLICY", + title: "Raise the ceiling", + proposerAddress: "1abc", + }); + expect(base).toBe( + "Solon proposal\norg:orangecat\ncategory:ALLOCATION_POLICY\ntitle:Raise the ceiling\nproposer:1abc", + ); + expect( + proposalMessage({ + orgSlug: "orangecat", + category: "ALLOCATION_POLICY", + title: "Raise the ceiling", + proposerAddress: "1abc", + contentHash: "deadbeef", + }), + ).toBe(`${base}\ncontent:deadbeef`); + }); +}); + +describe("sign/verify roundtrip", () => { + const pair = generateKeyPair(); + const message = voteMessage({ sessionId: "s", choice: "yes", memberAddress: pair.address }); + const signature = signMessage(message, pair.privateKeyHex); + + it("verifies a valid signature against the P2PKH address", () => { + const result = verifyMessage(message, pair.address, signature); + expect(result.valid).toBe(true); + expect(result.recoveredAddress).toBe(pair.address); + }); + + it("rejects a tampered message", () => { + const tampered = voteMessage({ sessionId: "s", choice: "no", memberAddress: pair.address }); + expect(verifyMessage(tampered, pair.address, signature).valid).toBe(false); + }); + + it("rejects a signature replayed onto a different session", () => { + const other = voteMessage({ sessionId: "s2", choice: "yes", memberAddress: pair.address }); + expect(verifyMessage(other, pair.address, signature).valid).toBe(false); + }); + + it("rejects the wrong address", () => { + const other = generateKeyPair(); + expect(verifyMessage(message, other.address, signature).valid).toBe(false); + }); + + it("rejects garbage signatures without throwing", () => { + expect(verifyMessage(message, pair.address, "not-base64!!").valid).toBe(false); + expect(verifyMessage(message, pair.address, Buffer.from("short").toString("base64")).valid).toBe(false); + }); +}); + +describe("BIP137 segwit support", () => { + const pair = generateKeyPair(); + const addrs = deriveAddresses(pair.publicKeyHex); + + it("derives distinct p2pkh / p2wpkh / p2sh-p2wpkh addresses", () => { + expect(addrs.p2pkh.startsWith("1")).toBe(true); + expect(addrs.p2wpkh.startsWith("bc1q")).toBe(true); + expect(addrs.p2shP2wpkh.startsWith("3")).toBe(true); + }); + + it("accepts a legacy-header signature claiming the bech32 address (Electrum behavior)", () => { + const message = voteMessage({ sessionId: "s", choice: "yes", memberAddress: addrs.p2wpkh }); + const signature = signMessage(message, pair.privateKeyHex); + expect(verifyMessage(message, addrs.p2wpkh, signature).valid).toBe(true); + }); + + it("accepts a signature claiming the P2SH-wrapped address", () => { + const message = voteMessage({ sessionId: "s", choice: "yes", memberAddress: addrs.p2shP2wpkh }); + const signature = signMessage(message, pair.privateKeyHex); + expect(verifyMessage(message, addrs.p2shP2wpkh, signature).valid).toBe(true); + }); + + it("accepts BIP137 segwit header bytes (35-42) as produced by Sparrow/Core", () => { + const message = voteMessage({ sessionId: "s", choice: "yes", memberAddress: addrs.p2wpkh }); + const legacy = Buffer.from(signMessage(message, pair.privateKeyHex), "base64"); + // Re-band the header from compressed-P2PKH (31-34) to P2WPKH (39-42). + const rebanded = Buffer.from(legacy); + rebanded[0] = legacy[0] - 31 + 39; + expect(verifyMessage(message, addrs.p2wpkh, rebanded.toString("base64")).valid).toBe(true); + }); + + it("rejects header bytes outside 27-42", () => { + const message = voteMessage({ sessionId: "s", choice: "yes", memberAddress: addrs.p2pkh }); + const sig = Buffer.from(signMessage(message, pair.privateKeyHex), "base64"); + sig[0] = 43; + expect(verifyMessage(message, addrs.p2pkh, sig.toString("base64")).valid).toBe(false); + }); +}); diff --git a/src/lib/bitcoin/message.ts b/src/lib/bitcoin/message.ts index 122cfe4..b598620 100644 --- a/src/lib/bitcoin/message.ts +++ b/src/lib/bitcoin/message.ts @@ -20,6 +20,7 @@ import * as secp from '@noble/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; import { ripemd160 } from '@noble/hashes/ripemd160'; import { hmac } from '@noble/hashes/hmac'; +import { bech32 } from '@scure/base'; import bs58check from 'bs58check'; // @noble/secp256k1 v2 needs an HMAC-SHA256 for RFC6979 deterministic signing. @@ -45,10 +46,39 @@ export function messageDigest(message: string): Uint8Array { return doubleSha256(preimage); } +function hash160(bytes: Uint8Array): Uint8Array { + return ripemd160(sha256(bytes)); +} + /** Mainnet P2PKH address (version 0x00) for a public key. */ function p2pkhAddress(pubkey: Uint8Array): string { - const hash = ripemd160(sha256(pubkey)); - return bs58check.encode(secp.etc.concatBytes(Uint8Array.of(0x00), hash)); + return bs58check.encode(secp.etc.concatBytes(Uint8Array.of(0x00), hash160(pubkey))); +} + +/** Mainnet native-segwit P2WPKH (bech32, bc1q…) for a compressed public key. */ +function p2wpkhAddress(pubkey: Uint8Array): string { + const words = [0, ...bech32.toWords(hash160(pubkey))]; + return bech32.encode('bc', words); +} + +/** Mainnet P2SH-wrapped segwit (3…) for a compressed public key. */ +function p2shP2wpkhAddress(pubkey: Uint8Array): string { + const redeemScript = secp.etc.concatBytes(Uint8Array.of(0x00, 0x14), hash160(pubkey)); + return bs58check.encode(secp.etc.concatBytes(Uint8Array.of(0x05), hash160(redeemScript))); +} + +/** All standard mainnet addresses for a compressed public key (hex). */ +export function deriveAddresses(publicKeyHex: string): { + p2pkh: string; + p2wpkh: string; + p2shP2wpkh: string; +} { + const pub = secp.etc.hexToBytes(publicKeyHex); + return { + p2pkh: p2pkhAddress(pub), + p2wpkh: p2wpkhAddress(pub), + p2shP2wpkh: p2shP2wpkhAddress(pub), + }; } export interface BitcoinKeyPair { @@ -90,8 +120,13 @@ export interface VerifyResult { /** * Verify that `signatureBase64` is a valid Bitcoin signed-message signature - * for `message` by the holder of `address`. Recovers the public key from the - * signature and checks the derived P2PKH address matches. + * for `message` by the holder of `address`. + * + * Accepts the full BIP137 header range (27–42: uncompressed/compressed P2PKH, + * P2SH-P2WPKH, P2WPKH) and, for compressed keys, matches the claimed address + * against ALL standard derivations of the recovered key. That covers wallets + * that set the segwit header bits (Sparrow, Bitcoin Core) AND wallets that + * sign segwit addresses with the legacy compressed header (Electrum). */ export function verifyMessage(message: string, address: string, signatureBase64: string): VerifyResult { let raw: Buffer; @@ -103,7 +138,7 @@ export function verifyMessage(message: string, address: string, signatureBase64: if (raw.length !== 65) return { valid: false, reason: `signature must be 65 bytes, got ${raw.length}` }; const header = raw[0]; - if (header < 27 || header > 34) return { valid: false, reason: `invalid header byte ${header}` }; + if (header < 27 || header > 42) return { valid: false, reason: `invalid header byte ${header}` }; const compressed = header >= 31; const recovery = (header - 27) & 0x03; @@ -112,8 +147,12 @@ export function verifyMessage(message: string, address: string, signatureBase64: const sig = secp.Signature.fromCompact(raw.subarray(1)).addRecoveryBit(recovery); const point = sig.recoverPublicKey(digest); const pub = point.toRawBytes(compressed); - const recoveredAddress = p2pkhAddress(pub); - return { valid: recoveredAddress === address, recoveredAddress }; + + const candidates = compressed + ? [p2pkhAddress(pub), p2wpkhAddress(pub), p2shP2wpkhAddress(pub)] + : [p2pkhAddress(pub)]; + const valid = candidates.includes(address); + return { valid, recoveredAddress: candidates[0], ...(valid ? {} : { reason: 'recovered key does not derive the claimed address' }) }; } catch (e) { return { valid: false, reason: e instanceof Error ? e.message : 'recovery failed' }; } @@ -127,3 +166,19 @@ export function verifyMessage(message: string, address: string, signatureBase64: export function voteMessage(params: { sessionId: string; choice: string; memberAddress: string }): string { return `Solon vote\nsession:${params.sessionId}\nchoice:${params.choice}\nvoter:${params.memberAddress}`; } + +/** + * Canonical message a member signs to file a proposal. Binds the organization, + * category, title, proposer, and (for policy changes) the sha256 of the exact + * proposed content — so a proposal can't be altered after signing. + */ +export function proposalMessage(params: { + orgSlug: string; + category: string; + title: string; + proposerAddress: string; + contentHash?: string | null; +}): string { + const base = `Solon proposal\norg:${params.orgSlug}\ncategory:${params.category}\ntitle:${params.title}\nproposer:${params.proposerAddress}`; + return params.contentHash ? `${base}\ncontent:${params.contentHash}` : base; +} diff --git a/src/lib/config/__tests__/governance.test.ts b/src/lib/config/__tests__/governance.test.ts new file mode 100644 index 0000000..d48c6f0 --- /dev/null +++ b/src/lib/config/__tests__/governance.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { DecisionCategory, Electorate } from "@prisma/client"; +import { + CATEGORY_ELECTORATE, + CATEGORY_QUORUM_PERCENT, + CATEGORY_THRESHOLD, + SUPERMAJORITY_FRACTION, + VOTING_WINDOW_DAYS, +} from "../governance"; + +const ALL_CATEGORIES = Object.values(DecisionCategory); + +describe("governance config completeness", () => { + it("covers every DecisionCategory in every table — a new enum value must be classified", () => { + for (const c of ALL_CATEGORIES) { + expect(CATEGORY_ELECTORATE[c], `electorate for ${c}`).toBeDefined(); + expect(CATEGORY_THRESHOLD[c], `threshold for ${c}`).toBeDefined(); + expect(CATEGORY_QUORUM_PERCENT[c], `quorum for ${c}`).toBeGreaterThan(0); + expect(CATEGORY_QUORUM_PERCENT[c], `quorum for ${c}`).toBeLessThanOrEqual(100); + } + }); + + it("keeps the red lines human-only: aid, membership, safety, and governance rules themselves", () => { + // Agents must never be able to vote money to people, change who belongs, + // weaken safety, or expand their own suffrage. (oc-solon-spec red lines.) + expect(CATEGORY_ELECTORATE[DecisionCategory.AID_DISBURSEMENT]).toBe(Electorate.HUMANS_ONLY); + expect(CATEGORY_ELECTORATE[DecisionCategory.MEMBERSHIP]).toBe(Electorate.HUMANS_ONLY); + expect(CATEGORY_ELECTORATE[DecisionCategory.SAFETY]).toBe(Electorate.HUMANS_ONLY); + expect(CATEGORY_ELECTORATE[DecisionCategory.GOVERNANCE_RULES]).toBe(Electorate.HUMANS_ONLY); + }); + + it("gives agents real suffrage somewhere — ALL_MEMBERS must not be empty", () => { + expect(ALL_CATEGORIES.some((c) => CATEGORY_ELECTORATE[c] === Electorate.ALL_MEMBERS)).toBe(true); + }); + + it("has sane global constants", () => { + expect(VOTING_WINDOW_DAYS).toBeGreaterThan(0); + expect(SUPERMAJORITY_FRACTION).toBeGreaterThan(0.5); + expect(SUPERMAJORITY_FRACTION).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/lib/config/governance.ts b/src/lib/config/governance.ts new file mode 100644 index 0000000..875c054 --- /dev/null +++ b/src/lib/config/governance.ts @@ -0,0 +1,53 @@ +import { + DecisionCategory, + Electorate, + VoteThreshold, +} from "@prisma/client"; + +/** + * SSOT for who decides what, and how. + * + * Agents (the Cat, FleetCrown's agents) are full members: they may PROPOSE in + * any category, and they VOTE wherever the electorate is ALL_MEMBERS. The + * HUMANS_ONLY categories are the red lines from the OC↔Solon spec: money + * moving to people, membership, safety — and GOVERNANCE_RULES itself, so + * agents can never vote to expand their own suffrage. + * + * Changing this file IS a GOVERNANCE_RULES matter (humans-only). Sessions + * snapshot these values at open, so edits here never rewrite a past decision. + */ +export const CATEGORY_ELECTORATE: Record = { + ALLOCATION_POLICY: Electorate.ALL_MEMBERS, + TREASURY_SPEND: Electorate.ALL_MEMBERS, + OPERATIONS: Electorate.ALL_MEMBERS, + AID_DISBURSEMENT: Electorate.HUMANS_ONLY, + MEMBERSHIP: Electorate.HUMANS_ONLY, + SAFETY: Electorate.HUMANS_ONLY, + GOVERNANCE_RULES: Electorate.HUMANS_ONLY, +}; + +export const CATEGORY_THRESHOLD: Record = { + ALLOCATION_POLICY: VoteThreshold.SIMPLE_MAJORITY, + TREASURY_SPEND: VoteThreshold.SIMPLE_MAJORITY, + OPERATIONS: VoteThreshold.SIMPLE_MAJORITY, + AID_DISBURSEMENT: VoteThreshold.SIMPLE_MAJORITY, + MEMBERSHIP: VoteThreshold.SUPERMAJORITY, + SAFETY: VoteThreshold.SUPERMAJORITY, + GOVERNANCE_RULES: VoteThreshold.SUPERMAJORITY, +}; + +/** Percent of eligible weight that must vote for the session to be decisive. */ +export const CATEGORY_QUORUM_PERCENT: Record = { + ALLOCATION_POLICY: 50, + TREASURY_SPEND: 50, + OPERATIONS: 30, + AID_DISBURSEMENT: 50, + MEMBERSHIP: 50, + SAFETY: 50, + GOVERNANCE_RULES: 60, +}; + +export const VOTING_WINDOW_DAYS = 7; + +/** SUPERMAJORITY means at least this fraction of yes among yes+no. */ +export const SUPERMAJORITY_FRACTION = 2 / 3; diff --git a/src/lib/domain/__tests__/tally.test.ts b/src/lib/domain/__tests__/tally.test.ts new file mode 100644 index 0000000..50fe12a --- /dev/null +++ b/src/lib/domain/__tests__/tally.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { SessionOutcome, VoteChoice, VoteThreshold } from "@prisma/client"; +import { decideOutcome, tally } from "../tally"; + +describe("tally", () => { + it("weights votes by member weight", () => { + expect( + tally([ + { choice: VoteChoice.YES, weight: 2.5 }, + { choice: VoteChoice.YES, weight: 1 }, + { choice: VoteChoice.NO, weight: 1 }, + { choice: VoteChoice.ABSTAIN, weight: 0.5 }, + ]), + ).toEqual({ yes: 3.5, no: 1, abstain: 0.5 }); + }); + + it("is zero on no votes", () => { + expect(tally([])).toEqual({ yes: 0, no: 0, abstain: 0 }); + }); +}); + +describe("decideOutcome", () => { + const base = { quorumPercent: 50, eligibleWeight: 10 }; + + it("approves a simple majority above quorum", () => { + expect( + decideOutcome({ ...base, tally: { yes: 4, no: 2, abstain: 0 }, threshold: VoteThreshold.SIMPLE_MAJORITY }), + ).toBe(SessionOutcome.APPROVED); + }); + + it("rejects when no >= yes", () => { + expect( + decideOutcome({ ...base, tally: { yes: 3, no: 3, abstain: 0 }, threshold: VoteThreshold.SIMPLE_MAJORITY }), + ).toBe(SessionOutcome.REJECTED); + }); + + it("expires below quorum — silence is not consent", () => { + expect( + decideOutcome({ ...base, tally: { yes: 2, no: 0, abstain: 0 }, threshold: VoteThreshold.SIMPLE_MAJORITY }), + ).toBe(SessionOutcome.EXPIRED); + }); + + it("abstain counts toward quorum but not toward the threshold", () => { + // 2 yes + 3 abstain = 5 cast, meets 50% quorum of 10; yes wins 2:0. + expect( + decideOutcome({ ...base, tally: { yes: 2, no: 0, abstain: 3 }, threshold: VoteThreshold.SIMPLE_MAJORITY }), + ).toBe(SessionOutcome.APPROVED); + }); + + it("expires when everyone abstains — no decisive vote was cast", () => { + expect( + decideOutcome({ ...base, tally: { yes: 0, no: 0, abstain: 6 }, threshold: VoteThreshold.SIMPLE_MAJORITY }), + ).toBe(SessionOutcome.EXPIRED); + }); + + it("supermajority requires >= 2/3 of decisive votes", () => { + expect( + decideOutcome({ ...base, tally: { yes: 4, no: 2, abstain: 0 }, threshold: VoteThreshold.SUPERMAJORITY }), + ).toBe(SessionOutcome.APPROVED); + expect( + decideOutcome({ ...base, tally: { yes: 3.9, no: 2.1, abstain: 0 }, threshold: VoteThreshold.SUPERMAJORITY }), + ).toBe(SessionOutcome.REJECTED); + }); + + it("expires on zero eligible weight — an empty electorate decides nothing", () => { + expect( + decideOutcome({ + tally: { yes: 1, no: 0, abstain: 0 }, + threshold: VoteThreshold.SIMPLE_MAJORITY, + quorumPercent: 50, + eligibleWeight: 0, + }), + ).toBe(SessionOutcome.EXPIRED); + }); +}); diff --git a/src/lib/domain/tally.ts b/src/lib/domain/tally.ts new file mode 100644 index 0000000..e210b1c --- /dev/null +++ b/src/lib/domain/tally.ts @@ -0,0 +1,50 @@ +import { VoteChoice, VoteThreshold, SessionOutcome } from "@prisma/client"; +import { SUPERMAJORITY_FRACTION } from "@/lib/config/governance"; + +export interface WeightedVote { + choice: VoteChoice; + weight: number; +} + +export interface Tally { + yes: number; + no: number; + abstain: number; +} + +/** Weighted tally over already-verified votes. Pure — no DB, fully testable. */ +export function tally(votes: WeightedVote[]): Tally { + const t: Tally = { yes: 0, no: 0, abstain: 0 }; + for (const v of votes) { + if (v.choice === VoteChoice.YES) t.yes += v.weight; + else if (v.choice === VoteChoice.NO) t.no += v.weight; + else t.abstain += v.weight; + } + return t; +} + +/** + * Decide a session outcome from its tally and the rules snapshotted at open. + * Abstain counts toward quorum (the member showed up) but not toward the + * threshold (it is not a yes and not a no). + */ +export function decideOutcome(params: { + tally: Tally; + threshold: VoteThreshold; + quorumPercent: number; + eligibleWeight: number; +}): SessionOutcome { + const { tally: t, threshold, quorumPercent, eligibleWeight } = params; + const cast = t.yes + t.no + t.abstain; + const quorumWeight = (quorumPercent / 100) * eligibleWeight; + if (eligibleWeight <= 0 || cast < quorumWeight) return SessionOutcome.EXPIRED; + + const decisive = t.yes + t.no; + if (decisive === 0) return SessionOutcome.EXPIRED; + + const passes = + threshold === VoteThreshold.SUPERMAJORITY + ? t.yes / decisive >= SUPERMAJORITY_FRACTION + : t.yes > t.no; + return passes ? SessionOutcome.APPROVED : SessionOutcome.REJECTED; +} diff --git a/src/lib/domain/treasury.ts b/src/lib/domain/treasury.ts new file mode 100644 index 0000000..64ba812 --- /dev/null +++ b/src/lib/domain/treasury.ts @@ -0,0 +1,48 @@ +import { prisma } from "@/lib/db"; +import { getAddressBalance } from "@/lib/bitcoin/mempool"; + +export interface TreasurySourceBalance { + label: string; + address: string; + /** Null when the chain lookup failed — shown as unavailable, never guessed. */ + totalSats: number | null; + txCount: number | null; +} + +export interface TreasuryReport { + sources: TreasurySourceBalance[]; + /** Sum over sources whose lookup succeeded; null if none succeeded. */ + totalSats: number | null; + allSourcesResolved: boolean; +} + +/** + * Live, watch-only treasury balances. Every number comes from the chain via + * mempool.space at request time — there is no recorded-sum fallback, because a + * fallback silently presented as a balance is a lie. A failed lookup renders + * as "unavailable". + */ +export async function treasuryReport(organizationId: string): Promise { + const rows = await prisma.treasurySource.findMany({ + where: { organizationId }, + orderBy: { createdAt: "asc" }, + }); + + const sources = await Promise.all( + rows.map(async (row): Promise => { + try { + const bal = await getAddressBalance(row.address); + return { label: row.label, address: row.address, totalSats: bal.total_sats, txCount: bal.tx_count }; + } catch { + return { label: row.label, address: row.address, totalSats: null, txCount: null }; + } + }), + ); + + const resolved = sources.filter((s) => s.totalSats !== null); + return { + sources, + totalSats: resolved.length ? resolved.reduce((sum, s) => sum + (s.totalSats ?? 0), 0) : null, + allSourcesResolved: resolved.length === sources.length, + }; +} diff --git a/src/lib/domain/voting.ts b/src/lib/domain/voting.ts new file mode 100644 index 0000000..6806625 --- /dev/null +++ b/src/lib/domain/voting.ts @@ -0,0 +1,280 @@ +import { + AuditEventType, + Electorate, + MemberStatus, + MemberType, + PolicyStatus, + Prisma, + ProposalStatus, + SessionOutcome, + SessionStatus, + VoteChoice, +} from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { verifyMessage, voteMessage } from "@/lib/bitcoin/message"; +import { + CATEGORY_ELECTORATE, + CATEGORY_QUORUM_PERCENT, + CATEGORY_THRESHOLD, + VOTING_WINDOW_DAYS, +} from "@/lib/config/governance"; +import { tally, decideOutcome, type Tally } from "@/lib/domain/tally"; + +export interface SubmitVoteInput { + address: string; + choice: "yes" | "no" | "abstain"; + signature: string; +} + +export interface SubmitVoteResult { + stored: boolean; + verified: boolean; + reason?: string; + recoveredAddress?: string; + tally?: Tally; + voteId?: string; +} + +const CHOICE_MAP: Record = { + yes: VoteChoice.YES, + no: VoteChoice.NO, + abstain: VoteChoice.ABSTAIN, +}; + +/** + * Open the voting session for a DRAFT proposal. The rules — electorate, + * threshold, quorum, eligibility — are resolved from config and the member + * roll ONCE, here, and snapshotted onto the session. The gate runs at open, + * not after a week of voting. + */ +export async function openSession(proposalId: string) { + const proposal = await prisma.proposal.findUnique({ where: { id: proposalId } }); + if (!proposal) throw new Error("proposal not found"); + if (proposal.status !== ProposalStatus.DRAFT) { + throw new Error(`proposal is ${proposal.status}, only DRAFT proposals can open`); + } + + const electorate = CATEGORY_ELECTORATE[proposal.category]; + const eligible = await prisma.member.findMany({ + where: { + organizationId: proposal.organizationId, + status: MemberStatus.ACTIVE, + ...(electorate === Electorate.HUMANS_ONLY ? { memberType: MemberType.HUMAN } : {}), + }, + select: { votingWeight: true }, + }); + if (eligible.length === 0) { + throw new Error("no eligible members — a session with an empty electorate cannot decide anything"); + } + const eligibleWeight = eligible.reduce((s, m) => s + Number(m.votingWeight), 0); + + const closesAt = new Date(); + closesAt.setDate(closesAt.getDate() + VOTING_WINDOW_DAYS); + + const session = await prisma.$transaction(async (tx) => { + const s = await tx.votingSession.create({ + data: { + proposalId, + status: SessionStatus.ACTIVE, + closesAt, + electorate, + threshold: CATEGORY_THRESHOLD[proposal.category], + quorumPercent: CATEGORY_QUORUM_PERCENT[proposal.category], + eligibleCount: eligible.length, + eligibleWeight: new Prisma.Decimal(eligibleWeight.toFixed(2)), + }, + }); + await tx.proposal.update({ + where: { id: proposalId }, + data: { status: ProposalStatus.OPEN }, + }); + await tx.auditEvent.create({ + data: { + organizationId: proposal.organizationId, + eventType: AuditEventType.SESSION_OPENED, + subjectType: "voting_session", + subjectId: s.id, + payload: { + proposalId, + electorate, + threshold: CATEGORY_THRESHOLD[proposal.category], + quorumPercent: CATEGORY_QUORUM_PERCENT[proposal.category], + eligibleCount: eligible.length, + eligibleWeight, + closesAt: closesAt.toISOString(), + }, + }, + }); + return s; + }); + return session; +} + +/** + * Cast a vote. The Bitcoin signature is the authorization: the voter is + * resolved by the address the signature recovers to — never by any claim the + * caller makes. Electorate rules snapshotted at open are enforced here. + */ +export async function submitVote(sessionId: string, input: SubmitVoteInput): Promise { + const message = voteMessage({ sessionId, choice: input.choice, memberAddress: input.address }); + const verification = verifyMessage(message, input.address, input.signature); + if (!verification.valid) { + return { + stored: false, + verified: false, + reason: verification.reason ?? "signature does not match address", + recoveredAddress: verification.recoveredAddress, + }; + } + + const session = await prisma.votingSession.findUnique({ + where: { id: sessionId }, + include: { proposal: true }, + }); + if (!session) return { stored: false, verified: true, reason: "voting session not found" }; + if (session.status !== SessionStatus.ACTIVE) { + return { stored: false, verified: true, reason: `voting session is ${session.status}` }; + } + if (new Date() > session.closesAt) { + return { stored: false, verified: true, reason: "voting window has closed" }; + } + + const member = await prisma.member.findFirst({ + where: { + organizationId: session.proposal.organizationId, + bitcoinAddress: input.address, + status: MemberStatus.ACTIVE, + }, + }); + if (!member) { + return { stored: false, verified: true, reason: "address is not an active member of this organization" }; + } + if (session.electorate === Electorate.HUMANS_ONLY && member.memberType !== MemberType.HUMAN) { + return { + stored: false, + verified: true, + reason: "this session's electorate is humans-only; agent members cannot vote here", + }; + } + + const choice = CHOICE_MAP[input.choice]; + const vote = await prisma.$transaction(async (tx) => { + const v = await tx.vote.upsert({ + where: { sessionId_memberId: { sessionId, memberId: member.id } }, + create: { + sessionId, + memberId: member.id, + choice, + weight: member.votingWeight, + signedMessage: message, + signature: input.signature, + }, + update: { choice, signedMessage: message, signature: input.signature, createdAt: new Date() }, + }); + await tx.auditEvent.create({ + data: { + organizationId: session.proposal.organizationId, + eventType: AuditEventType.VOTE_CAST, + actorMemberId: member.id, + subjectType: "vote", + subjectId: v.id, + payload: { sessionId, memberType: member.memberType, weight: Number(member.votingWeight) }, + }, + }); + return v; + }); + + return { stored: true, verified: true, tally: await sessionTally(sessionId), voteId: vote.id }; +} + +/** Weighted tally over stored (already-verified) votes in a session. */ +export async function sessionTally(sessionId: string): Promise { + const votes = await prisma.vote.findMany({ + where: { sessionId }, + select: { choice: true, weight: true }, + }); + return tally(votes.map((v) => ({ choice: v.choice, weight: Number(v.weight) }))); +} + +/** + * Close a session: outcome from the snapshotted rules; on APPROVED policy + * proposals, activate the next policy version — the ONLY code path that can + * create an active policy version with a session reference. + */ +export async function closeSession(sessionId: string) { + const session = await prisma.votingSession.findUnique({ + where: { id: sessionId }, + include: { proposal: true }, + }); + if (!session) throw new Error("voting session not found"); + if (session.status !== SessionStatus.ACTIVE) throw new Error(`session already ${session.status}`); + + const t = await sessionTally(sessionId); + const outcome = decideOutcome({ + tally: t, + threshold: session.threshold, + quorumPercent: session.quorumPercent, + eligibleWeight: Number(session.eligibleWeight), + }); + + return prisma.$transaction(async (tx) => { + const closed = await tx.votingSession.update({ + where: { id: sessionId }, + data: { status: SessionStatus.CLOSED, outcome, closedAt: new Date() }, + }); + await tx.proposal.update({ + where: { id: session.proposalId }, + data: { status: ProposalStatus.CLOSED }, + }); + await tx.auditEvent.create({ + data: { + organizationId: session.proposal.organizationId, + eventType: AuditEventType.SESSION_CLOSED, + subjectType: "voting_session", + subjectId: sessionId, + payload: { outcome, tally: { ...t } }, + }, + }); + + const policyKey = session.proposal.policyKey; + if ( + outcome === SessionOutcome.APPROVED && + policyKey !== null && + session.proposal.proposedContent !== null + ) { + const organizationId = session.proposal.organizationId; + const current = await tx.policy.findFirst({ + where: { organizationId, key: policyKey, status: PolicyStatus.ACTIVE }, + orderBy: { version: "desc" }, + }); + const nextVersion = (current?.version ?? 0) + 1; + if (current) { + await tx.policy.update({ + where: { id: current.id }, + data: { status: PolicyStatus.SUPERSEDED }, + }); + } + const activated = await tx.policy.create({ + data: { + organizationId, + key: policyKey, + version: nextVersion, + content: session.proposal.proposedContent as Prisma.InputJsonValue, + status: PolicyStatus.ACTIVE, + approvedBySessionId: sessionId, + }, + }); + await tx.auditEvent.create({ + data: { + organizationId, + eventType: AuditEventType.POLICY_ACTIVATED, + subjectType: "policy", + subjectId: activated.id, + payload: { key: policyKey, version: nextVersion, approvedBySessionId: sessionId }, + }, + }); + } + + return { session: closed, outcome, tally: t }; + }); +} diff --git a/src/lib/solon/democracy.ts b/src/lib/solon/democracy.ts deleted file mode 100644 index c097fbe..0000000 --- a/src/lib/solon/democracy.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { prisma } from "@/lib/db"; -import { verifyMessage, voteMessage, type VerifyResult } from "@/lib/bitcoin/message"; - -export type VoteChoice = 'yes' | 'no' | 'abstain'; - -export interface SubmitVoteInput { - /** Bitcoin address of the voting member (must be a registered member). */ - address: string; - choice: VoteChoice; - /** Base64 Bitcoin signed-message signature over the canonical vote message. */ - signature: string; -} - -export interface SubmitVoteResult { - stored: boolean; - verified: boolean; - reason?: string; - verification: VerifyResult; - tally?: Record; - voteId?: string; -} - -/** - * Cryptographic democracy: a vote counts only if its Bitcoin signature - * verifies against the member's address. No authority is trusted — the - * signature is the authorization. Invalid signatures are never stored. - */ -export class Democracy { - async submitVote(sessionId: string, input: SubmitVoteInput): Promise { - const message = voteMessage({ sessionId, choice: input.choice, memberAddress: input.address }); - const verification = verifyMessage(message, input.address, input.signature); - if (!verification.valid) { - return { stored: false, verified: false, reason: verification.reason ?? 'signature does not match address', verification }; - } - - const session = await prisma.voting_sessions.findUnique({ where: { id: sessionId } }); - if (!session) return { stored: false, verified: true, reason: 'voting session not found', verification }; - if (session.status !== 'active') return { stored: false, verified: true, reason: `voting session is ${session.status}`, verification }; - - // Only registered members of this organization may vote — resolved by the - // address recovered from the signature, not by any claim the caller makes. - const member = await prisma.members.findFirst({ - where: { organization_id: session.organization_id, bitcoin_address: input.address, status: 'active' }, - }); - if (!member) return { stored: false, verified: true, reason: 'address is not an active member of this organization', verification }; - - const vote = await prisma.votes.upsert({ - where: { voting_session_id_member_id: { voting_session_id: sessionId, member_id: member.id } }, - create: { - voting_session_id: sessionId, - member_id: member.id, - vote_choice: input.choice, - weight: member.voting_weight, - bitcoin_signature: input.signature, - }, - update: { vote_choice: input.choice, bitcoin_signature: input.signature, signed_at: new Date() }, - }); - - const tally = await this.tally(sessionId); - await prisma.voting_sessions.update({ - where: { id: sessionId }, - data: { total_votes_cast: tally.yes + tally.no + tally.abstain }, - }); - - return { stored: true, verified: true, verification, tally, voteId: vote.id }; - } - - /** Weighted tally over all stored (already-verified) votes in a session. */ - async tally(sessionId: string): Promise> { - const votes = await prisma.votes.findMany({ where: { voting_session_id: sessionId } }); - const t: Record = { yes: 0, no: 0, abstain: 0 }; - for (const v of votes) { - const choice = v.vote_choice as VoteChoice; - if (choice in t) t[choice] += Number(v.weight); - } - return t; - } -} diff --git a/src/lib/solon/types.ts b/src/lib/solon/types.ts deleted file mode 100644 index dd0d011..0000000 --- a/src/lib/solon/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -export type UUID = string; - -export interface Organization { - id: UUID; - name: string; - type: 'nonprofit' | 'dao' | 'cooperative' | 'government'; - bitcoin_wallet_xpub: string; - governance_model: 'democratic' | 'consensus' | 'delegated'; - country: string; - primary_language: 'en' | 'de' | 'fr' | 'it'; -} - -export interface BitcoinTransaction { - id: UUID; - organization_id: UUID; - txid: string; - amount_sats: number; - category: string; - description?: string; - to_address?: string; - from_address?: string; - block_height?: number | null; - transaction_date: string; // ISO -} - -export interface Decision { - id: UUID; - organization_id: UUID; - title: string; - description?: string; - decision_type: 'policy' | 'budget' | 'service_procurement'; - status: 'proposed' | 'voting' | 'approved' | 'rejected'; - effectiveness_kpi?: Record; - origin_member_id?: UUID; - bitcoin_signature?: string; - voting_deadline?: string; -} - -export interface VotingSession { - id: UUID; - organization_id: UUID; - decision_id: UUID | null; - title: string; - voting_type: 'simple_majority' | 'consensus' | 'weighted'; - status: 'active' | 'closed' | 'pending'; - start_date: string; - end_date?: string; - total_votes_cast: number; - bitcoin_signature_required: boolean; - tally?: { yes: number; no: number; abstain: number }; -} - -export interface Vote { - id: UUID; - voting_session_id: UUID; - member_id: UUID; - vote_choice: 'yes' | 'no' | 'abstain'; - weight: number; - bitcoin_signature: string; - signed_at: string; -} - -export interface ServiceRequest { - id: UUID; - organization_id: UUID; - title: string; - description?: string; - budget_sats?: number; - status: 'open' | 'bidding' | 'awarded' | 'completed'; -} - diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..19991ff --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + test: { + include: ["src/**/__tests__/**/*.test.ts"], + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +}); From c0266487ef7c8c99ffbf60d87dfbfbac622f886e Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:13:33 +0200 Subject: [PATCH 2/2] fix(prisma): restore migration_lock.toml lost in the baseline regeneration Co-Authored-By: Claude Fable 5 --- prisma/migrations/migration_lock.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 prisma/migrations/migration_lock.toml diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql"