diff --git a/.github/workflows/ballot-deadline-reminders.yml b/.github/workflows/ballot-deadline-reminders.yml new file mode 100644 index 00000000..50969b94 --- /dev/null +++ b/.github/workflows/ballot-deadline-reminders.yml @@ -0,0 +1,50 @@ +name: Ballot Deadline Reminders + +# Enqueues 48h/24h ballot-deadline reminder emails (derived from on-chain +# proposal expiration epochs) and drains the outbox, by calling the +# authenticated reminder endpoint. Shares NOTIFICATION_DRAIN_SECRET with the +# outbox drain workflow; until it is set both here and on the deployment, runs +# are graceful no-ops. + +on: + schedule: + # Hourly; reminder windows are 24h wide so this is ample resolution. + - cron: '17 * * * *' + # Allow manual triggering for testing + workflow_dispatch: + +jobs: + remind: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Scan ballots and enqueue deadline reminders + env: + API_BASE_URL: 'https://multisig.meshjs.dev' + DRAIN_SECRET: ${{ secrets.NOTIFICATION_DRAIN_SECRET }} + run: | + if [ -z "$DRAIN_SECRET" ]; then + echo "NOTIFICATION_DRAIN_SECRET repo secret is not set; skipping." + exit 0 + fi + + status=$(curl -s -o response.json -w "%{http_code}" -X POST \ + "$API_BASE_URL/api/notifications/ballot-deadlines" \ + -H "Authorization: Bearer $DRAIN_SECRET") + + echo "HTTP $status" + cat response.json || true + echo + + case "$status" in + 200) + ;; + 503) + echo "Endpoint reports NOTIFICATION_DRAIN_SECRET is not configured on the deployment; skipping." + ;; + *) + echo "Ballot deadline reminder request failed." + exit 1 + ;; + esac diff --git a/README.md b/README.md index bf2eb9da..f1b48161 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,8 @@ The application provides comprehensive API documentation through Swagger UI: - `GET /api/v1/walletIds` - Get user's wallet IDs - `POST /api/v1/addTransaction` - Create new transaction - `POST /api/v1/authSigner` - Authenticate signer -- `GET /api/v1/lookupMultisigWallet` - Lookup multisig wallet +- `GET /api/v1/lookupMultisigWallet` - Lookup multisig wallet registrations by signer key hash +- `GET /api/v1/resolveScript` - Resolve a native script (policy id or wallet address) to its signer key hashes - `POST /api/discord/send-message` - Send Discord notifications > πŸ’‘ **Tip**: The Swagger UI provides interactive API testing. Start the dev server and visit `/api-docs` to explore all available endpoints. diff --git a/ROADMAP.md b/ROADMAP.md index 85730706..55b9dfc7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,11 +1,51 @@ # 12-Month Roadmap: Mesh Multi-Sig Wallet -**Timeline:** May 2026 - April 2027 -**Team:** Quirin + Andre, part-time (~25 hrs/week combined), feature-based ownership +**Timeline:** April 2026 – March 2027 **Approach:** Month-by-month cadence combining baseline maintenance with feature delivery. No hard requirements for feature delivery or releases β€” tasks scale up/down based on project activity. --- +## MRP task mapping + +The authoritative mapping between MRP reward tasks and the months below. **Month N of this roadmap = MRP Month N = the calendar month in the same row.** Use this table whenever an MRP task and a roadmap section appear to disagree. + +| MRP task | Calendar month | Roadmap section | On-chain task hash | +|---|---|---|---| +| MRP Month 1 | April 2026 | [Month 1](#month-1--april-2026) | β€” | +| MRP Month 2 | May 2026 | [Month 2](#month-2--may-2026) | `02e1e7c8…65256f` | +| MRP Month 3 | June 2026 | [Month 3](#month-3--june-2026) | `a833f41c…91cef8` | +| MRP Month 4 | July 2026 | [Month 4](#month-4--july-2026) | `27034bf3…dd219a` | +| MRP Month 5 | August 2026 | [Month 5](#month-5--august-2026) | β€” | +| MRP Month 6 | September 2026 | [Month 6](#month-6--september-2026) | β€” | +| MRP Month 7 | October 2026 | [Month 7](#month-7--october-2026) | β€” | +| MRP Month 8 | November 2026 | [Month 8](#month-8--november-2026) | β€” | +| MRP Month 9 | December 2026 | [Month 9](#month-9--december-2026) | β€” | +| MRP Month 10 | January 2027 | [Month 10](#month-10--january-2027) | β€” | +| MRP Month 11 | February 2027 | [Month 11](#month-11--february-2027) | β€” | +| MRP Month 12 | March 2027 | [Month 12](#month-12--march-2027) | β€” | + +> **Why this table exists.** The month headings were renumbered on 2026-08-03 (`984aa46`) to match actual delivery: the original numbering started at "Month 1 β€” May 2026" while Month 1's own proof-of-completion table documented April work. MRP task cards created before that date therefore carry bullet text describing the **following** month β€” e.g. the card headed *MRP Month 2* lists the June workstreams. The table above is what governs; a card's bullet text does not. + +### Underlying PRs per MRP month + +Each MRP month resolves to a concrete, reproducible set of merged pull requests. The "all merged PRs" link runs the exact GitHub search; the counts are Quirin's authored merges in that window. + +| MRP month | Merged PRs (Quirin) | The actual PRs | +|---|---|---| +| **M1 β€” April 2026** | [10](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-04-01..2026-04-30) | [#215](https://github.com/MeshJS/multisig/pull/215) drep prerender fix Β· [#216](https://github.com/MeshJS/multisig/pull/216) missing User table on startup Β· [#217](https://github.com/MeshJS/multisig/pull/217) VKey witness filter + CI smoke system Β· [#218](https://github.com/MeshJS/multisig/pull/218) preprod environment Β· [#219](https://github.com/MeshJS/multisig/pull/219)/[#222](https://github.com/MeshJS/multisig/pull/222)/[#224](https://github.com/MeshJS/multisig/pull/224)/[#226](https://github.com/MeshJS/multisig/pull/226) 12-month roadmap + contributing guide Β· [#227](https://github.com/MeshJS/multisig/pull/227) invalid-CBOR guard in `addTransaction` Β· [#228](https://github.com/MeshJS/multisig/pull/228) M1 proof of completion | +| **M2 β€” May 2026** | [3](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-05-01..2026-05-31) | [#257](https://github.com/MeshJS/multisig/pull/257) pin Mesh SDK + reject witnesses that don't verify against the tx body Β· [#259](https://github.com/MeshJS/multisig/pull/259) Import Wallet wizard Β· [#260](https://github.com/MeshJS/multisig/pull/260) `main`β†’`preprod` merge clearing #229 + CodeQL fixes | +| **M3 β€” June 2026** | [51](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-06-01..2026-06-30) | Governance [#271](https://github.com/MeshJS/multisig/pull/271)–[#272](https://github.com/MeshJS/multisig/pull/272), [#279](https://github.com/MeshJS/multisig/pull/279), [#286](https://github.com/MeshJS/multisig/pull/286), [#296](https://github.com/MeshJS/multisig/pull/296)–[#297](https://github.com/MeshJS/multisig/pull/297), [#300](https://github.com/MeshJS/multisig/pull/300), [#302](https://github.com/MeshJS/multisig/pull/302), [#315](https://github.com/MeshJS/multisig/pull/315) Β· Signing & auth [#273](https://github.com/MeshJS/multisig/pull/273)–[#277](https://github.com/MeshJS/multisig/pull/277), [#281](https://github.com/MeshJS/multisig/pull/281)–[#282](https://github.com/MeshJS/multisig/pull/282), [#324](https://github.com/MeshJS/multisig/pull/324) Β· Mesh 2.0 groundwork [#229](https://github.com/MeshJS/multisig/pull/229), [#269](https://github.com/MeshJS/multisig/pull/269), [#278](https://github.com/MeshJS/multisig/pull/278) Β· Mobile & UX [#287](https://github.com/MeshJS/multisig/pull/287)–[#295](https://github.com/MeshJS/multisig/pull/295) Β· Landing/theme/SEO [#298](https://github.com/MeshJS/multisig/pull/298)–[#299](https://github.com/MeshJS/multisig/pull/299), [#308](https://github.com/MeshJS/multisig/pull/308)–[#318](https://github.com/MeshJS/multisig/pull/318), [#328](https://github.com/MeshJS/multisig/pull/328) Β· Infra & security [#284](https://github.com/MeshJS/multisig/pull/284), [#301](https://github.com/MeshJS/multisig/pull/301), [#319](https://github.com/MeshJS/multisig/pull/319) Β· Docs & releases [#280](https://github.com/MeshJS/multisig/pull/280), [#283](https://github.com/MeshJS/multisig/pull/283), [#285](https://github.com/MeshJS/multisig/pull/285), [#303](https://github.com/MeshJS/multisig/pull/303), [#309](https://github.com/MeshJS/multisig/pull/309), [#320](https://github.com/MeshJS/multisig/pull/320)–[#321](https://github.com/MeshJS/multisig/pull/321) | +| **M4 β€” July 2026** | [16](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-07-01..2026-07-31) | Bot platform & API [#341](https://github.com/MeshJS/multisig/pull/341)–[#345](https://github.com/MeshJS/multisig/pull/345) Β· Agent/crawler surface [#346](https://github.com/MeshJS/multisig/pull/346) Β· DRep vote-history explorer [#337](https://github.com/MeshJS/multisig/pull/337)–[#339](https://github.com/MeshJS/multisig/pull/339) Β· Roadmap & delivery audit [#347](https://github.com/MeshJS/multisig/pull/347), [#350](https://github.com/MeshJS/multisig/pull/350)–[#352](https://github.com/MeshJS/multisig/pull/352) Β· Production hardening [#332](https://github.com/MeshJS/multisig/pull/332)–[#334](https://github.com/MeshJS/multisig/pull/334) | + +Reproduce any row: + +```bash +gh pr list --repo MeshJS/multisig --state merged --limit 100 \ + --search "merged:2026-06-01..2026-06-30 author:QSchlegel" --json number,title,mergedAt +``` + +--- + ## Baseline (applies every month) - Issues and PRs do not stall @@ -14,10 +54,12 @@ --- -## Delivered to date (May – July 2026) +## Delivered to date (April – July 2026) What the product can actually do today, as verified in the codebase on 2026-07-26. The per-month **Progress** tables below track plan-vs-actual; this section is the cumulative capability inventory, and it is the input that reshaped M4–M6. +Coverage starts at **April**, the programme's first month β€” April's output is infrastructure rather than user-facing features (the preprod environment, the real-chain smoke system, transaction-integrity guards), so it shows up inside the sections below rather than as a headline capability of its own. + > **Caveat β€” delivered β‰  live.** Everything below is merged on `preprod`. `main` is 75 commits behind and the production database is four migrations behind, so a good share of this is not yet reachable on the production deployment. Closing that gap is the first item in August. ### Governance @@ -41,23 +83,28 @@ What the product can actually do today, as verified in the codebase on 2026-07-2 ### Notifications -Resend-backed email channel with a real outbox: `NotificationDelivery` carries an idempotency key, attempt counter, `nextAttemptAt` backoff and nine statuses (including four distinct skip reasons), drained by `drainNotificationOutbox` via a token-authenticated `POST /api/notifications/drain`. Event types are `email.verify`, `signature.required`, `signature.reminder`. Per-wallet Γ— per-signer settings UI on the wallet Info page, plus hashed-token email verification ([#322](https://github.com/MeshJS/multisig/pull/322), [#326](https://github.com/MeshJS/multisig/pull/326)). **Gap:** no scheduled workflow drains the outbox β€” `daily-balance-snapshots.yml` is the only cron in the repo. +Resend-backed email channel with a real outbox: `NotificationDelivery` carries an idempotency key, attempt counter, `nextAttemptAt` backoff and nine statuses (including four distinct skip reasons), drained by `drainNotificationOutbox` via a token-authenticated `POST /api/notifications/drain`. Event types are `email.verify`, `signature.required`, `signature.reminder`, `threshold.reached` (a transaction/payload collected enough signatures) and `ballot.deadline` (48h/24h reminders for ballots and pending vote transactions, derived from on-chain proposal expiration epochs, scanned hourly by `ballot-deadline-reminders.yml` via `POST /api/notifications/ballot-deadlines`). Per-wallet Γ— per-signer settings UI on the wallet Info page, plus hashed-token email verification ([#322](https://github.com/MeshJS/multisig/pull/322), [#326](https://github.com/MeshJS/multisig/pull/326)). **Gap:** no scheduled workflow drains the outbox β€” `daily-balance-snapshots.yml` is the only cron in the repo. ### Testing & CI - **Playwright E2E**: 11 spec files, ~54 tests, in `e2e/tests/` β€” wallet creation (legacy + SDK), ring transfers on real preprod, staking, proxy, DRep/ballot UI, bot management, notification settings, wallet access control, signing rejection, responsive smoke. Runs in Docker via `pr-playwright-browser.yml`, serialized against the v1 smoke job through a shared `ci-preprod-wallets` concurrency group ([#323](https://github.com/MeshJS/multisig/pull/323), [#335](https://github.com/MeshJS/multisig/pull/335), [#336](https://github.com/MeshJS/multisig/pull/336)). -- Real-chain smoke system closed ([#213](https://github.com/MeshJS/multisig/issues/213)); deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)). +- **Preprod environment + real-chain smoke CI** β€” built in April: the `preprod` branch and environment ([#218](https://github.com/MeshJS/multisig/pull/218)) and the CI smoke-test system that exercises the route chain against real preprod ([#217](https://github.com/MeshJS/multisig/pull/217)), which skips gracefully when `SMOKE_*` secrets are absent. [#213](https://github.com/MeshJS/multisig/issues/213) closed once the first real run was linked. Everything since β€” the Playwright suite above included β€” runs on this foundation. +- deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)). ### Platform -Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul; on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)). +**Transaction & signing integrity** β€” the through-line from April onward: extraneous VKey witnesses filtered out of submitted transactions ([#217](https://github.com/MeshJS/multisig/pull/217)); an invalid-CBOR guard on `POST /api/v1/addTransaction` plus a degraded "unreadable transaction" card with Reject & Delete, so an API-poisoned row can no longer lock a wallet's UTxOs ([#227](https://github.com/MeshJS/multisig/pull/227), [#211](https://github.com/MeshJS/multisig/issues/211)); Mesh SDK pinned to exact versions after a lockfile patch drift changed Conway CBOR encoding and made multisig DRep votes fail on chain, with a client-side guard that now rejects witnesses which don't verify against the body they're attached to ([#257](https://github.com/MeshJS/multisig/pull/257)). + +**Wallet lifecycle** β€” Import Wallet wizard covering four sources (another multisig instance, Summon, native-script CBOR, JSON backup) with `importWallet`/`exportWallet` procedures, cross-instance endpoints reusing the CIP-8 `checkSignature` path, a downloadable JSON backup and a `lockedSigners` gate so imported wallets can't silently diverge from their origin ([#259](https://github.com/MeshJS/multisig/pull/259)); on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)). + +**Everything else** β€” Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul. ### Landed ahead of schedule | Capability | Planned | Actually delivered | Effect on the plan | |------------|---------|--------------------|--------------------| | Governance metadata fix (#122) | M7 (Nov) | June | Closed | -| Wallet V2 β€” registration & discovery (#33) | M3 (Jul) | July ([#340](https://github.com/MeshJS/multisig/pull/340)) | On time; feeds the Discover page (#52), which moves up from M10 | +| Wallet V2 β€” registration & discovery (#33) | M3 (Jul) | July ([#340](https://github.com/MeshJS/multisig/pull/340)) | On time; feeds the M5 Discover lookup by signer/policy, which moves up from M10 (#52 was closed as unspecified; scope now tracked under #33) | | Bot platform (SDK/reference client, scoped auth, ballot API) | M7 (Nov) | July | M7 reduces to **webhooks only** β€” no webhook code exists yet | | API documentation & developer portal | M8 (Dec) | June–July | Done; M8 slot freed | | Pending transactions on user's homepage (#125) | M7 (Nov) | Shipped β€” surfaced on the wallets dashboard | Issue still open; verify and close | @@ -163,7 +210,7 @@ End-of-month snapshot. Last updated 2026-07-26. | FROST research kickoff (#220) | Not started | Carried to August. Needs to start there to leave runway before the October go/no-go | | CI/maintenance baseline | Watch item β€” unchanged | `pr-multisig-v1-smoke.yml` still `exit 1`s in its "Validate required CI secrets" step when secrets are absent, and dependabot-triggered runs never receive repo Actions secrets. Every dependabot PR is therefore red for systemic reasons, not because of the version bump β€” 7 are open, the oldest since 2026-06-15. The sibling `ci-smoke-preprod.yml` already has the skip-when-unconfigured guard to copy | | Wallet V2 (#33) | Delivered | On-chain wallet registration + discovery shipped in [#340](https://github.com/MeshJS/multisig/pull/340) | -| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month β€” see [Delivered to date](#delivered-to-date-may--july-2026) | +| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month β€” see [Delivered to date](#delivered-to-date-april--july-2026) | --- @@ -171,7 +218,7 @@ End-of-month snapshot. Last updated 2026-07-26. **Focus:** Close the production release gap, then start Document Sign-Off (see [Flagship feature](#flagship-feature--document-sign-off)). -Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-may--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work. +Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-april--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work. **Quirin** @@ -208,7 +255,7 @@ Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date | Task | Issues | |------|--------| | Transaction visualization MVP (ship) β€” extend the tx visualizer to work with bot and display/build all tx types multisig is capable of doing | | -| Discover page β€” fold into the delivered Wallet V2 registration/discovery rather than building it standalone; add lookup by signer/policy *(moved up from M10)* | #52, #33 | +| Discover β€” lookup by signer/policy on the import wizard's Discover tab (search by signer address/key hash or wallet address/script hash; view-only for non-participants), `resolveScript` route, MCP `multisig_lookup_wallet` by script hash/address. No standalone page β€” rides the delivered Wallet V2 discovery *(moved up from M10)* | #33 | | Notification digests & deadline reminders β€” ballot-deadline and threshold-reached emails on the existing outbox (product work, infrastructure already exists) | | | Monthly report | | @@ -216,7 +263,7 @@ Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date ## Month 6 β€” September 2026 -**Focus:** Document Sign-Off provenance, FROST findings, hardware wallets. +**Focus:** Document Sign-Off provenance, FROST findings, and MCP transaction review. **Quirin** @@ -229,14 +276,15 @@ Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date | Task | Issues | |------|--------| -| Hardware wallet support β€” Ledger/Trezor. **Scope the CIP-8 `signData` constraint during the M4–M5 Sign-Off build, not after** β€” Ledger/Trezor support for `signData` is limited, and Document Sign-Off approvals depend on it | #44 | -| UX papercut batch β€” full-address verification (#196), transaction pagination (#30), better 404 page (#22) | #196, #30, #22 | +| MCP unsigned transaction creation β€” create unsigned multisig transactions through MCP and prepare them for signer review | | +| Transaction review PNG & in-chat review β€” generate a clear PNG summary containing the key transaction details and display it in the user's chat for human review; MCP must not sign or broadcast on the user's behalf | | +| Project task board with multisig payouts β€” let users and agents create and manage project tasks, optionally define one or more payment recipients and amounts, and prepare payouts for multisig review and approval | | --- ## Month 7 β€” October 2026 -**Focus:** Governance polish, dApp connector, bot platform. +**Focus:** Governance polish, dApp connector, and advanced transaction building. Revised 2026-07-26: the governance metadata fix closed in June, and the bot platform and developer portal shipped in July, so this month absorbs the work those slots were holding. @@ -251,9 +299,8 @@ Revised 2026-07-26: the governance metadata fix closed in June, and the bot plat | Task | Issues | |------|--------| -| Bot platform β€” webhooks. The rest of "v2" (scoped auth, reference client, example bots, OpenAPI) shipped in July; webhooks are the only unbuilt piece β€” no webhook code exists in `src/` today | | -| Multisig MCP server β€” expose the existing bot API as an MCP server so an agent can act as a wallet observer or ballot drafter. Small step from `/llms.txt` + `/api/skill` + the scoped bot JWT, and a genuine differentiator | | -| Verify and close pending-transactions-on-homepage (#125), already surfaced on the wallets dashboard | #125 | +| Output datum controls β€” let users attach and edit validated inline datum values on individual transaction outputs under Advanced, preserve each output-to-datum association through draft editing, and encode the datums in the generated unsigned transaction | | +| Plutus script-spend and redeemer controls β€” let users configure Plutus-controlled inputs with their script, datum source, and validated redeemer under Advanced; use an ADA-only collateral UTxO supplied by the connected signer, require that collateral owner's signature, show the amount at risk, and evaluate and preserve the script data in the generated unsigned transaction. Automatic collateral creation, reservation, and collateral-return management remain in #221 | | --- @@ -442,13 +489,13 @@ Aggregated view of the 12-month roadmap split by contributor. Each task has a si - [M4] Unblock dependabot CI β€” skip-when-unconfigured guard in `pr-multisig-v1-smoke.yml`, then clear the 7 open dependency PRs - [M4] Notification center follow-ups β€” gov-proposal improvements, Playwright coverage, scheduled outbox drain (#327) - [M4–5] Document Sign-Off MVP β€” Documents UI, six-state lifecycle, signer review, diffs -- [M5] Discover page + lookup by signer/policy (#52, #33) β€” moved up from M10 +- [M5] Discover β€” lookup by signer/policy on the Discover tab, `resolveScript` route, MCP policy lookup (#33) β€” moved up from M10 - [M5] Notification digests & deadline reminders -- [M6] Hardware wallet support β€” Ledger/Trezor (#44); CIP-8 `signData` constraint scoped during M4–M5 -- [M6] UX papercut batch β€” full-address verification (#196), tx pagination (#30), 404 page (#22) -- [M7] Bot platform β€” webhooks (the rest of "v2" shipped in July) -- [M7] Multisig MCP server β€” agent access over the existing bot API -- [M7] Verify and close pending transactions on homepage (#125) +- [M6] MCP unsigned transaction creation β€” create unsigned multisig transactions through MCP and prepare them for signer review +- [M6] Transaction review PNG & in-chat review β€” generate and display a visual transaction summary in chat without signing or broadcasting +- [M6] Project task board with multisig payouts β€” let users and agents create and manage project tasks, optionally define one or more payment recipients and amounts, and prepare payouts for multisig review and approval +- [M7] Output datum controls β€” attach and edit validated inline datum values on individual transaction outputs under Advanced, preserve each output-to-datum association through draft editing, and encode the datums in the generated unsigned transaction +- [M7] Plutus script-spend and redeemer controls β€” configure Plutus-controlled inputs with their script, datum source, and validated redeemer under Advanced; use an ADA-only collateral UTxO supplied by the connected signer, require that collateral owner's signature, show the amount at risk, and evaluate and preserve the script data in the generated unsigned transaction. Automatic collateral creation, reservation, and collateral-return management remain in #221 - [M8] Backlog cleanup, dependency/security updates - [M9] User profiles and contacts - [M11] Document Sign-Off v3 β€” Collaboration & standards (research) diff --git a/docs/notification-center-plan.md b/docs/notification-center-plan.md index 1ad88681..78fc0896 100644 --- a/docs/notification-center-plan.md +++ b/docs/notification-center-plan.md @@ -432,6 +432,15 @@ Manual QA: 7. Enable production for verified internal/test signers. 8. Remove or migrate client-side Discord reminder calls after email path is stable. +## Phase 12: Threshold-Reached and Ballot-Deadline Events (shipped 2026-08-27) + +Two more toggles on `WalletSignerNotificationSetting` (`notifyThresholdReached`, `notifyBallotDeadlines`) and two event types on the same outbox: + +- `threshold.reached` β€” enqueued by `enqueueThresholdReachedNotifications` (`src/lib/notifications/center.ts`) from `transaction.updateTransaction`, `signable.updateSignable` and `POST /api/v1/signTransaction` whenever a signature update moves a resource from below to at-or-above `getRequiredSignerCount`. Audience is every wallet signer except the actor (`resolveWalletSignerRecipients`), so unlike `signature.required` the creator and earlier signers are included. One row per resource Γ— recipient. +- `ballot.deadline` β€” `enqueueBallotDeadlineReminders` (`src/lib/notifications/ballotDeadlines.ts`) has two sources: saved ballots, and pending (`state: 0`) transactions that vote (proposal ids read from `txJson.votes[].vote.govActionId` and `txJson.proxyBot.votes[].proposalId` via `extractVoteProposalIds`), so a direct vote cast without a ballot is covered too. The deadline is the earliest active proposal's Blockfrost `expiration` epoch (end of that epoch = `end_time(latest) + Ξ”epochs Γ— 432000s`); exactly one window per run (`48h` = 24–48h out, `24h` = 0–24h out); rows are keyed on `` Γ— id Γ— signer Γ— window Γ— expiration epoch. Transaction reminders stop by themselves once the tx is submitted. A ballot whose expiring proposals are all covered by a pending vote tx of the same wallet defers to that transaction's reminder (one email, not two); ballots with a submitted `Ballot Vote:`/`Proxy Ballot Vote:` transaction created after the ballot are skipped. Client-side proxy votes (the vote lives in a Plutus redeemer) are covered because `useTransaction.newTransaction` accepts `txJsonExtras`, and the proxy-vote call sites (`proposal/voteButtton.tsx`, `ballot/ballot.tsx`) annotate the stored txJson with the same `proxyBot: { kind: "proxyVote", votes }` block the bot API writes β€” a client-written annotation, only present on transactions created after 2026-08-27. Driven hourly by `.github/workflows/ballot-deadline-reminders.yml` β†’ `POST /api/notifications/ballot-deadlines` (same `NOTIFICATION_DRAIN_SECRET`). + +The worker's send-time preference re-check is keyed by `getNotificationPreferenceField(eventType, resourceType)` (`events.ts`), so both new events honour toggles flipped after enqueue. + ## Open Questions - Should wallet creators be allowed to enter another signer's email, or should emails only be entered and verified by the signer themselves? diff --git a/e2e/tests/discover-wallet-ui.spec.ts b/e2e/tests/discover-wallet-ui.spec.ts new file mode 100644 index 00000000..39dd1d81 --- /dev/null +++ b/e2e/tests/discover-wallet-ui.spec.ts @@ -0,0 +1,276 @@ +// Discover tab (import wizard, "Discover on-chain"): lookup by signer / policy. +// +// The chain reads behind the tab are intercepted in the browser so the spec is +// deterministic and needs no on-chain registration: +// - /api/v1/lookupMultisigWallet -> one registration whose participants ECHO +// the requested pubKeyHashes. The default +// view therefore lists a wallet the signer +// belongs to (Import enabled), while a +// search for someone else's keys yields a +// wallet they are not part of (view-only). +// - /api/v1/resolveScript -> a fixed pair of fake signer hashes, so a +// policy search resolves to a wallet the +// signer is not part of; returns no signers +// for the bare-hash fallback case. +// +// Import itself (resolveRegistrationScript + script reconstruction) is covered +// by unit tests; nothing here is ever persisted. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; + +const REGISTRATION_TX = "4".repeat(64); +const FOREIGN_SIG_HASHES = ["5".repeat(56), "6".repeat(56)]; + +type DiscoveryMocks = { + /** every intercepted discovery request, path + query, in order */ + requests: string[]; +}; + +async function mockDiscoveryRoutes( + page: Page, + options: { resolveToSigners: boolean }, +): Promise { + const requests: string[] = []; + + await page.route("**/api/v1/lookupMultisigWallet**", async (route) => { + const url = new URL(route.request().url()); + requests.push(`${url.pathname}${url.search}`); + const hashes = (url.searchParams.get("pubKeyHashes") ?? "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + const participants = Object.fromEntries( + hashes.map((hash, i) => [hash, { name: `Signer ${i + 1}` }]), + ); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + tx_hash: REGISTRATION_TX, + json_metadata: { + types: [0], + name: "E2E Registered Wallet", + description: "Mocked CIP-0146 registration", + participants, + }, + }, + ]), + }); + }); + + await page.route("**/api/v1/resolveScript**", async (route) => { + const url = new URL(route.request().url()); + requests.push(`${url.pathname}${url.search}`); + const scriptHash = url.searchParams.get("scriptHash") ?? ""; + const sigHashes = options.resolveToSigners ? FOREIGN_SIG_HASHES : []; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + scriptHash, + stakeCredentialHash: null, + scriptJson: options.resolveToSigners + ? { + type: "atLeast", + required: 2, + scripts: sigHashes.map((keyHash) => ({ type: "sig", keyHash })), + } + : null, + sigHashes, + }), + }); + }); + + return { requests }; +} + +async function openDiscoverTab(page: Page): Promise { + await page.goto("/wallets/import-wallet?tab=discover"); + await expect(page.getByText("Discover registered wallets")).toBeVisible({ + timeout: 60_000, + }); +} + +function searchBox(page: Page) { + return page.getByRole("textbox", { + name: "Search by signer or wallet address", + }); +} + +async function search(page: Page, value: string): Promise { + await searchBox(page).fill(value); + await page.getByRole("button", { name: "Search", exact: true }).click(); +} + +test.describe("discover tab lookup by signer / policy", () => { + test("lists the connected signer's registered wallet as importable", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true }); + + await openDiscoverTab(page); + + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("you", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Import", exact: true })).toBeEnabled(); + await expect(page.getByText("View only")).toHaveCount(0); + expect( + mocks.requests.some((r) => r.startsWith("/api/v1/lookupMultisigWallet")), + ).toBe(true); + }); + + test("searching another signer's address shows their wallet view-only", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + const ctx = loadContext(); + const otherSigner = ctx.signerAddresses[1]; + if (!otherSigner) throw new Error("Bootstrap context needs two signers"); + + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true }); + await openDiscoverTab(page); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + + await search(page, otherSigner); + + await expect(page.getByText(/found for this signer/)).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("match", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "View only" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Import", exact: true })).toHaveCount(0); + await expect( + page.getByText(/isn't a participant of this wallet/), + ).toBeVisible(); + + // The lookup was made with the searched signer's keys, not the user's. + const { resolvePaymentKeyHash } = await import("@meshsdk/core"); + const otherHash = resolvePaymentKeyHash(otherSigner).toLowerCase(); + expect( + mocks.requests.some( + (r) => + r.startsWith("/api/v1/lookupMultisigWallet") && r.includes(otherHash), + ), + ).toBe(true); + }); + + test("searching a multisig address resolves the script and matches by policy", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + const ctx = loadContext(); + const walletAddress = ctx.wallets[0]?.walletAddress; + if (!walletAddress) throw new Error("Bootstrap context has no wallet address"); + + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true }); + await openDiscoverTab(page); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + + await search(page, walletAddress); + + await expect(page.getByText(/has 2 signers; showing registrations/)).toBeVisible( + { timeout: 30_000 }, + ); + await expect(page.getByText(/found for this wallet/)).toBeVisible(); + await expect(page.getByRole("button", { name: "View only" })).toBeDisabled(); + + // Policy path: resolveScript by hash first, then lookup by its signers. + const resolveIdx = mocks.requests.findIndex((r) => + r.startsWith("/api/v1/resolveScript?scriptHash="), + ); + expect(resolveIdx).toBeGreaterThanOrEqual(0); + const followUp = mocks.requests + .slice(resolveIdx + 1) + .find((r) => r.startsWith("/api/v1/lookupMultisigWallet")); + expect(followUp).toBeDefined(); + for (const hash of FOREIGN_SIG_HASHES) { + expect(followUp).toContain(hash); + } + }); + + test("a bare hash that is not a script falls back to a signer lookup", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + const ctx = loadContext(); + const self = ctx.signerAddresses[0]; + if (!self) throw new Error("Bootstrap context has no signer address"); + const { resolvePaymentKeyHash } = await import("@meshsdk/core"); + const selfHash = resolvePaymentKeyHash(self).toLowerCase(); + + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: false }); + await openDiscoverTab(page); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + + await search(page, selfHash.toUpperCase()); + + await expect(page.getByText(/found for this hash/)).toBeVisible({ + timeout: 30_000, + }); + // The echoed registration lists the user's own hash, so it is importable. + await expect(page.getByRole("button", { name: "Import", exact: true })).toBeEnabled(); + expect( + mocks.requests.some((r) => + r.startsWith(`/api/v1/resolveScript?scriptHash=${selfHash}`), + ), + ).toBe(true); + expect( + mocks.requests.some( + (r) => + r.startsWith("/api/v1/lookupMultisigWallet") && r.includes(selfHash), + ), + ).toBe(true); + }); + + test("malformed input shows an inline error and makes no request", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true }); + await openDiscoverTab(page); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + const before = mocks.requests.length; + + await search(page, "not-an-address"); + + // Next's route announcer is also role="alert", so match on the copy. + const inlineError = page.getByText(/Enter a signer address/); + await expect(inlineError).toBeVisible(); + await expect(inlineError).toHaveAttribute("role", "alert"); + await expect( + page.getByText("Fix the search to look up registrations."), + ).toBeVisible(); + expect(mocks.requests.length).toBe(before); + + // Clear restores the default (own keys) listing. + await page.getByRole("button", { name: "Clear", exact: true }).click(); + await expect(inlineError).toHaveCount(0); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + }); +}); diff --git a/e2e/tests/notification-settings-ui.spec.ts b/e2e/tests/notification-settings-ui.spec.ts index f9cb20aa..471f4eee 100644 --- a/e2e/tests/notification-settings-ui.spec.ts +++ b/e2e/tests/notification-settings-ui.spec.ts @@ -15,11 +15,25 @@ // The email delivery pipeline itself (outbox rows, worker, verify link) is // server-side and covered outside the browser suite. +import type { Page } from "@playwright/test"; import { test, expect } from "../fixtures/authFixture"; import { loadContext } from "../helpers/contextLoader"; import { createThrowawayWallet } from "../helpers/apiHelpers"; import { mockWalletUtxos } from "../helpers/phase3Mocks"; +// The "Notification settings saved" toast lingers for 4s, so it cannot be +// used to sequence back-to-back saves: a stale toast from the previous save +// satisfies the assertion before the next mutation's request has landed. +// Sync on the tRPC response instead. +function waitForSettingsSave(page: Page) { + return page.waitForResponse( + (response) => + response.url().includes("notification.upsertWalletSignerSetting") && + response.request().method() === "POST", + { timeout: 60_000 }, + ); +} + test.describe("notification settings UI", () => { test("signer saves an email, sees verification state, and toggles persist", async ({ page, @@ -51,7 +65,13 @@ test.describe("notification settings UI", () => { const emailInput = page.getByLabel("Email address"); await expect(emailInput).toBeEnabled({ timeout: 30_000 }); await emailInput.fill(email); + const emailSavePromise = waitForSettingsSave(page); await page.getByRole("button", { name: "Save", exact: true }).click(); + const emailSaveResponse = await emailSavePromise; + expect( + emailSaveResponse.ok(), + `email save failed ${emailSaveResponse.status()}`, + ).toBe(true); await expect(page.getByText("Notification settings saved").first()).toBeVisible({ timeout: 30_000, }); @@ -76,12 +96,41 @@ test.describe("notification settings UI", () => { }); await expect(transactionsToggle).toBeEnabled({ timeout: 30_000 }); await expect(transactionsToggle).toHaveAttribute("aria-checked", "true"); + const transactionsSavePromise = waitForSettingsSave(page); await transactionsToggle.click(); - await expect(page.getByText("Notification settings saved").first()).toBeVisible({ - timeout: 30_000, + const transactionsSaveResponse = await transactionsSavePromise; + expect( + transactionsSaveResponse.ok(), + `transactions toggle save failed ${transactionsSaveResponse.status()}`, + ).toBe(true); + + // The threshold-reached and ballot-deadline toggles persist the same way. + const thresholdToggle = page.getByRole("switch", { + name: "Toggle threshold reached notifications", + }); + await expect(thresholdToggle).toBeEnabled({ timeout: 30_000 }); + await expect(thresholdToggle).toHaveAttribute("aria-checked", "true"); + const thresholdSavePromise = waitForSettingsSave(page); + await thresholdToggle.click(); + const thresholdSaveResponse = await thresholdSavePromise; + expect( + thresholdSaveResponse.ok(), + `threshold toggle save failed ${thresholdSaveResponse.status()}`, + ).toBe(true); + const ballotToggle = page.getByRole("switch", { + name: "Toggle ballot deadline notifications", }); + await expect(ballotToggle).toBeEnabled({ timeout: 30_000 }); + await expect(ballotToggle).toHaveAttribute("aria-checked", "true"); + const ballotSavePromise = waitForSettingsSave(page); + await ballotToggle.click(); + const ballotSaveResponse = await ballotSavePromise; + expect( + ballotSaveResponse.ok(), + `ballot toggle save failed ${ballotSaveResponse.status()}`, + ).toBe(true); - // Everything survives a full reload: email, badge, and the toggle. + // Everything survives a full reload: email, badge, and the toggles. await page.reload(); await expect( page.getByRole("heading", { name: "Email Notifications" }), @@ -97,5 +146,11 @@ test.describe("notification settings UI", () => { name: "Toggle transaction signature notifications", }), ).toHaveAttribute("aria-checked", "false", { timeout: 30_000 }); + await expect( + page.getByRole("switch", { name: "Toggle threshold reached notifications" }), + ).toHaveAttribute("aria-checked", "false", { timeout: 30_000 }); + await expect( + page.getByRole("switch", { name: "Toggle ballot deadline notifications" }), + ).toHaveAttribute("aria-checked", "false", { timeout: 30_000 }); }); }); diff --git a/e2e/tests/responsive-smoke.spec.ts b/e2e/tests/responsive-smoke.spec.ts index ff87a8bd..131a1f18 100644 --- a/e2e/tests/responsive-smoke.spec.ts +++ b/e2e/tests/responsive-smoke.spec.ts @@ -132,6 +132,40 @@ for (const viewport of VIEWPORTS) { await expectNoHorizontalOverflow(page, "new transaction form"); }); + test("transaction builder keeps its actions on screen", async ({ + page, + authenticateAs, + }) => { + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + await authenticateAs(page, 0); + await mockUtxos(page); + await page.goto(`/wallets/${wallet.walletId}/build`); + await expect(page.getByTestId("tx-builder-canvas")).toBeVisible({ + timeout: 60_000, + }); + + // The primary action must sit inside the viewport, not merely exist: + // the shell clips horizontal overflow, so a button pushed past the + // right edge would still count as "visible" to Playwright. + const buildButton = page.getByTestId("tx-builder-build"); + await buildButton.scrollIntoViewIfNeeded(); + await expect(buildButton).toBeVisible(); + const box = await buildButton.boundingBox(); + expect(box, "build button has no bounding box").not.toBeNull(); + expect( + box!.x + box!.width, + `build button overflows the ${viewport.width}px viewport`, + ).toBeLessThanOrEqual(viewport.width); + expect(box!.x).toBeGreaterThanOrEqual(0); + + // Phones get the palette folded into a single "Add" menu. + await expect(page.getByTestId("tx-builder-add-menu")).toBeVisible(); + await expect(page.getByTestId("tx-builder-add-recipient")).toBeHidden(); + + await expectNoHorizontalOverflow(page, "transaction builder"); + }); + test("wallet connect entry point works without overflow", async ({ page, injectWallet, diff --git a/prisma/migrations/20260805090000_add_document_signoff/migration.sql b/prisma/migrations/20260805090000_add_document_signoff/migration.sql new file mode 100644 index 00000000..4df95649 --- /dev/null +++ b/prisma/migrations/20260805090000_add_document_signoff/migration.sql @@ -0,0 +1,190 @@ +-- Document Sign-Off (PRD-001) β€” five-entity model. +-- +-- Approval binds to an exact content hash on a DocumentVersion, never to the +-- mutable Document container. DocumentSignerSnapshot freezes the wallet's +-- signer set + threshold at review start so later membership changes cannot +-- rewrite history. DocumentEvent is append-only and feeds the proof export. + +-- CreateEnum +CREATE TYPE "DocumentStatus" AS ENUM ('Draft', 'InReview', 'Approved', 'Rejected', 'Superseded', 'Archived'); + +-- CreateEnum +CREATE TYPE "DocumentReviewAction" AS ENUM ('approve', 'reject'); + +-- CreateEnum +CREATE TYPE "DocumentStorageMode" AS ENUM ('hashOnly', 'inline', 'external'); + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "documentType" TEXT, + "createdBy" TEXT NOT NULL, + "status" "DocumentStatus" NOT NULL DEFAULT 'Draft', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentVersion" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "versionNumber" INTEGER NOT NULL, + "contentHash" TEXT NOT NULL, + "hashAlgorithm" TEXT NOT NULL DEFAULT 'sha256', + "fileName" TEXT, + "mimeType" TEXT, + "fileSize" INTEGER, + "storageMode" "DocumentStorageMode" NOT NULL DEFAULT 'hashOnly', + "contentRef" TEXT, + "contentInline" TEXT, + "reviewInstructions" TEXT, + "status" "DocumentStatus" NOT NULL DEFAULT 'Draft', + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewStartedAt" TIMESTAMP(3), + "decidedAt" TIMESTAMP(3), + "supersededAt" TIMESTAMP(3), + + CONSTRAINT "DocumentVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentReview" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "action" "DocumentReviewAction" NOT NULL, + "comment" TEXT, + "payload" TEXT NOT NULL, + "signature" TEXT NOT NULL, + "signatureKey" TEXT NOT NULL, + "signedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentReview_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentSignerSnapshot" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signersAddresses" TEXT[], + "signersDescriptions" TEXT[], + "requiredSigners" INTEGER NOT NULL, + "walletPolicyHash" TEXT NOT NULL, + "capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentSignerSnapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentEvent" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "versionId" TEXT, + "type" TEXT NOT NULL, + "actorAddress" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Document_walletId_idx" ON "Document"("walletId"); + +-- CreateIndex +CREATE INDEX "Document_walletId_status_idx" ON "Document"("walletId", "status"); + +-- CreateIndex +CREATE INDEX "Document_createdBy_idx" ON "Document"("createdBy"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_documentId_idx" ON "DocumentVersion"("documentId"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_contentHash_idx" ON "DocumentVersion"("contentHash"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_status_idx" ON "DocumentVersion"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentVersion_documentId_versionNumber_key" ON "DocumentVersion"("documentId", "versionNumber"); + +-- CreateIndex +CREATE INDEX "DocumentReview_versionId_idx" ON "DocumentReview"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentReview_signerAddress_idx" ON "DocumentReview"("signerAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentReview_versionId_signerAddress_key" ON "DocumentReview"("versionId", "signerAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentSignerSnapshot_versionId_key" ON "DocumentSignerSnapshot"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentSignerSnapshot_walletId_idx" ON "DocumentSignerSnapshot"("walletId"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_documentId_createdAt_idx" ON "DocumentEvent"("documentId", "createdAt"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_versionId_idx" ON "DocumentEvent"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_type_idx" ON "DocumentEvent"("type"); + +-- AddForeignKey +ALTER TABLE "DocumentVersion" ADD CONSTRAINT "DocumentVersion_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentReview" ADD CONSTRAINT "DocumentReview_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentSignerSnapshot" ADD CONSTRAINT "DocumentSignerSnapshot_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentEvent" ADD CONSTRAINT "DocumentEvent_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentEvent" ADD CONSTRAINT "DocumentEvent_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Row Level Security β€” same contract as 20251215090000_enable_rls_disable_postgrest +-- and 20260706100000_enable_rls_followup_tables: RLS on unconditionally, deny-all +-- policies for the PostgREST roles when those roles exist. Prisma connects as the +-- table owner / service role and continues to bypass RLS. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY[ + 'Document', 'DocumentVersion', 'DocumentReview', + 'DocumentSignerSnapshot', 'DocumentEvent' + ]) + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END LOOP; +END $$; diff --git a/prisma/migrations/20260813000000_add_proxy_member/migration.sql b/prisma/migrations/20260813000000_add_proxy_member/migration.sql new file mode 100644 index 00000000..0d5c35d1 --- /dev/null +++ b/prisma/migrations/20260813000000_add_proxy_member/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "ProxyMember" ( + "id" TEXT NOT NULL, + "proxyId" TEXT NOT NULL, + "address" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'viewer', + "label" TEXT, + "invitedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProxyMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ProxyMember_proxyId_idx" ON "ProxyMember"("proxyId"); + +-- CreateIndex +CREATE INDEX "ProxyMember_address_idx" ON "ProxyMember"("address"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProxyMember_proxyId_address_key" ON "ProxyMember"("proxyId", "address"); + +-- Match the RLS posture applied to every other table (see +-- 20251215090000_enable_rls_disable_postgrest): RLS on unconditionally, plus +-- deny-all policies for the PostgREST roles when they exist. Prisma connects +-- with the service role and keeps bypassing RLS. +DO $$ +BEGIN + ALTER TABLE "ProxyMember" ENABLE ROW LEVEL SECURITY; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + CREATE POLICY "deny_all_anon_ProxyMember" ON "ProxyMember" + FOR ALL TO anon USING (false) WITH CHECK (false); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE POLICY "deny_all_authenticated_ProxyMember" ON "ProxyMember" + FOR ALL TO authenticated USING (false) WITH CHECK (false); + END IF; +END $$; diff --git a/prisma/migrations/20260821120000_document_attestation/migration.sql b/prisma/migrations/20260821120000_document_attestation/migration.sql new file mode 100644 index 00000000..adc63736 --- /dev/null +++ b/prisma/migrations/20260821120000_document_attestation/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "DocumentAttestation" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "sequence" INTEGER NOT NULL, + "contentHash" TEXT NOT NULL, + "prevAttestationHash" TEXT NOT NULL, + "attestationHash" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "signature" TEXT NOT NULL, + "publicKeyId" TEXT NOT NULL, + "attestedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DocumentAttestation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentAttestation_versionId_key" ON "DocumentAttestation"("versionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentAttestation_attestationHash_key" ON "DocumentAttestation"("attestationHash"); + +-- CreateIndex +CREATE INDEX "DocumentAttestation_documentId_idx" ON "DocumentAttestation"("documentId"); + +-- CreateIndex +CREATE INDEX "DocumentAttestation_contentHash_idx" ON "DocumentAttestation"("contentHash"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentAttestation_documentId_sequence_key" ON "DocumentAttestation"("documentId", "sequence"); + +-- AddForeignKey +ALTER TABLE "DocumentAttestation" ADD CONSTRAINT "DocumentAttestation_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260822090000_document_draft/migration.sql b/prisma/migrations/20260822090000_document_draft/migration.sql new file mode 100644 index 00000000..b0a2e4b4 --- /dev/null +++ b/prisma/migrations/20260822090000_document_draft/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "DocumentDraft" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "body" TEXT, + "storeBody" BOOLEAN NOT NULL DEFAULT false, + "revision" INTEGER NOT NULL DEFAULT 0, + "updatedBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentDraft_documentId_key" ON "DocumentDraft"("documentId"); + +-- AddForeignKey +ALTER TABLE "DocumentDraft" ADD CONSTRAINT "DocumentDraft_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260822100000_wallet_vault_salt/migration.sql b/prisma/migrations/20260822100000_wallet_vault_salt/migration.sql new file mode 100644 index 00000000..bd9d86eb --- /dev/null +++ b/prisma/migrations/20260822100000_wallet_vault_salt/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Wallet" ADD COLUMN "vaultSalt" TEXT; + diff --git a/prisma/migrations/20260823090000_enable_rls_document_draft_attestation/migration.sql b/prisma/migrations/20260823090000_enable_rls_document_draft_attestation/migration.sql new file mode 100644 index 00000000..d52cfef7 --- /dev/null +++ b/prisma/migrations/20260823090000_enable_rls_document_draft_attestation/migration.sql @@ -0,0 +1,49 @@ +-- Row Level Security for DocumentDraft and DocumentAttestation. +-- +-- Every other table in this schema gets RLS in the migration that creates it β€” +-- see 20251215090000_enable_rls_disable_postgrest, its follow-up +-- 20260706100000_enable_rls_followup_tables, and the per-table blocks in +-- 20260805090000_add_document_signoff and 20260813000000_add_proxy_member. +-- These two tables were added without it, so they are the only ones in the +-- schema that PostgREST's anon and authenticated roles are not denied on. +-- +-- That matters more for these two than for most: DocumentDraft is the one table +-- in the document stack that holds document BODIES rather than hashes, and +-- DocumentAttestation holds the signed notary chain. +-- +-- Written as a follow-up rather than by editing those migrations, because a +-- migration that any environment has already applied cannot be edited without a +-- checksum failure on the next deploy β€” and this repo ships migrations through +-- an action that does not self-retry, so a failed deploy blocks every later +-- migration too. +-- +-- Same contract as the migrations above: RLS on unconditionally, deny-all +-- policies for the PostgREST roles only when those roles exist, and Prisma +-- continues to connect as the table owner / service role and bypass RLS. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY['DocumentDraft', 'DocumentAttestation']) + LOOP + -- Skip tables that don't exist + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = tbl) THEN + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END IF; + END LOOP; +END $$; diff --git a/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql b/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql new file mode 100644 index 00000000..c551a40d --- /dev/null +++ b/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql @@ -0,0 +1,145 @@ +-- CreateEnum +CREATE TYPE "DocumentSigningMode" AS ENUM ('threshold', 'parties'); + +-- CreateEnum +CREATE TYPE "ContractFieldKind" AS ENUM ('signature', 'initials', 'date', 'text', 'checkbox'); + +-- CreateEnum +CREATE TYPE "SignatureMethod" AS ENUM ('cip8Wallet', 'ausweisApp', 'eudiWallet'); + +-- DropIndex +DROP INDEX "DocumentReview_versionId_signerAddress_key"; + +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "signingMode" "DocumentSigningMode" NOT NULL DEFAULT 'threshold'; + +-- AlterTable +ALTER TABLE "DocumentReview" ADD COLUMN "method" "SignatureMethod" NOT NULL DEFAULT 'cip8Wallet', +ADD COLUMN "partyId" TEXT; + +-- CreateTable +CREATE TABLE "ContractParty" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "email" TEXT, + "address" TEXT, + "required" BOOLEAN NOT NULL DEFAULT true, + "signingOrder" INTEGER NOT NULL DEFAULT 0, + "inviteTokenHash" TEXT, + "invitedAt" TIMESTAMP(3), + "inviteExpiresAt" TIMESTAMP(3), + "inviteConsumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractParty_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContractField" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "partyId" TEXT NOT NULL, + "kind" "ContractFieldKind" NOT NULL, + "label" TEXT, + "anchor" TEXT NOT NULL, + "required" BOOLEAN NOT NULL DEFAULT true, + "value" TEXT, + "filledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractField_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractParty_inviteTokenHash_key" ON "ContractParty"("inviteTokenHash"); + +-- CreateIndex +CREATE INDEX "ContractParty_documentId_idx" ON "ContractParty"("documentId"); + +-- CreateIndex +CREATE INDEX "ContractParty_address_idx" ON "ContractParty"("address"); + +-- CreateIndex +CREATE INDEX "ContractParty_inviteExpiresAt_idx" ON "ContractParty"("inviteExpiresAt"); + +-- CreateIndex +CREATE INDEX "ContractField_partyId_idx" ON "ContractField"("partyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractField_documentId_anchor_key" ON "ContractField"("documentId", "anchor"); + +-- CreateIndex +CREATE INDEX "DocumentReview_partyId_idx" ON "DocumentReview"("partyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentReview_versionId_partyId_key" ON "DocumentReview"("versionId", "partyId"); + +-- AddForeignKey +ALTER TABLE "DocumentReview" ADD CONSTRAINT "DocumentReview_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "ContractParty"("id") ON DELETE NO ACTION ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractParty" ADD CONSTRAINT "ContractParty_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractField" ADD CONSTRAINT "ContractField_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractField" ADD CONSTRAINT "ContractField_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "ContractParty"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Threshold mode keeps its one-action-per-signer guarantee. +-- +-- Dropping DocumentReview_versionId_signerAddress_key above is the direct cost +-- of letting one human hold two roles: a tenant who is also their own guarantor +-- signs the same version twice, once per capacity. But that only applies to +-- party-attributed reviews. For wallet-threshold sign-off β€” every row where +-- partyId IS NULL β€” one signer acting twice on one version is still wrong, and +-- the in-transaction "has already acted" check reads rows fetched before the +-- write, so it cannot stop two concurrent submissions on its own. +-- +-- A partial unique index restores exactly the old guarantee, exactly where it +-- still holds. Prisma cannot express `WHERE` on @@unique, so it lives here. +-- +-- KEEP THIS. Migrations in this repo are generated with +-- `prisma migrate diff --from-schema --to-schema `, which compares +-- two schema files and never sees this index, so it will not be dropped by +-- accident β€” but a diff taken `--from-migrations` would propose removing it. +CREATE UNIQUE INDEX "DocumentReview_versionId_signerAddress_threshold_key" + ON "DocumentReview" ("versionId", "signerAddress") + WHERE "partyId" IS NULL; + +-- Row Level Security β€” same contract as 20251215090000_enable_rls_disable_postgrest +-- and the per-table block in 20260805090000_add_document_signoff. In this +-- migration rather than a follow-up because ContractParty holds the only +-- identifiable third-party data in the document stack (a counterparty's email), +-- and 20260823090000 is the precedent for what happens when it is forgotten. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY['ContractParty', 'ContractField']) + LOOP + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = tbl) THEN + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END IF; + END LOOP; +END $$; diff --git a/prisma/migrations/20260827090000_notification_threshold_ballot_toggles/migration.sql b/prisma/migrations/20260827090000_notification_threshold_ballot_toggles/migration.sql new file mode 100644 index 00000000..4fc8fdb0 --- /dev/null +++ b/prisma/migrations/20260827090000_notification_threshold_ballot_toggles/migration.sql @@ -0,0 +1,7 @@ +-- Two new per-wallet Γ— per-signer email toggles: +-- notifyThresholdReached β€” a transaction/payload collected enough signatures +-- notifyBallotDeadlines β€” proposals in a ballot stop accepting votes soon +-- Both default on, matching the existing signature toggles. +ALTER TABLE "WalletSignerNotificationSetting" + ADD COLUMN "notifyThresholdReached" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "notifyBallotDeadlines" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b04f4f22..5a7a215a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,19 @@ model Wallet { profileImageIpfsUrl String? ownerAddress String? + /// Secret used to derive per-document salts for this wallet's vault view. + /// + /// The demo vault at /vault derives salts from the note title, and says in + /// its own comment that this is fine there and nowhere real: a salt that is a + /// public function of the title cannot stop anyone brute-forcing a short + /// document from a guessed title, which is the one thing salts are for here. + /// + /// A real vault needs unguessable salts. One 32-byte secret per wallet gives + /// that with a single column: each node's salt is HMAC(vaultSalt, nodeId), so + /// salts are unguessable without the secret and no per-document storage is + /// needed. Nullable and generated lazily so existing wallets need no backfill. + vaultSalt String? + @@index([ownerAddress]) @@index([signersAddresses(ops: ArrayOps)], type: Gin) } @@ -163,6 +176,24 @@ model Proxy { @@index([userId, isActive]) } +/// In-app access grants for a Proxy. Membership controls who can see and manage +/// a proxy inside the app; it never grants on-chain authority, which stays with +/// the multisig signers holding the auth token. +model ProxyMember { + id String @id @default(cuid()) + proxyId String + address String + role String @default("viewer") + label String? + invitedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([proxyId, address]) + @@index([proxyId]) + @@index([address]) +} + model BalanceSnapshot { id String @id @default(cuid()) walletId String @@ -221,6 +252,8 @@ model WalletSignerNotificationSetting { emailOptIn Boolean @default(true) notifyTransactionSignatures Boolean @default(true) notifySignableSignatures Boolean @default(true) + notifyThresholdReached Boolean @default(true) + notifyBallotDeadlines Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -245,26 +278,26 @@ model EmailVerificationToken { } model NotificationDelivery { - id String @id @default(cuid()) - eventType String - channel String - recipientAddress String - recipientEmail String? - resourceType String - resourceId String - walletId String? - idempotencyKey String @unique - subject String - payload Json - status String @default("pending") - provider String? - providerMessageId String? - attempts Int @default(0) - lastError String? - nextAttemptAt DateTime @default(now()) - sentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([status, nextAttemptAt]) @@index([recipientAddress]) @@ -390,6 +423,185 @@ model ProposalTally { @@unique([network, proposalId]) } +// --------------------------------------------------------------------------- +// Document Sign-Off (PRD-001) β€” five-entity model. +// +// A wallet-native, off-chain approval layer. Approval binds to an exact +// content hash, never to the mutable document container, and inherits the +// wallet's signer set + threshold frozen at the moment a review round starts. +// No on-chain dependency in the MVP. +// --------------------------------------------------------------------------- + +enum DocumentStatus { + Draft + InReview + Approved + Rejected + Superseded + Archived +} + +enum DocumentReviewAction { + approve + reject +} + +// How the version's bytes are retained. The MVP never requires the bytes β€” +// the content hash is the binding β€” so `hashOnly` is the privacy-preserving +// default (see the vault's "Storage Privacy Risk"). +enum DocumentStorageMode { + hashOnly + inline + external +} + +// The stable container. Title/description may change; approval never attaches +// here, only to a DocumentVersion. +model Document { + id String @id @default(cuid()) + walletId String + title String + description String? + documentType String? + createdBy String // signer address that created it + status DocumentStatus @default(Draft) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + versions DocumentVersion[] + events DocumentEvent[] + draft DocumentDraft? + + /// Which rule decides this document's rounds. Defaulting to `threshold` is + /// what makes this migration a no-op for every document that already exists. + signingMode DocumentSigningMode @default(threshold) + + parties ContractParty[] + contractFields ContractField[] + + @@index([walletId]) + @@index([walletId, status]) + @@index([createdBy]) +} + +// Each version is its own approval object β€” this is what makes version-bound +// approval and approval-reset-on-new-version possible. +model DocumentVersion { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + versionNumber Int + contentHash String // lowercase hex digest of the exact bytes + hashAlgorithm String @default("sha256") + fileName String? + mimeType String? + fileSize Int? + storageMode DocumentStorageMode @default(hashOnly) + contentRef String? // external URI when storageMode = external + contentInline String? // base64 bytes when storageMode = inline + reviewInstructions String? + status DocumentStatus @default(Draft) + createdBy String + createdAt DateTime @default(now()) + reviewStartedAt DateTime? + decidedAt DateTime? // approved or rejected + supersededAt DateTime? + reviews DocumentReview[] + signerSnapshot DocumentSignerSnapshot? + events DocumentEvent[] + attestation DocumentAttestation? + + @@unique([documentId, versionNumber]) + @@index([documentId]) + @@index([contentHash]) + @@index([status]) +} + +// One signer's action on one version, with the exact payload that was signed. +// Append-only: a signer gets one action per version, and a new version starts +// a fresh round at zero approvals. +model DocumentReview { + id String @id @default(cuid()) + versionId String + version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + signerAddress String + action DocumentReviewAction + comment String? + payload String // canonical JSON string that was signed (CIP-8 payload) + signature String // COSE_Sign1 hex + signatureKey String // COSE key hex + signedAt DateTime // client-asserted, server-validated against a window + createdAt DateTime @default(now()) + + /// The party this signature was made as; null in threshold mode, where the + /// signer is a wallet signer and signs in no particular capacity. + /// + /// NoAction, not Prisma's default. The default for an optional relation is + /// SetNull, which would silently strip the capacity off an already-signed, + /// already-exported review on an append-only table. Cascade is worse: it + /// deletes the signature itself, with none of deleteDocument's retype-the- + /// title guard or its pre-delete AuditLog row, and would revert a fully + /// executed contract to InReview on the next recount. NoAction is checked at + /// the end of the statement, so deleting a whole document still cascades + /// cleanly while a party who has signed cannot be deleted on their own. + partyId String? + party ContractParty? @relation(fields: [partyId], references: [id], onDelete: NoAction) + + /// How this signature was produced. Only `cip8Wallet` is written today. + /// + /// This column is not yet part of the signed bytes, and until it is it is an + /// index rather than evidence β€” see the party-signing work. Nothing but the + /// default is written while parties mode is unimplemented, so there is + /// nothing to misrepresent yet. + method SignatureMethod @default(cip8Wallet) + + /// NOT unique on (versionId, signerAddress) any more: one human holding two + /// roles signs twice on the same version, once per capacity. Threshold mode + /// keeps its one-action-per-signer guarantee through the already-acted check + /// inside submitSignerAction's transaction, which is covered by an + /// integration test rather than by an index that parties mode cannot keep. + /// + /// One review per party per version. NULLs are distinct in Postgres, so every + /// threshold-mode row (partyId null) is unaffected by this. + @@unique([versionId, partyId]) + @@index([versionId]) + @@index([signerAddress]) + @@index([partyId]) +} + +// The signer set and threshold that applied when the round started. Frozen so +// later membership changes never rewrite history. +model DocumentSignerSnapshot { + id String @id @default(cuid()) + versionId String @unique + version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + walletId String + signersAddresses String[] + signersDescriptions String[] + requiredSigners Int + walletPolicyHash String // sha256 of the wallet's scriptCbor β€” binds the round to the policy + capturedAt DateTime @default(now()) + + @@index([walletId]) +} + +// Append-only audit log. Feeds the detail page history and the proof export. +model DocumentEvent { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + versionId String? + version DocumentVersion? @relation(fields: [versionId], references: [id], onDelete: Cascade) + type String // "document.created" | "version.uploaded" | "review.started" | ... + actorAddress String? + metadata Json? + createdAt DateTime @default(now()) + + @@index([documentId, createdAt]) + @@index([versionId]) + @@index([type]) +} + // --------------------------------------------------------------------------- // OAuth 2.1 authorization server // @@ -481,3 +693,234 @@ model OAuthGrant { @@unique([subjectAddress, clientId]) @@index([subjectAddress]) } + +/// Platform attestation over a document version: a timestamp and ordering +/// record, signed by the platform's Ed25519 notary key. +/// +/// Explicitly NOT an approval. Approval is DocumentReview, signed by the +/// wallet's own signers. This row only witnesses that a version existed at a +/// time and in a position, and each row commits to the hash of the previous +/// one, so the history is tamper-evident independently of this table. +model DocumentAttestation { + id String @id @default(cuid()) + versionId String @unique + version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + documentId String + /// 1-based position in this document's chain. + sequence Int + contentHash String + /// Link to the previous attestation, or 64 zeroes for the first. + prevAttestationHash String + /// sha256 of the canonical payload β€” what the NEXT attestation commits to. + attestationHash String @unique + /// The exact canonical JSON that was signed. + payload String + /// Ed25519 signature, lowercase hex. + signature String + /// Which key signed it, so keys can be rotated without reissuing history. + publicKeyId String + attestedAt DateTime + + @@unique([documentId, sequence]) + @@index([documentId]) + @@index([contentHash]) +} + +/// A mutable working copy that sits BEFORE the version model. +/// +/// Document Sign-Off is built on immutability: a signature commits to an exact +/// contentHash, and uploading a new version supersedes the last one and resets +/// approvals to zero. An editor cannot live inside that β€” autosaving through +/// `uploadVersion` would destroy in-flight approvals on every keystroke batch +/// and append a link to the attestation chain each time. +/// +/// So a draft is deliberately UNSIGNABLE: no contentHash, no attestation, and +/// nothing in the review flow can reach it. Editing touches only this row. +/// Publishing is the one-way door that serialises it into a DocumentVersion, +/// hashes it server-side and attests it. +model DocumentDraft { + id String @id @default(cuid()) + documentId String @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + /// Markdown body. Null while `storeBody` is false β€” the draft then only + /// records that editing is in progress and no content reaches the server. + body String? + + /// Server-side storage is opt-in PER DOCUMENT. The feature's default posture + /// is hashOnly, where bytes never leave the author's machine; keeping an + /// editable body on the server is a deliberate reversal and must be chosen, + /// never inherited. + storeBody Boolean @default(false) + + /// Optimistic concurrency. A save presents the revision it read; a mismatch + /// is a conflict rather than a silent overwrite, which is what makes several + /// people editing safe without a CRDT or a broker. + revision Int @default(0) + updatedBy String + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) +} + +// --------------------------------------------------------------------------- +// Contracts β€” named parties on top of Document Sign-Off. +// +// Sign-off is M-of-N over a wallet's own signers. A contract is N-of-N over +// named parties who are usually NOT members of the wallet. The roster lives on +// the Document so it survives a re-issue; everything per-round stays where it +// already is β€” frozen in DocumentSignerSnapshot, proved by DocumentReview β€” so +// the two can never drift. +// --------------------------------------------------------------------------- + +/// Which rule decides a version's outcome. +enum DocumentSigningMode { + /// M-of-N over the wallet's signers, read from the Wallet row at startReview. + threshold + /// Every named party signs. startReview builds the snapshot from the parties + /// with requiredSigners = the party count, so `evaluateThreshold` yields + /// N-of-N with no second evaluator and no change to the verifier. + parties +} + +/// What a party is expected to put at an anchor in the body. +enum ContractFieldKind { + signature + initials + date + text + checkbox +} + +/// How a signature was produced. Only `cip8Wallet` is implemented; the others +/// exist now because adding an enum value later is its own migration, and +/// migrations reach production through an action that does not self-retry. +/// +/// Note these describe two different things that will need separating: how the +/// bytes were signed, and how the human was identified. AusweisApp and EUDI are +/// identification protocols β€” a signature made after an eID check may still be +/// a CIP-8 one. +enum SignatureMethod { + /// CIP-8 COSE_Sign1 from a Cardano wallet, including utxos.dev passkey wallets. + cip8Wallet + /// German eID. Not implemented. + ausweisApp + /// EUDI wallet. Not implemented. + eudiWallet +} + +/// A named party to a contract: who they are, in what capacity they sign, and +/// the invite that lets a non-member of the wallet reach the review at all. +/// +/// Deliberately holds NO per-round state β€” no status, no viewedAt, no +/// decidedAt. `uploadVersion` and `publishDraft` supersede the version and +/// reset approvals to zero without touching this row, so a per-round column +/// here would survive a reset it has no business surviving: an ordering gate +/// reading a stale "signed" would wave through a countersignature on a version +/// the first party has never seen. Whether a party signed is not an opinion +/// this table stores β€” it is the existence of a DocumentReview carrying their +/// signature over the canonical payload. +model ContractParty { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + /// Free text, because every contract names its parties differently ("Buyer", + /// "Lessee", "Witness"). Copied with displayName into the frozen snapshot's + /// signersDescriptions at startReview β€” that is how the capacity a signature + /// was made in reaches the exported proof without a proof-format bump. + role String + displayName String + /// Where the invite is sent. The only identifiable third-party data in the + /// document stack, which is why this table gets its RLS block in the same + /// migration rather than a follow-up. + email String? + + /// Null until the party redeems their invite and connects a wallet. A round + /// cannot start while any party is null here: the snapshot freezes addresses + /// and the signed payload binds signerAddress, so a party with no address at + /// startReview could never act on that version. + address String? + + /// Whether this party's signature is needed for the contract to complete. + /// + /// An optional party (a Witness, an observer) may sign or decline without + /// blocking. This is exactly why `evaluateThreshold` cannot decide a + /// contract: it counts approvals anonymously, so an optional party sitting in + /// the snapshot would let one be Approved over a REQUIRED party's explicit + /// rejection. Parties mode uses the party-aware evaluator in payload.ts. + required Boolean @default(true) + + /// Parties with equal values sign in parallel; a party may act only once + /// every party with a strictly lower value has a review on the version being + /// signed. Enforced in submitSignerAction β€” it is a rule about rows in + /// another table, so no constraint here can express it. + signingOrder Int @default(0) + + /// sha256 of a single-use invite token; the token itself is never stored. + /// This is what makes the feature reachable at all β€” a party is not in + /// wallet.signersAddresses, so assertWalletAccess rejects them. + inviteTokenHash String? @unique + + invitedAt DateTime? + /// Expiry and consumption, mirroring EmailVerificationToken and + /// BotClaimToken. Without them the invite is a bearer credential valid + /// forever: whoever holds the link last claims the party slot, so a mistyped + /// or later-compromised mailbox could bind a stranger's wallet to "Buyer". + inviteExpiresAt DateTime? + inviteConsumedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + fields ContractField[] + reviews DocumentReview[] + + /// Deliberately NOT unique on (documentId, address): one human may hold two + /// roles β€” a tenant who is also their own guarantor β€” so the same wallet can + /// back two parties. That is only safe because the signed payload carries + /// partyId, so two signatures from one address are distinguishable by the + /// capacity they were made in rather than collapsing into one. + @@index([documentId]) + @@index([address]) + @@index([inviteExpiresAt]) +} + +/// A per-party placeholder in the body: which anchor belongs to whom, and what +/// kind of thing goes there. +/// +/// `value` is PRE-PUBLISH input only. A field is a term of the contract, and +/// the whole model rests on a signature binding an exact contentHash β€” so +/// values must be rendered into the body by publishDraft and hashed with it. A +/// value left live after the freeze would be an unsigned, mutable contract +/// term: change 50000 to 5000 after signing and every signature still verifies. +model ContractField { + id String @id @default(cuid()) + + /// Denormalised from the party so anchor uniqueness can be enforced across + /// the whole body, which spans parties. Two fields on one anchor are + /// unresolvable for the renderer, and ContractField has no other path to the + /// document. Same pattern DocumentAttestation uses for documentId. + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + partyId String + party ContractParty @relation(fields: [partyId], references: [id], onDelete: Cascade) + + kind ContractFieldKind + label String? + /// Stable marker in the body that the renderer resolves. + anchor String + required Boolean @default(true) + + /// Pre-publish input. publishDraft renders these into the markdown body + /// before hashing, so contentHash covers them; nothing may write here once + /// the document's latest version has a snapshot. + value String? + filledAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([documentId, anchor]) + @@index([partyId]) +} diff --git a/public/og-image.png b/public/og-image.png index cd6bf8da..18849f22 100644 Binary files a/public/og-image.png and b/public/og-image.png differ diff --git a/public/og/api-docs.png b/public/og/api-docs.png new file mode 100644 index 00000000..da1f7e87 Binary files /dev/null and b/public/og/api-docs.png differ diff --git a/public/og/blog.png b/public/og/blog.png new file mode 100644 index 00000000..4f77533a Binary files /dev/null and b/public/og/blog.png differ diff --git a/public/og/dapps.png b/public/og/dapps.png new file mode 100644 index 00000000..de864d0e Binary files /dev/null and b/public/og/dapps.png differ diff --git a/public/og/drep.png b/public/og/drep.png new file mode 100644 index 00000000..104c9987 Binary files /dev/null and b/public/og/drep.png differ diff --git a/public/og/features.png b/public/og/features.png new file mode 100644 index 00000000..9854caf1 Binary files /dev/null and b/public/og/features.png differ diff --git a/public/og/governance.png b/public/og/governance.png new file mode 100644 index 00000000..fa0c6134 Binary files /dev/null and b/public/og/governance.png differ diff --git a/public/og/import-wallet.png b/public/og/import-wallet.png new file mode 100644 index 00000000..d7276222 Binary files /dev/null and b/public/og/import-wallet.png differ diff --git a/public/og/roadmap-graph.png b/public/og/roadmap-graph.png new file mode 100644 index 00000000..6888b365 Binary files /dev/null and b/public/og/roadmap-graph.png differ diff --git a/public/og/roadmap.png b/public/og/roadmap.png new file mode 100644 index 00000000..b1500117 Binary files /dev/null and b/public/og/roadmap.png differ diff --git a/public/og/vault.png b/public/og/vault.png new file mode 100644 index 00000000..ec1dda4b Binary files /dev/null and b/public/og/vault.png differ diff --git a/scripts/ci/README.md b/scripts/ci/README.md index b6374fda..855b5a39 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -115,6 +115,7 @@ The manifest currently covers: - route discovery (`walletIds`, `proxies`) - **pending-transactions zero-check** at bootstrap for each wallet type β€” catches stale state from a previous incomplete run before the ring transfer begins - **public wallet lookup** (`lookupMultisigWallet`) β€” smoke-tests the unauthenticated on-chain metadata lookup endpoint +- **public script resolution** (`resolveScript`) β€” per wallet type, resolves the bootstrap multisig address to its signer key hashes and asserts signer 0 is among them (backs lookup-by-policy on the Discover tab) - route health checks (`freeUtxos`, `nativeScript`) β€” `nativeScript` now asserts a `payment` script entry is present and, when the root type is `atLeast`, that `required` matches `CI_NUM_REQUIRED_SIGNERS` - **wallet creation via API** (`createWallet`) β€” creates a wallet through the bot-authenticated API path and confirms it appears in `walletIds`; runs early to avoid prior default-bot smoke checks consuming the shared bot rate-limit budget - bot identity (`botAuth` explicit response shape, `botMe`) diff --git a/scripts/ci/scenarios/steps/discovery.ts b/scripts/ci/scenarios/steps/discovery.ts index 74a9b4b8..105e54ae 100644 --- a/scripts/ci/scenarios/steps/discovery.ts +++ b/scripts/ci/scenarios/steps/discovery.ts @@ -136,6 +136,53 @@ function createLookupMultisigWalletStep(ctx: CIBootstrapContext): RouteStep { }; } +function createResolveScriptStep(walletType: string): RouteStep { + return { + id: `v1.resolveScript.walletAddress.${walletType}`, + description: `Smoke-test public /api/v1/resolveScript with the ${walletType} wallet address`, + severity: "non-critical", + execute: async (runCtx) => { + const wallet = getWalletByType(runCtx, walletType); + if (!wallet?.walletAddress) { + throw new Error(`resolveScript: no ${walletType} wallet address in bootstrap context`); + } + const signerAddress = runCtx.signerAddresses[0]; + if (!signerAddress) { + throw new Error("resolveScript: no signer addresses in bootstrap context"); + } + const { resolvePaymentKeyHash } = await import("@meshsdk/core"); + const signerKeyHash = resolvePaymentKeyHash(signerAddress).toLowerCase(); + + const response = await requestJson< + { scriptHash?: string; sigHashes?: string[] } | { error?: string } + >({ + url: `${runCtx.apiBaseUrl}/api/v1/resolveScript?address=${encodeURIComponent(wallet.walletAddress)}&network=${runCtx.networkId}`, + method: "GET", + }); + if (response.status !== 200 || !response.data || typeof response.data !== "object") { + throw new Error( + `resolveScript failed for ${walletType} (${response.status}): ${stringifyRedacted(response.data)}`, + ); + } + const data = response.data as { scriptHash?: string; sigHashes?: string[] }; + if (!Array.isArray(data.sigHashes)) { + throw new Error(`resolveScript returned no sigHashes array for ${walletType}`); + } + // The multisig address's script must name the bootstrap signer 0 β€” + // otherwise the route resolved the wrong script (or none). + if (!data.sigHashes.includes(signerKeyHash)) { + throw new Error( + `resolveScript: signer 0 key hash not among ${data.sigHashes.length} resolved sig hashes for ${walletType}`, + ); + } + return { + message: `resolveScript resolved ${data.sigHashes.length} signer hashes for the ${walletType} wallet address`, + artifacts: { walletType, scriptHash: data.scriptHash, sigHashCount: data.sigHashes.length }, + }; + }, + }; +} + function createFreeUtxosStep(walletType: string): RouteStep { return { id: `v1.freeUtxos.${walletType}`, @@ -234,6 +281,7 @@ export function createScenarioPendingAndDiscovery(ctx: CIBootstrapContext): Scen ...ctx.walletTypes.map((walletType) => createPendingTransactionsZeroStep(walletType)), ...ctx.walletTypes.map((walletType) => createProxiesListStep(walletType)), createLookupMultisigWalletStep(ctx), + ...ctx.walletTypes.map((walletType) => createResolveScriptStep(walletType)), ], }; } diff --git a/scripts/generate-attestation-key.mjs b/scripts/generate-attestation-key.mjs new file mode 100644 index 00000000..a58d2772 --- /dev/null +++ b/scripts/generate-attestation-key.mjs @@ -0,0 +1,48 @@ +// Generates the Ed25519 keypair used to attest document versions. +// +// Run with: node scripts/generate-attestation-key.mjs +// +// The private half goes in DOCUMENT_ATTESTATION_KEY. The public half is safe to +// publish and is what lets anyone verify a document's attestation chain without +// this app β€” print it, commit it to a status page, hand it to an auditor. +// +// This key is a NOTARY, not an approver. It signs "this version existed at this +// time, in this position"; it cannot approve a document and cannot witness a +// transaction. See src/lib/documents/attestation.ts for the full threat model. +// +// TO ROTATE: generate a new key, move the OLD public key into +// DOCUMENT_ATTESTATION_PRIOR_PUBLIC_KEYS as {"":""} so +// history signed by it keeps verifying, then set the new private key. +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; + +const { privateKey } = generateKeyPairSync("ed25519"); + +const privateBase64 = privateKey + .export({ type: "pkcs8", format: "der" }) + .toString("base64"); + +const publicKeyHex = createPublicKey(privateKey) + .export({ type: "spki", format: "der" }) + .toString("hex"); + +const keyId = createHash("sha256") + .update(publicKeyHex, "utf8") + .digest("hex") + .slice(0, 16); + +console.log(` +Document attestation keypair +============================ + +key id ${keyId} +public key ${publicKeyHex} + +Set this on the server (Railway variable, .env locally) and keep it secret: + +DOCUMENT_ATTESTATION_KEY=${privateBase64} + +Publish the public key and key id above so attestation chains can be verified +without this app. Rotating? Keep the previous public key verifiable: + +DOCUMENT_ATTESTATION_PRIOR_PUBLIC_KEYS={"":""} +`); diff --git a/scripts/generate-og-image.mjs b/scripts/generate-og-image.mjs index f714e027..a9dc1cdd 100644 --- a/scripts/generate-og-image.mjs +++ b/scripts/generate-og-image.mjs @@ -1,14 +1,27 @@ -// Generates the static social card at public/og-image.png (1200Γ—630). +// Generates the branded 1200Γ—630 social cards under public/ (see OG_CARDS). // // Run with: node scripts/generate-og-image.mjs // -// We rasterise a hand-written SVG with `sharp` (already a dependency via -// next/image) and composite the white Mesh logo on top. Keeping this as a -// committed script means the card is reproducible and tweakable without any -// design tooling or runtime/edge dependency. +// Why a script and not a runtime/edge route: the cards are pure functions of the +// copy below, so rasterising them once and committing the PNGs keeps social +// previews free of a runtime dependency, an image budget and a cold start β€” +// while staying reproducible and reviewable in the diff. +// +// Every marketing route gets its OWN card. A shared card means a /governance +// link and a /roadmap link are indistinguishable in a feed; the eyebrow, the +// headline and the accent tint make each share say what it actually links to. +// +// We rasterise hand-written SVG with `sharp` (already a dependency via +// next/image) and composite the white Mesh logo on top. Text is rendered by +// librsvg using system Helvetica/Arial, so regenerate on a machine that has +// them and commit the result β€” CI does not rebuild these. +// +// Keep in sync with `routeSeo[...].image` in src/lib/seo.ts. The `og cards` +// test asserts every referenced card actually exists on disk. import sharp from "sharp"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { mkdir } from "node:fs/promises"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, ".."); @@ -16,56 +29,271 @@ const root = join(__dirname, ".."); const W = 1200; const H = 630; -const svg = ` +/** Left margin; the headline block is inset further by the accent bar. */ +const MARGIN = 76; +const TEXT_X = 112; +const CONTENT_W = W - TEXT_X - MARGIN; + +/** + * The cards. `headline` and `subhead` are arrays because SVG has no line + * wrapping β€” authoring the breaks explicitly makes the layout exact instead of + * dependent on a width estimate. + */ +const OG_CARDS = [ + { + // The site-wide default, and the home page. Path kept as /og-image.png so + // links shared before per-route cards existed still resolve. + file: "public/og-image.png", + accent: "#c9c9d6", + eyebrow: "Multi-signature wallet", + headline: ["Cardano treasuries,", "secured by multisig."], + subhead: ["Free, open-source, Cardano-native multisig for teams & DAOs."], + chips: ["Treasury", "Governance", "Collaboration"], + }, + { + file: "public/og/features.png", + accent: "#5fbdd0", + eyebrow: "Features", + headline: ["Everything a Cardano", "team treasury needs."], + subhead: ["M-of-N approvals, signer invites, governance voting, staking."], + chips: ["Approvals", "Invites", "History"], + }, + { + file: "public/og/governance.png", + accent: "#8b7ff0", + eyebrow: "Governance", + headline: ["Vote on Cardano", "governance as a team."], + subhead: ["Browse proposals and cast votes with M-of-N approval."], + chips: ["Proposals", "DRep", "On-chain votes"], + }, + { + file: "public/og/drep.png", + accent: "#7fb2f0", + eyebrow: "DRep explorer", + headline: ["Find the DRep that", "votes like you do."], + subhead: ["Voting records, delegated stake and metadata for every DRep."], + chips: ["Voting records", "Delegated stake"], + }, + { + file: "public/og/roadmap.png", + accent: "#e0a45e", + eyebrow: "Roadmap", + headline: ["Twelve months of", "Mesh Multisig."], + subhead: ["What shipped, what is in progress, and what comes next."], + chips: ["April 2026", "March 2027"], + }, + { + file: "public/og/roadmap-graph.png", + accent: "#e0a45e", + eyebrow: "Feature graph", + headline: ["Every feature, and", "what it connects to."], + subhead: ["An interactive graph of delivered, planned and blocked work."], + chips: ["Delivered", "Planned", "Blocked"], + }, + { + file: "public/og/blog.png", + accent: "#6fc79b", + eyebrow: "Blog", + headline: ["Notes on multisig,", "governance and agents."], + subhead: ["Guides and updates from the team behind Mesh Multisig."], + chips: ["Guides", "Release notes"], + }, + { + file: "public/og/api-docs.png", + accent: "#5fbdd0", + eyebrow: "API & bots", + headline: ["Drive your treasury", "from code."], + subhead: ["OpenAPI-documented REST endpoints for bots and agents."], + chips: ["REST", "OpenAPI", "Bot auth"], + }, + { + file: "public/og/dapps.png", + accent: "#d97fae", + eyebrow: "DApps", + headline: ["Use any Cardano dApp", "in multisig mode."], + subhead: ["Connect Mesh Multisig to the dApps your team already uses."], + chips: ["CIP-30", "Multisig mode"], + }, + { + file: "public/og/import-wallet.png", + accent: "#c9c9d6", + eyebrow: "Import wallet", + headline: ["Bring your multisig", "wallet with you."], + subhead: ["Import from another instance, Summon, raw CBOR or a backup."], + chips: ["Instance", "Summon", "CBOR", "JSON"], + }, + { + file: "public/og/vault.png", + accent: "#6aa8a0", + eyebrow: "Shielded sign-off", + headline: ["Prove one document.", "Reveal nothing else."], + subhead: ["A hash-linked document vault with selective disclosure."], + chips: ["Trust graph", "Selective disclosure"], + }, +]; + +const FONT = "Helvetica, Arial, sans-serif"; + +/** XML-escape a copy string before it goes into the SVG. */ +const esc = (s) => + String(s).replace(/&/g, "&").replace(//g, ">"); + +/** + * Rough advance-width estimate for Helvetica, calibrated against the rendered + * card. Only used to warn about copy that would overflow β€” never to lay out. + */ +function estimateWidth( + text, + fontSize, + { bold = false, uppercase = false, letterSpacing = 0 } = {}, +) { + // Calibrated against rendered output: uppercase Helvetica is materially wider + // per glyph than mixed case, and under-measuring it overflows the pill. + const factor = uppercase ? 0.635 : bold ? 0.51 : 0.47; + return text.length * (fontSize * factor + letterSpacing); +} + +function warnIfWide(label, text, width, limit) { + if (width > limit) { + console.warn( + ` ! "${text}" is ~${Math.round(width)}px wide, over the ${limit}px ${label} limit β€” shorten it.`, + ); + } +} + +function buildSvg(card) { + const { accent, eyebrow, headline, subhead, chips } = card; + + // --- eyebrow pill, right-aligned in the header row ----------------------- + const eyebrowText = eyebrow.toUpperCase(); + const eyebrowSize = 19; + const eyebrowLs = 2.6; + const eyebrowTextW = estimateWidth(eyebrowText, eyebrowSize, { + bold: true, + uppercase: true, + letterSpacing: eyebrowLs, + }); + // 44px lead-in covers the dot, 26px trails the text. + const pillW = Math.round(eyebrowTextW + 44 + 26); + const pillH = 44; + const pillX = W - MARGIN - pillW; + const pillY = 62; + + // --- headline block ------------------------------------------------------ + const headSize = 72; + const headLead = 84; + const firstBaseline = headline.length > 1 ? 300 : 342; + const lastBaseline = firstBaseline + (headline.length - 1) * headLead; + + const barTop = firstBaseline - 60; + const barBottom = lastBaseline + 18; + + const subSize = 27; + const subLead = 38; + const subFirst = lastBaseline + 66; + + headline.forEach((line) => + warnIfWide( + "headline", + line, + estimateWidth(line, headSize, { bold: true, letterSpacing: -1.8 }), + CONTENT_W, + ), + ); + subhead.forEach((line) => + warnIfWide("subhead", line, estimateWidth(line, subSize), CONTENT_W), + ); + + const headlineSvg = headline + .map( + (line, i) => + `${esc(line)}`, + ) + .join("\n "); + + const subheadSvg = subhead + .map( + (line, i) => + `${esc(line)}`, + ) + .join("\n "); + + return ` - - + + - - - - + + + + + + + + + + - - - - + + - - + + + + + + - - Mesh Multisig + + - - Cardano treasuries, - secured by multisig. + + Mesh Multisig - - Free, open-source, Cardano-native multi-signature wallet for teams & DAOs. + + + + ${esc(eyebrowText)} - - - multisig.meshjs.dev - Treasury Β· Governance Β· Collaboration + + + + ${headlineSvg} + + ${subheadSvg} + + + + multisig.meshjs.dev + ${esc(chips.join(" Β· "))} `; +} const logo = await sharp( join(root, "public/logo-mesh/white/logo-mesh-white-512x512.png"), ) - .resize(96, 96, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .resize(84, 84, { + fit: "contain", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) .png() .toBuffer(); -await sharp(Buffer.from(svg)) - .composite([{ input: logo, top: 50, left: 80 }]) - .png() - .toFile(join(root, "public/og-image.png")); +await mkdir(join(root, "public/og"), { recursive: true }); + +for (const card of OG_CARDS) { + console.log(`Rendering ${card.file}`); + await sharp(Buffer.from(buildSvg(card))) + .composite([{ input: logo, top: 54, left: MARGIN }]) + .png({ compressionLevel: 9 }) + .toFile(join(root, card.file)); +} -console.log("Wrote public/og-image.png"); +console.log(`Wrote ${OG_CARDS.length} social cards.`); diff --git a/src/__tests__/ballotDeadlines.test.ts b/src/__tests__/ballotDeadlines.test.ts new file mode 100644 index 00000000..e5f2d8fc --- /dev/null +++ b/src/__tests__/ballotDeadlines.test.ts @@ -0,0 +1,636 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +jest.mock("@/lib/governance/provider", () => ({ + __esModule: true, + getGovernanceProvider: () => null, + providerGet: jest.fn(async () => { + throw new Error("network access not expected in tests"); + }), +})); + +jest.mock("@/utils/multisigSDK", () => ({ + __esModule: true, + addressToNetwork: (address: string) => (address.includes("test") ? 0 : 1), +})); + +jest.mock("@/lib/notifications/worker", () => ({ + __esModule: true, + drainNotificationOutbox: jest.fn(async () => []), +})); + +import { + ballotDeadlineIdempotencyKey, + computeEpochEndMs, + enqueueBallotDeadlineReminders, + selectReminderWindow, + type LatestEpoch, + type ProposalDeadlineDetails, +} from "@/lib/notifications/ballotDeadlines"; +import { + getNotificationPreferenceField, + NOTIFICATION_EVENT_BALLOT_DEADLINE, + NOTIFICATION_STATUS_PENDING, +} from "@/lib/notifications/events"; +import { renderBallotDeadlineEmail } from "@/lib/notifications/templates/ballotDeadline"; +import { extractVoteProposalIds } from "@/lib/notifications/voteProposals"; + +const HOUR = 60 * 60 * 1000; +const PROPOSAL_A = "aa".repeat(32) + "#0"; +const PROPOSAL_B = "bb".repeat(32) + "#1"; + +describe("computeEpochEndMs", () => { + it("adds whole epochs to the latest epoch's end time", () => { + expect( + computeEpochEndMs({ latestEpoch: 500, latestEndTimeSec: 1_000_000, epoch: 500 }), + ).toBe(1_000_000_000); + expect( + computeEpochEndMs({ latestEpoch: 500, latestEndTimeSec: 1_000_000, epoch: 502 }), + ).toBe((1_000_000 + 2 * 432_000) * 1000); + }); +}); + +describe("selectReminderWindow", () => { + it("picks exactly one window and none once the deadline passed", () => { + expect(selectReminderWindow(-1)).toBeNull(); + expect(selectReminderWindow(0)).toBeNull(); + expect(selectReminderWindow(1)).toBe("24h"); + expect(selectReminderWindow(24 * HOUR)).toBe("24h"); + expect(selectReminderWindow(24 * HOUR + 1)).toBe("48h"); + expect(selectReminderWindow(48 * HOUR)).toBe("48h"); + expect(selectReminderWindow(48 * HOUR + 1)).toBeNull(); + }); +}); + +describe("ballotDeadlineIdempotencyKey", () => { + it("scopes reminders per resource, signer, window and expiration epoch", () => { + expect( + ballotDeadlineIdempotencyKey({ + resourceId: "b1", + walletId: "w1", + recipientAddress: "addr", + window: "24h", + expirationEpoch: 510, + }), + ).toBe("ballot.deadline:email:ballot:b1:w1:addr:24h:510"); + expect( + ballotDeadlineIdempotencyKey({ + resourceType: "transaction", + resourceId: "tx1", + walletId: "w1", + recipientAddress: "addr", + window: "48h", + expirationEpoch: 510, + }), + ).toBe("ballot.deadline:email:transaction:tx1:w1:addr:48h:510"); + }); +}); + +describe("getNotificationPreferenceField for ballot.deadline", () => { + it("gates both ballot and transaction keyed rows by notifyBallotDeadlines", () => { + expect(getNotificationPreferenceField(NOTIFICATION_EVENT_BALLOT_DEADLINE, "ballot")).toBe( + "notifyBallotDeadlines", + ); + expect( + getNotificationPreferenceField(NOTIFICATION_EVENT_BALLOT_DEADLINE, "transaction"), + ).toBe("notifyBallotDeadlines"); + expect(getNotificationPreferenceField(NOTIFICATION_EVENT_BALLOT_DEADLINE, "wallet")).toBeNull(); + }); +}); + +describe("extractVoteProposalIds", () => { + it("reads client-built votes from txJson.votes", () => { + const txJson = JSON.stringify({ + votes: [ + { + type: "SimpleScriptVote", + vote: { + voter: { type: "DRep", drepId: "drep1" }, + govActionId: { txHash: "aa".repeat(32), txIndex: 0 }, + votingProcedure: { voteKind: "Yes" }, + }, + }, + { + type: "BasicVote", + vote: { govActionId: { txHash: "bb".repeat(32), txIndex: 1 } }, + }, + ], + }); + expect(extractVoteProposalIds(txJson)).toEqual([PROPOSAL_A, PROPOSAL_B]); + }); + + it("reads bot proxy votes from txJson.proxyBot and dedupes across shapes", () => { + const txJson = { + votes: [{ vote: { govActionId: { txHash: "aa".repeat(32), txIndex: 0 } } }], + proxyBot: { + kind: "proxyVote", + votes: [ + { proposalId: PROPOSAL_A, voteKind: "Yes" }, + { proposalId: PROPOSAL_B, voteKind: "No" }, + ], + }, + }; + expect(extractVoteProposalIds(txJson)).toEqual([PROPOSAL_A, PROPOSAL_B]); + }); + + it("ignores malformed entries, other proxy kinds and unparsable input", () => { + expect( + extractVoteProposalIds({ + votes: [{ vote: { govActionId: { txHash: 123, txIndex: "0" } } }, null], + proxyBot: { kind: "proxyDRepCertificate", votes: [{ proposalId: PROPOSAL_A }] }, + }), + ).toEqual([]); + expect(extractVoteProposalIds({ proxyBot: { kind: "proxyVote", votes: [{ proposalId: "nope" }] } })).toEqual([]); + expect(extractVoteProposalIds("{not json")).toEqual([]); + expect(extractVoteProposalIds(null)).toEqual([]); + }); +}); + +type Scenario = { + ballots?: Array>; + transactions?: Array>; + wallets?: Array>; + settings?: Array>; + votedTx?: Record | null; + proposals?: Record; + latest?: LatestEpoch; +}; + +const NOW = new Date("2026-08-27T12:00:00Z"); +const LATEST: LatestEpoch = { + epoch: 600, + // Latest epoch ends 30h from NOW β†’ a proposal expiring in epoch 600 lands in the 48h window. + end_time: Math.floor(NOW.getTime() / 1000) + 30 * 3600, +}; + +function setting(address: string, overrides: Record = {}) { + return { + walletId: "wallet_1", + signerAddress: address, + email: `${address}@example.com`, + emailNormalized: `${address}@example.com`, + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + notifyThresholdReached: true, + notifyBallotDeadlines: true, + ...overrides, + }; +} + +function voteTxJson(...proposalIds: string[]) { + return JSON.stringify({ + votes: proposalIds.map((id) => { + const [txHash, txIndex] = id.split("#"); + return { vote: { govActionId: { txHash, txIndex: Number(txIndex) } } }; + }), + }); +} + +const DEFAULT_BALLOT = { + id: "ballot_1", + walletId: "wallet_1", + description: "August votes", + items: [PROPOSAL_A, PROPOSAL_B], + itemDescriptions: ["Treasury withdrawal", "Info action"], + createdAt: new Date("2026-08-20T00:00:00Z"), +}; + +function makeScenario(overrides: Scenario = {}) { + const ballots = overrides.ballots ?? [DEFAULT_BALLOT]; + const transactions = overrides.transactions ?? []; + const wallets = overrides.wallets ?? [ + { + id: "wallet_1", + name: "Treasury", + signersAddresses: ["addr_test_one", "addr_test_two"], + numRequiredSigners: 2, + type: "atLeast", + isArchived: false, + }, + ]; + const settings = overrides.settings ?? [setting("addr_test_one")]; + const proposals = overrides.proposals ?? { + [PROPOSAL_A]: { expiration: 600 }, + [PROPOSAL_B]: { expiration: 605 }, + }; + const latest = overrides.latest ?? LATEST; + + const upsert = jest.fn( + async (args: { create: Record }) => args.create, + ); + const fetchLatestEpoch = jest.fn(async (_network: string) => latest); + const fetchProposal = jest.fn( + async (_network: string, txHash: string, certIndex: number) => + proposals[`${txHash}#${certIndex}`] ?? null, + ); + const db = { + ballot: { findMany: jest.fn(async () => ballots) }, + wallet: { findMany: jest.fn(async () => wallets) }, + transaction: { + findMany: jest.fn(async (_args: unknown) => transactions), + findFirst: jest.fn(async (_args: unknown) => overrides.votedTx ?? null), + }, + walletSignerNotificationSetting: { + findMany: jest.fn(async () => settings), + }, + notificationDelivery: { upsert }, + }; + return { db, upsert, fetchLatestEpoch, fetchProposal }; +} + +function createdRows(upsert: ReturnType["upsert"]) { + return upsert.mock.calls.map((call) => call[0].create); +} + +describe("enqueueBallotDeadlineReminders β€” ballots", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("enqueues a 48h reminder for the earliest expiring active proposal", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario(); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result).toMatchObject({ + ballotsScanned: 1, + ballotsDue: 1, + transactionsScanned: 0, + transactionsDue: 0, + remindersEnqueued: 1, + skipped: 1, + errors: [], + }); + expect(fetchLatestEpoch).toHaveBeenCalledTimes(1); + expect(fetchLatestEpoch).toHaveBeenCalledWith("0"); + expect(fetchProposal).toHaveBeenCalledTimes(2); + expect(upsert).toHaveBeenCalledTimes(2); + + const created = createdRows(upsert); + const eligible = created.find((row) => row.recipientAddress === "addr_test_one")!; + expect(eligible).toMatchObject({ + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + resourceType: "ballot", + resourceId: "ballot_1", + walletId: "wallet_1", + status: NOTIFICATION_STATUS_PENDING, + idempotencyKey: + "ballot.deadline:email:ballot:ballot_1:wallet_1:addr_test_one:48h:600", + subject: "Ballot closes in 48 hours: Treasury", + }); + const payload = eligible.payload as Record; + expect(payload.window).toBe("48h"); + expect(payload.kind).toBe("ballot"); + expect(payload.deadlineEpoch).toBe(600); + // Only the proposal that sets the deadline is listed. + expect(payload.proposals).toEqual([ + { id: PROPOSAL_A, title: "Treasury withdrawal", expirationEpoch: 600 }, + ]); + + const skipped = created.find((row) => row.recipientAddress === "addr_test_two")!; + expect(skipped).toMatchObject({ status: "skipped_no_email", recipientEmail: null }); + }); + + it("uses the 24h window (and never both) inside the final day", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + latest: { epoch: 600, end_time: Math.floor(NOW.getTime() / 1000) + 5 * 3600 }, + }); + + await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(createdRows(upsert).map((row) => row.idempotencyKey)).toEqual([ + "ballot.deadline:email:ballot:ballot_1:wallet_1:addr_test_one:24h:600", + "ballot.deadline:email:ballot:ballot_1:wallet_1:addr_test_two:24h:600", + ]); + }); + + it("does nothing when the deadline is more than 48h away", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + proposals: { + [PROPOSAL_A]: { expiration: 601 }, + [PROPOSAL_B]: { expiration: 605 }, + }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result.ballotsDue).toBe(0); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("ignores proposals that are no longer active or already past expiry", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + proposals: { + [PROPOSAL_A]: { expiration: 600, enacted_epoch: 599 }, + [PROPOSAL_B]: { expiration: 599 }, + }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result.ballotsDue).toBe(0); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("skips ballots whose vote transaction was already submitted", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + votedTx: { id: "tx_voted" }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(db.transaction.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + walletId: "wallet_1", + state: 1, + description: { + in: ["Ballot Vote: August votes", "Proxy Ballot Vote: August votes"], + }, + }), + }), + ); + expect(result).toMatchObject({ ballotsDue: 1, remindersEnqueued: 0, skipped: 1 }); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("skips archived wallets and fetches each proposal once across ballots", async () => { + const { db, upsert, fetchProposal, fetchLatestEpoch } = makeScenario({ + ballots: [ + { ...DEFAULT_BALLOT, id: "ballot_1", items: [PROPOSAL_A], itemDescriptions: ["Shared"] }, + { ...DEFAULT_BALLOT, id: "ballot_2", items: [PROPOSAL_A], itemDescriptions: ["Shared"] }, + { + ...DEFAULT_BALLOT, + id: "ballot_3", + walletId: "wallet_archived", + items: [PROPOSAL_A], + itemDescriptions: ["Shared"], + }, + ], + wallets: [ + { + id: "wallet_1", + name: "Treasury", + signersAddresses: ["addr_test_one"], + numRequiredSigners: 1, + type: "atLeast", + isArchived: false, + }, + ], + proposals: { [PROPOSAL_A]: { expiration: 600 } }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(fetchProposal).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ ballotsScanned: 3, ballotsDue: 2, remindersEnqueued: 2 }); + expect(upsert).toHaveBeenCalledTimes(2); + }); + + it("records provider failures without aborting the scan", async () => { + const { db, upsert, fetchLatestEpoch } = makeScenario(); + const failingFetch = jest.fn( + async ( + _network: string, + _txHash: string, + _certIndex: number, + ): Promise => { + throw new Error("blockfrost down"); + }, + ); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal: failingFetch, + }); + + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toContain("blockfrost down"); + expect(upsert).not.toHaveBeenCalled(); + }); +}); + +describe("enqueueBallotDeadlineReminders β€” pending vote transactions", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const DIRECT_VOTE_TX = { + id: "tx_vote", + walletId: "wallet_1", + txJson: voteTxJson(PROPOSAL_A), + description: "Vote: Yes - Treasury withdrawal", + signedAddresses: ["addr_test_one"], + createdAt: new Date("2026-08-26T00:00:00Z"), + }; + + it("reminds signers about a direct vote that was never added to a ballot", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + ballots: [], + transactions: [DIRECT_VOTE_TX], + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(db.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ state: 0 }), + }), + ); + expect(result).toMatchObject({ + ballotsScanned: 0, + transactionsScanned: 1, + transactionsDue: 1, + remindersEnqueued: 1, + skipped: 1, + }); + expect(fetchProposal).toHaveBeenCalledTimes(1); + + const created = createdRows(upsert); + const eligible = created.find((row) => row.recipientAddress === "addr_test_one")!; + expect(eligible).toMatchObject({ + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + resourceType: "transaction", + resourceId: "tx_vote", + idempotencyKey: + "ballot.deadline:email:transaction:tx_vote:wallet_1:addr_test_one:48h:600", + subject: "Vote closes in 48 hours: Treasury", + }); + const payload = eligible.payload as Record; + expect(payload.kind).toBe("transaction"); + expect(payload.signedCount).toBe(1); + expect(payload.requiredCount).toBe(2); + expect(String(payload.text)).toContain("1 of 2 required signatures collected"); + expect(String(payload.text)).toContain("Review and sign: "); + expect(String(payload.text)).toContain("/wallets/wallet_1/transactions"); + }); + + it("ignores pending transactions without votes and far-off deadlines", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + ballots: [], + transactions: [ + { ...DIRECT_VOTE_TX, id: "tx_plain", txJson: JSON.stringify({ outputs: [] }) }, + { ...DIRECT_VOTE_TX, id: "tx_far", txJson: voteTxJson(PROPOSAL_B) }, + ], + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result).toMatchObject({ + transactionsScanned: 1, + transactionsDue: 0, + remindersEnqueued: 0, + }); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("defers a ballot to the pending vote transaction that covers its expiring proposals", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + transactions: [ + { + ...DIRECT_VOTE_TX, + id: "tx_ballot_vote", + txJson: voteTxJson(PROPOSAL_A, PROPOSAL_B), + description: "Ballot Vote: August votes", + }, + ], + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + // One email per signer about this deadline: the transaction's. + expect(result).toMatchObject({ + ballotsDue: 1, + transactionsDue: 1, + remindersEnqueued: 1, + skipped: 2, // ballot deferred + co-signer without email + }); + expect(createdRows(upsert).map((row) => row.idempotencyKey)).toEqual([ + "ballot.deadline:email:transaction:tx_ballot_vote:wallet_1:addr_test_one:48h:600", + "ballot.deadline:email:transaction:tx_ballot_vote:wallet_1:addr_test_two:48h:600", + ]); + expect(db.transaction.findFirst).not.toHaveBeenCalled(); + }); + + it("still reminds about a ballot that is only partially covered by a pending vote", async () => { + const proposalC = "cc".repeat(32) + "#2"; + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + ballots: [ + { + ...DEFAULT_BALLOT, + items: [PROPOSAL_A, proposalC], + itemDescriptions: ["Treasury withdrawal", "Constitution"], + }, + ], + transactions: [DIRECT_VOTE_TX], + proposals: { + [PROPOSAL_A]: { expiration: 600 }, + [proposalC]: { expiration: 600 }, + }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result).toMatchObject({ ballotsDue: 1, transactionsDue: 1, remindersEnqueued: 2 }); + const keys = createdRows(upsert) + .filter((row) => row.recipientAddress === "addr_test_one") + .map((row) => row.idempotencyKey); + expect(keys).toEqual([ + "ballot.deadline:email:transaction:tx_vote:wallet_1:addr_test_one:48h:600", + "ballot.deadline:email:ballot:ballot_1:wallet_1:addr_test_one:48h:600", + ]); + }); +}); + +describe("renderBallotDeadlineEmail", () => { + it("lists proposals, the UTC deadline and escapes values", () => { + const template = renderBallotDeadlineEmail({ + walletName: "Treasury & Co", + kind: "ballot", + label: "August", + window: "24h", + deadline: new Date("2026-08-28T12:00:00Z"), + deadlineEpoch: 600, + proposals: [ + { id: PROPOSAL_A, title: "Withdraw", expirationEpoch: 600 }, + { id: "cc".repeat(32) + "#2", title: null, expirationEpoch: 600 }, + ], + actionUrl: "https://example.com/wallets/w/governance", + preferencesUrl: "https://example.com/wallets/w/info", + }); + + expect(template.subject).toBe("Ballot closes in 24 hours: Treasury & Co"); + expect(template.html).toContain("Treasury & Co"); + expect(template.html).toContain("<b>August</b>"); + expect(template.html).toContain("2026-08-28 12:00 UTC (end of epoch 600)"); + expect(template.html).toContain("Withdraw"); + expect(template.text).toContain("- Withdraw (expires epoch 600)"); + expect(template.text).toContain(`- ${"cc".repeat(32)}#2 (expires epoch 600)`); + expect(template.text).toContain("Open governance: https://example.com/wallets/w/governance"); + expect(template.text).toContain("you can ignore this reminder"); + }); + + it("renders the pending-transaction variant with signature progress", () => { + const template = renderBallotDeadlineEmail({ + walletName: "Treasury", + kind: "transaction", + label: "Vote: Yes - Withdraw", + window: "48h", + deadline: new Date("2026-08-29T12:00:00Z"), + deadlineEpoch: 600, + proposals: [{ id: PROPOSAL_A, title: null, expirationEpoch: 600 }], + signedCount: 1, + requiredCount: 3, + actionUrl: "https://example.com/wallets/w/transactions", + preferencesUrl: "https://example.com/wallets/w/info", + }); + + expect(template.subject).toBe("Vote closes in 48 hours: Treasury"); + expect(template.text).toContain('pending vote transaction "Vote: Yes - Withdraw"'); + expect(template.text).toContain("1 of 3 required signatures collected"); + expect(template.text).toContain("Review and sign: https://example.com/wallets/w/transactions"); + expect(template.text).not.toContain("you can ignore this reminder"); + }); +}); diff --git a/src/__tests__/cip146Registration.test.ts b/src/__tests__/cip146Registration.test.ts index 4f8f6186..f9865571 100644 --- a/src/__tests__/cip146Registration.test.ts +++ b/src/__tests__/cip146Registration.test.ts @@ -5,6 +5,7 @@ import { chunkMetadataString, isRegistrationUpToDate, joinMetadataString, + participantsInclude, participantsMatchExactly, truncateMetadataString, } from "../utils/cip146Registration"; @@ -143,6 +144,32 @@ describe("participantsMatchExactly", () => { }); }); +describe("participantsInclude", () => { + const item = { + tx_hash: "1".repeat(64), + json_metadata: { + types: [0], + participants: { + [hashA.toUpperCase()]: { name: "Alice" }, + [hashB]: { name: "Bob" }, + }, + }, + }; + + it("accepts subsets and the full set, case-insensitively", () => { + expect(participantsInclude(item, [hashA])).toBe(true); + expect(participantsInclude(item, [hashA.toUpperCase(), hashB])).toBe(true); + }); + + it("rejects supersets, empty queries and items without participants", () => { + expect(participantsInclude(item, [hashA, hashB, hashC])).toBe(false); + expect(participantsInclude(item, [])).toBe(false); + expect(participantsInclude({ tx_hash: "2".repeat(64) }, [hashA])).toBe( + false, + ); + }); +}); + describe("isRegistrationUpToDate", () => { it("recognizes matching on-chain metadata, including chunked strings", () => { const wallet = makeWallet("A Very Long Wallet Name", "desc"); diff --git a/src/__tests__/contractOutcome.test.ts b/src/__tests__/contractOutcome.test.ts new file mode 100644 index 00000000..573e1083 --- /dev/null +++ b/src/__tests__/contractOutcome.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "@jest/globals"; + +import { + buildSignOffPayload, + canonicalizeSignOffPayload, + evaluateContractOutcome, + evaluateThreshold, +} from "@/lib/documents/payload"; + +/** + * The contract outcome rule, and why it cannot be `evaluateThreshold`. + * + * A threshold counts approvals anonymously. A contract is a set of named + * obligations β€” so the two differ precisely where it matters: an optional + * party's refusal must not sink the agreement, and a required party's refusal + * must, no matter how many other signatures exist. + */ + +const P = (id: string, required = true) => ({ id, required }); +const R = (partyId: string | null, action: "approve" | "reject") => ({ + partyId, + action, +}); + +describe("evaluateContractOutcome", () => { + it("approves once every required party has approved", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve"), R("seller", "approve")], + }), + ).toBe("Approved"); + }); + + it("stays in review while a required party has not acted", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve")], + }), + ).toBe("InReview"); + }); + + it("is rejected the moment any required party rejects", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve"), R("seller", "reject")], + }), + ).toBe("Rejected"); + }); + + it("ignores an optional party's refusal", () => { + // The case a threshold cannot express. A Witness declining is not the + // contract failing. + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [ + R("buyer", "approve"), + R("seller", "approve"), + R("witness", "reject"), + ], + }), + ).toBe("Approved"); + }); + + it("does not let an optional party's approval stand in for a required one", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [R("buyer", "approve"), R("witness", "approve")], + }), + ).toBe("InReview"); + }); + + it("completes without the optional party ever acting", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("witness", false)], + reviews: [R("buyer", "approve")], + }), + ).toBe("Approved"); + }); + + it("refuses to call a party set with nothing required decided", () => { + // "Every required party approved" is vacuously true over an empty set, and + // approving a contract nobody had to sign is the worst possible default. + expect( + evaluateContractOutcome({ + parties: [P("witness", false)], + reviews: [R("witness", "approve")], + }), + ).toBe("InReview"); + expect(evaluateContractOutcome({ parties: [], reviews: [] })).toBe( + "InReview", + ); + }); + + it("ignores reviews that carry no party", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer")], + reviews: [R(null, "approve")], + }), + ).toBe("InReview"); + }); + + it("counts one human holding two roles as two obligations", () => { + // Both parties may resolve to the same wallet address; the rule never sees + // addresses, only capacities, which is what makes dual roles safe. + expect( + evaluateContractOutcome({ + parties: [P("tenant"), P("guarantor")], + reviews: [R("tenant", "approve")], + }), + ).toBe("InReview"); + expect( + evaluateContractOutcome({ + parties: [P("tenant"), P("guarantor")], + reviews: [R("tenant", "approve"), R("guarantor", "approve")], + }), + ).toBe("Approved"); + }); + + it("differs from evaluateThreshold on the case that matters", () => { + // Same facts, both rules. Two approvals out of three signers with a + // required party having rejected: the anonymous count says Approved. + expect( + evaluateThreshold({ + approvals: 2, + rejections: 1, + signerCount: 3, + requiredSigners: 2, + }), + ).toBe("Approved"); + + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [ + R("buyer", "approve"), + R("witness", "approve"), + R("seller", "reject"), + ], + }), + ).toBe("Rejected"); + }); +}); + +describe("partyId in the signed bytes", () => { + const base = { + action: "approve" as const, + contentHash: "a".repeat(64), + documentId: "d", + signedAt: "2026-01-01T00:00:00.000Z", + signerAddress: "addr", + versionId: "v", + versionNumber: 1, + walletId: "w", + walletPolicyHash: "p", + }; + + it("leaves threshold bytes byte-identical to before contracts existed", () => { + // The reason this needs no SIGNOFF_DOMAIN bump: canonicalize drops + // undefined keys, so every proof already issued keeps verifying. + const threshold = canonicalizeSignOffPayload(buildSignOffPayload(base)); + expect(threshold).not.toContain("partyId"); + expect( + canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: null }), + ), + ).toBe(threshold); + }); + + it("binds the capacity when there is one", () => { + const parties = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_buyer" }), + ); + expect(parties).toContain('"partyId":"cp_buyer"'); + // Sorted like every other key β€” canonical form is not insertion order. + expect(parties.indexOf('"documentId"')).toBeLessThan( + parties.indexOf('"partyId"'), + ); + }); + + it("gives two roles of one human different bytes to sign", () => { + const asTenant = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_tenant" }), + ); + const asGuarantor = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_guarantor" }), + ); + // Same address, same version, same action β€” and not interchangeable. This + // is what stops one signature counting twice. + expect(asTenant).not.toBe(asGuarantor); + }); +}); diff --git a/src/__tests__/discoverQuery.test.ts b/src/__tests__/discoverQuery.test.ts new file mode 100644 index 00000000..f71b8498 --- /dev/null +++ b/src/__tests__/discoverQuery.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "@jest/globals"; +import { + deserializeAddress, + pubKeyAddress, + resolvePaymentKeyHash, + resolveStakeKeyHash, + serializeAddressObj, + serializeNativeScript, + type NativeScript, +} from "@meshsdk/core"; + +import { + classifyDiscoverInput, + describeDiscoverQuery, +} from "../utils/discoverQuery"; +import { participantsInclude } from "../utils/cip146Registration"; +import { + externalStakeCredential, + mockAddresses, + mockKeyHashes, + realTestAddresses, +} from "./testUtils"; + +const hashA = mockKeyHashes.payment1; +const hashB = mockKeyHashes.payment2; + +const script: NativeScript = { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: hashA }, + { type: "sig", keyHash: hashB }, + ], +}; + +describe("classifyDiscoverInput", () => { + it("treats blank input as the default (own keys) query", () => { + expect(classifyDiscoverInput("", 0)).toEqual({ kind: "empty" }); + expect(classifyDiscoverInput(" \n", 0)).toEqual({ kind: "empty" }); + }); + + it("classifies a bare 56-hex hash as ambiguous and lowercases it", () => { + expect(classifyDiscoverInput(` ${hashA.toUpperCase()} `, 0)).toEqual({ + kind: "hash", + hash: hashA, + }); + }); + + it("classifies a base address as a signer query with payment + stake hashes", () => { + const address = realTestAddresses.address1; + const result = classifyDiscoverInput(address, 0); + expect(result).toEqual({ + kind: "signer", + keyHashes: [ + resolvePaymentKeyHash(address).toLowerCase(), + deserializeAddress(address).stakeCredentialHash.toLowerCase(), + ], + }); + }); + + it("classifies an enterprise address as a payment-hash-only signer query", () => { + const enterprise = serializeAddressObj(pubKeyAddress(hashA), 0); + expect(classifyDiscoverInput(enterprise, 0)).toEqual({ + kind: "signer", + keyHashes: [hashA], + }); + }); + + it("classifies a stake address as a signer query by stake hash", () => { + expect(classifyDiscoverInput(externalStakeCredential, 0)).toEqual({ + kind: "signer", + keyHashes: [resolveStakeKeyHash(externalStakeCredential).toLowerCase()], + }); + }); + + it("classifies a multisig (script) address as a policy query", () => { + const { address, scriptCbor } = serializeNativeScript( + script, + undefined, + 0, + true, + ); + expect(scriptCbor).toBeTruthy(); + const scriptHash = deserializeAddress(address).scriptHash.toLowerCase(); + expect(scriptHash).toMatch(/^[0-9a-f]{56}$/); + expect(classifyDiscoverInput(address, 0)).toEqual({ + kind: "policy", + scriptHash, + address, + }); + }); + + it("rejects addresses from the other network", () => { + expect(classifyDiscoverInput(mockAddresses.mainnet, 0)).toEqual({ + kind: "invalid", + reason: "wrong-network", + }); + expect(classifyDiscoverInput(realTestAddresses.address1, 1)).toEqual({ + kind: "invalid", + reason: "wrong-network", + }); + expect(classifyDiscoverInput(externalStakeCredential, 1)).toEqual({ + kind: "invalid", + reason: "wrong-network", + }); + }); + + it("rejects malformed input", () => { + expect(classifyDiscoverInput("hello", 0)).toEqual({ + kind: "invalid", + reason: "malformed", + }); + expect(classifyDiscoverInput("addr_test1notreallyanaddress", 0)).toEqual({ + kind: "invalid", + reason: "malformed", + }); + expect(classifyDiscoverInput("stake_test1nope", 0)).toEqual({ + kind: "invalid", + reason: "malformed", + }); + // 55 hex chars β€” one short of a hash, not an address either + expect(classifyDiscoverInput("a".repeat(55), 0)).toEqual({ + kind: "invalid", + reason: "malformed", + }); + }); +}); + +describe("describeDiscoverQuery", () => { + it("labels each query kind for result copy", () => { + expect(describeDiscoverQuery({ kind: "empty" })).toBe("your keys"); + expect(describeDiscoverQuery({ kind: "signer", keyHashes: [hashA] })).toBe( + "this signer", + ); + expect( + describeDiscoverQuery({ kind: "policy", scriptHash: hashA }), + ).toBe("this wallet"); + expect(describeDiscoverQuery({ kind: "hash", hash: hashA })).toBe( + "this hash", + ); + }); +}); + +describe("participantsInclude", () => { + const item = { + tx_hash: "1".repeat(64), + json_metadata: { + types: [0, 2], + participants: { + [hashA.toUpperCase()]: { name: "Alice" }, + [hashB]: { name: "Bob" }, + [mockKeyHashes.stake1]: { name: "Alice stake" }, + }, + }, + }; + + it("accepts a script whose signers are all participants (subset)", () => { + expect(participantsInclude(item, [hashA, hashB])).toBe(true); + expect(participantsInclude(item, [hashA.toUpperCase()])).toBe(true); + }); + + it("rejects a script with a signer the registration does not list", () => { + expect(participantsInclude(item, [hashA, mockKeyHashes.drep1])).toBe(false); + }); + + it("rejects empty queries and items without participants", () => { + expect(participantsInclude(item, [])).toBe(false); + expect(participantsInclude({ tx_hash: "2".repeat(64) }, [hashA])).toBe( + false, + ); + }); +}); diff --git a/src/__tests__/documentAttestation.test.ts b/src/__tests__/documentAttestation.test.ts new file mode 100644 index 00000000..01cd6330 --- /dev/null +++ b/src/__tests__/documentAttestation.test.ts @@ -0,0 +1,280 @@ +import { generateKeyPairSync } from "crypto"; +import { describe, expect, it } from "@jest/globals"; + +import { + ATTESTATION_DOMAIN, + ATTESTATION_STATEMENT, + GENESIS_PREV, + attestationHash, + attestationKeyId, + buildAttestationPayload, + signAttestation, + verifyAttestation, + verifyAttestationChain, + type AttestationRecord, +} from "@/lib/documents/attestation"; + +/** + * The platform attestation is a timestamp and ordering record, so the only + * properties worth testing are the ones an auditor would otherwise have to + * trust the database for: that a chain verifies when it is honest, and that + * every way of altering history is detected. + */ + +function keypair() { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const spkiHex = publicKey + .export({ type: "spki", format: "der" }) + .toString("hex"); + return { privateKey, spkiHex, keyId: attestationKeyId(spkiHex) }; +} + +const KEY = keypair(); +const KEYS = { [KEY.keyId]: KEY.spkiHex }; + +const DOC = "doc_abc"; +const WALLET = "wallet_1"; + +/** A chain of `n` versions, honestly signed and correctly linked. */ +function chain(n: number, key = KEY): AttestationRecord[] { + const out: AttestationRecord[] = []; + let prev = GENESIS_PREV; + for (let i = 1; i <= n; i++) { + const payload = buildAttestationPayload({ + attestedAt: new Date(Date.UTC(2026, 0, i, 12)), + contentHash: String(i).repeat(64).slice(0, 64), + documentId: DOC, + prevAttestationHash: prev, + sequence: i, + versionId: `ver_${i}`, + versionNumber: i, + walletId: WALLET, + }); + out.push({ + payload, + signature: signAttestation(payload, key.privateKey), + publicKeyId: key.keyId, + }); + prev = attestationHash(payload); + } + return out; +} + +describe("a single attestation", () => { + it("verifies under the key that signed it", () => { + const [record] = chain(1); + expect( + verifyAttestation(record!.payload, record!.signature, KEY.spkiHex), + ).toBe(true); + }); + + it("says in its own bytes that it is not an approval", () => { + const [record] = chain(1); + expect(record!.payload.statement).toBe(ATTESTATION_STATEMENT); + expect(record!.payload.statement).toMatch(/not an approval/i); + expect(record!.payload.statement).toMatch(/grants no authority/i); + expect(record!.payload.domain).toBe(ATTESTATION_DOMAIN); + }); + + it("does not verify under a different key", () => { + const other = keypair(); + const [record] = chain(1); + expect( + verifyAttestation(record!.payload, record!.signature, other.spkiHex), + ).toBe(false); + }); + + it("does not verify once any signed field is changed", () => { + const [record] = chain(1); + for (const mutate of [ + { contentHash: "b".repeat(64) }, + { versionNumber: 99 }, + { attestedAt: "2020-01-01T00:00:00.000Z" }, + { walletId: "wallet_2" }, + { statement: "I approve this document." }, + ]) { + expect( + verifyAttestation( + { ...record!.payload, ...mutate }, + record!.signature, + KEY.spkiHex, + ), + ).toBe(false); + } + }); + + it("rejects a malformed signature rather than throwing", () => { + const [record] = chain(1); + for (const bad of ["", "zz", "ff", "f".repeat(127), "g".repeat(128)]) { + expect(verifyAttestation(record!.payload, bad, KEY.spkiHex)).toBe(false); + } + }); + + it("rejects a malformed public key rather than throwing", () => { + const [record] = chain(1); + expect( + verifyAttestation(record!.payload, record!.signature, "not-a-key"), + ).toBe(false); + }); +}); + +describe("the chain", () => { + it("verifies an honest history and reports its head", () => { + const records = chain(3); + const result = verifyAttestationChain(records, KEYS); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); + expect(result.head).toBe(attestationHash(records[2]!.payload)); + }); + + it("starts from genesis", () => { + expect(chain(1)[0]!.payload.prevAttestationHash).toBe(GENESIS_PREV); + }); + + it("refuses an empty chain", () => { + expect(verifyAttestationChain([], KEYS).ok).toBe(false); + }); + + it("detects a reordered history", () => { + const [a, b, c] = chain(3); + const swapped = [a!, c!, b!]; + const result = verifyAttestationChain(swapped, KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/does not link|expected sequence/); + }); + + it("detects a removed version", () => { + const [a, , c] = chain(3); + const result = verifyAttestationChain([a!, c!], KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/does not link|expected sequence/); + }); + + it("detects an altered version, even re-signed by the real key", () => { + // The strongest case: an attacker who HAS the key rewrites history in + // place. The link from the next attestation still commits to the original, + // so the tampering surfaces anyway. + const records = chain(3); + const rewritten = { + ...records[1]!.payload, + contentHash: "e".repeat(64), + }; + records[1] = { + payload: rewritten, + signature: signAttestation(rewritten, KEY.privateKey), + publicKeyId: KEY.keyId, + }; + const result = verifyAttestationChain(records, KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/does not link/); + }); + + it("detects a back-dated attestation", () => { + const records = chain(3); + const backdated = { + ...records[2]!.payload, + attestedAt: "2000-01-01T00:00:00.000Z", + }; + records[2] = { + payload: backdated, + signature: signAttestation(backdated, KEY.privateKey), + publicKeyId: KEY.keyId, + }; + const result = verifyAttestationChain(records, KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/attested before/); + }); + + it("refuses an attestation signed by a key it does not know", () => { + const rogue = keypair(); + const records = chain(2, rogue); + const result = verifyAttestationChain(records, KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/unknown key/); + }); + + it("refuses a forged attestation appended without the key", () => { + const records = chain(2); + const forgedPayload = buildAttestationPayload({ + attestedAt: new Date(Date.UTC(2026, 0, 3, 12)), + contentHash: "c".repeat(64), + documentId: DOC, + prevAttestationHash: attestationHash(records[1]!.payload), + sequence: 3, + versionId: "ver_3", + versionNumber: 3, + walletId: WALLET, + }); + // Correctly linked, correctly sequenced β€” and unsigned by anyone who could. + const result = verifyAttestationChain( + [ + ...records, + { + payload: forgedPayload, + signature: "0".repeat(128), + publicKeyId: KEY.keyId, + }, + ], + KEYS, + ); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/signature does not verify/); + }); + + it("refuses a chain that splices in another document's attestation", () => { + const mine = chain(1); + const theirs = chain(1); + const spliced = [ + mine[0]!, + { + ...theirs[0]!, + payload: { ...theirs[0]!.payload, documentId: "doc_other" }, + }, + ]; + expect(verifyAttestationChain(spliced, KEYS).ok).toBe(false); + }); + + it("accepts a rotated key when both keys are known", () => { + const older = KEY; + const newer = keypair(); + + const first = buildAttestationPayload({ + attestedAt: new Date(Date.UTC(2026, 0, 1, 12)), + contentHash: "a".repeat(64), + documentId: DOC, + prevAttestationHash: GENESIS_PREV, + sequence: 1, + versionId: "ver_1", + versionNumber: 1, + walletId: WALLET, + }); + const second = buildAttestationPayload({ + attestedAt: new Date(Date.UTC(2026, 0, 2, 12)), + contentHash: "b".repeat(64), + documentId: DOC, + prevAttestationHash: attestationHash(first), + sequence: 2, + versionId: "ver_2", + versionNumber: 2, + walletId: WALLET, + }); + + const result = verifyAttestationChain( + [ + { + payload: first, + signature: signAttestation(first, older.privateKey), + publicKeyId: older.keyId, + }, + { + payload: second, + signature: signAttestation(second, newer.privateKey), + publicKeyId: newer.keyId, + }, + ], + { ...KEYS, [newer.keyId]: newer.spkiHex }, + ); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/__tests__/documentSignoff.test.ts b/src/__tests__/documentSignoff.test.ts new file mode 100644 index 00000000..113477a5 --- /dev/null +++ b/src/__tests__/documentSignoff.test.ts @@ -0,0 +1,394 @@ +/** + * Document Sign-Off (PRD-001) β€” payload binding, threshold, and proof verification. + * + * These cover the two rules the feature stands on: a signature is bound to one + * exact document version, and the threshold comes from the frozen signer + * snapshot. Both are enforced server-side, so both are tested server-side. + */ + +import { + SIGNOFF_DOMAIN, + SIGNOFF_STATEMENTS, + buildSignOffPayload, + canonicalize, + canonicalizeSignOffPayload, + evaluateThreshold, + isSha256Hex, + isSignedAtWithinTolerance, + sha256Hex, + walletPolicyHash, +} from "@/lib/documents/payload"; +import { + PROOF_FORMAT, + VERIFICATION_INSTRUCTIONS, + verifyProofPackage, + type ProofPackage, + type ProofReview, +} from "@/lib/documents/proof"; + +const SIGNER_A = "addr_test1_signer_a"; +const SIGNER_B = "addr_test1_signer_b"; +const SIGNER_C = "addr_test1_signer_c"; +const OUTSIDER = "addr_test1_outsider"; + +const CONTENT_HASH = sha256Hex("the budget, version 1"); +const OTHER_HASH = sha256Hex("the budget, version 2"); +const POLICY_HASH = walletPolicyHash("8200581c-script-cbor"); +const SIGNED_AT = "2026-08-05T09:00:00.000Z"; + +/** Accepts anything β€” isolates the non-signature checks. */ +const acceptAll = async () => true; +const rejectAll = async () => false; + +function makeReview( + signerAddress: string, + action: "approve" | "reject" = "approve", + overrides: Partial<{ contentHash: string; versionId: string; comment: string }> = {}, +): ProofReview { + const payload = buildSignOffPayload({ + action, + comment: overrides.comment, + contentHash: overrides.contentHash ?? CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress, + versionId: overrides.versionId ?? "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + return { + signerAddress, + action, + comment: overrides.comment ?? null, + payload: canonicalizeSignOffPayload(payload), + signature: "cose_sign1_hex", + signatureKey: "cose_key_hex", + signedAt: SIGNED_AT, + }; +} + +function makeProof(reviews: ProofReview[], requiredSigners = 2): ProofPackage { + return { + format: PROOF_FORMAT, + exportedAt: "2026-08-05T10:00:00.000Z", + document: { + id: "doc_1", + walletId: "wallet_1", + title: "Q3 Treasury Budget", + description: null, + documentType: null, + createdBy: SIGNER_A, + createdAt: "2026-08-01T00:00:00.000Z", + }, + version: { + id: "ver_1", + versionNumber: 1, + contentHash: CONTENT_HASH, + hashAlgorithm: "sha256", + fileName: "budget.pdf", + mimeType: "application/pdf", + fileSize: 1024, + status: "Approved", + createdBy: SIGNER_A, + createdAt: "2026-08-01T00:00:00.000Z", + reviewStartedAt: "2026-08-02T00:00:00.000Z", + decidedAt: "2026-08-05T09:00:00.000Z", + }, + policy: { + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + requiredSigners, + signersAddresses: [SIGNER_A, SIGNER_B, SIGNER_C], + signersDescriptions: ["Alice", "Bob", "Carol"], + capturedAt: "2026-08-02T00:00:00.000Z", + }, + reviews, + events: [], + verification: { + domain: SIGNOFF_DOMAIN, + instructions: VERIFICATION_INSTRUCTIONS, + }, + }; +} + +// --------------------------------------------------------------------------- + +describe("canonicalization", () => { + it("is independent of key insertion order", () => { + expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 })); + }); + + it("produces no incidental whitespace", () => { + expect(canonicalize({ a: 1, b: "x" })).toBe('{"a":1,"b":"x"}'); + }); + + it("drops undefined but keeps null", () => { + expect(canonicalize({ a: undefined, b: null })).toBe('{"b":null}'); + }); + + it("recurses into nested objects and arrays", () => { + expect(canonicalize({ z: [{ b: 1, a: 2 }] })).toBe('{"z":[{"a":2,"b":1}]}'); + }); +}); + +describe("buildSignOffPayload", () => { + it("carries the plain-language statement that matches the action", () => { + const approve = buildSignOffPayload({ + action: "approve", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + expect(approve.statement).toBe(SIGNOFF_STATEMENTS.approve); + expect(approve.statement).toMatch(/I approve this exact document version/); + expect(approve.domain).toBe(SIGNOFF_DOMAIN); + }); + + it("always includes comment, so an empty comment is still signed", () => { + const payload = buildSignOffPayload({ + action: "reject", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + expect(payload.comment).toBe(""); + expect(canonicalizeSignOffPayload(payload)).toContain('"comment":""'); + }); + + it("rejects an unparseable signedAt rather than silently stamping now()", () => { + expect(() => + buildSignOffPayload({ + action: "approve", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: "not-a-date", + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }), + ).toThrow(/not a valid date/i); + }); +}); + +describe("version-hash binding", () => { + const base = { + action: "approve" as const, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }; + + it("produces a different payload for a different content hash", () => { + const v1 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const v2 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: OTHER_HASH, versionId: "ver_1" }), + ); + expect(v1).not.toBe(v2); + }); + + it("produces a different payload for a different version id", () => { + const v1 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const v2 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_2" }), + ); + expect(v1).not.toBe(v2); + }); + + it("a tampered comment changes the payload, so the signature no longer matches", () => { + const clean = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const tampered = canonicalizeSignOffPayload( + buildSignOffPayload({ + ...base, + contentHash: CONTENT_HASH, + versionId: "ver_1", + comment: "actually I meant no", + }), + ); + expect(clean).not.toBe(tampered); + }); + + it("rebuilding from identical inputs is byte-identical β€” the server-side check", () => { + const input = { ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }; + expect(canonicalizeSignOffPayload(buildSignOffPayload(input))).toBe( + canonicalizeSignOffPayload(buildSignOffPayload(input)), + ); + }); +}); + +describe("hash + time helpers", () => { + it("recognises a sha256 digest and rejects near-misses", () => { + expect(isSha256Hex(CONTENT_HASH)).toBe(true); + expect(isSha256Hex(CONTENT_HASH.toUpperCase())).toBe(false); + expect(isSha256Hex(CONTENT_HASH.slice(0, 63))).toBe(false); + expect(isSha256Hex("")).toBe(false); + }); + + it("accepts a signedAt inside the window and rejects one outside it", () => { + const now = new Date("2026-08-05T09:00:00.000Z"); + expect(isSignedAtWithinTolerance("2026-08-05T09:05:00.000Z", now)).toBe(true); + expect(isSignedAtWithinTolerance("2026-08-05T08:45:00.000Z", now)).toBe(false); + expect(isSignedAtWithinTolerance("nonsense", now)).toBe(false); + }); +}); + +describe("evaluateThreshold", () => { + it("approves once the threshold is met", () => { + expect( + evaluateThreshold({ approvals: 2, rejections: 0, signerCount: 3, requiredSigners: 2 }), + ).toBe("Approved"); + }); + + it("stays open while the threshold is still reachable", () => { + expect( + evaluateThreshold({ approvals: 1, rejections: 1, signerCount: 3, requiredSigners: 2 }), + ).toBe("InReview"); + }); + + it("rejects as soon as the threshold has become unreachable", () => { + expect( + evaluateThreshold({ approvals: 1, rejections: 2, signerCount: 3, requiredSigners: 2 }), + ).toBe("Rejected"); + }); + + it("handles unanimous policies", () => { + expect( + evaluateThreshold({ approvals: 2, rejections: 1, signerCount: 3, requiredSigners: 3 }), + ).toBe("Rejected"); + expect( + evaluateThreshold({ approvals: 3, rejections: 0, signerCount: 3, requiredSigners: 3 }), + ).toBe("Approved"); + }); +}); + +describe("verifyProofPackage", () => { + it("accepts a well-formed, fully signed, threshold-reaching package", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(true); + expect(result.approvals).toBe(2); + expect(result.thresholdReached).toBe(true); + expect(result.reviews.every((r) => r.valid)).toBe(true); + }); + + it("confirms a re-hashed document against the approved content hash", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const ok = await verifyProofPackage(proof, { + checkSignature: acceptAll, + expectedContentHash: CONTENT_HASH, + }); + expect(ok.contentHashMatches).toBe(true); + expect(ok.valid).toBe(true); + + const wrong = await verifyProofPackage(proof, { + checkSignature: acceptAll, + expectedContentHash: OTHER_HASH, + }); + expect(wrong.contentHashMatches).toBe(false); + expect(wrong.valid).toBe(false); + expect(wrong.errors.join(" ")).toMatch(/does not hash to the approved content hash/i); + }); + + it("fails when a signature does not verify", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: rejectAll }); + expect(result.valid).toBe(false); + expect(result.approvals).toBe(0); + expect(result.reviews[0]?.signatureValid).toBe(false); + }); + + it("fails when a review's payload names a different version's hash", async () => { + const proof = makeProof([ + makeReview(SIGNER_A), + makeReview(SIGNER_B, "approve", { contentHash: OTHER_HASH }), + ]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.payloadBindsToVersion).toBe(false); + expect(result.reviews[1]?.errors.join(" ")).toMatch(/payload\.contentHash/); + }); + + it("rejects a signer who is not in the frozen snapshot", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(OUTSIDER)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.signerInSnapshot).toBe(false); + expect(result.approvals).toBe(1); + }); + + it("rejects a duplicated signer rather than counting them twice", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_A)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.errors.join(" ")).toMatch(/duplicate/i); + expect(result.approvals).toBe(1); + }); + + it("rejects a payload that is not in canonical form", async () => { + const review = makeReview(SIGNER_A); + const reordered = JSON.stringify(JSON.parse(review.payload), null, 2); + const proof = makeProof([{ ...review, payload: reordered }, makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[0]?.errors.join(" ")).toMatch(/canonical/i); + }); + + it("reports not-yet-approved when the threshold is unmet", async () => { + const proof = makeProof([makeReview(SIGNER_A)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.thresholdReached).toBe(false); + expect(result.valid).toBe(false); + expect(result.approvals).toBe(1); + expect(result.requiredSigners).toBe(2); + }); + + it("counts rejections separately and does not credit them as approvals", async () => { + const proof = makeProof([ + makeReview(SIGNER_A, "approve"), + makeReview(SIGNER_B, "reject"), + ]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.approvals).toBe(1); + expect(result.rejections).toBe(1); + expect(result.thresholdReached).toBe(false); + }); + + it("flags an unknown proof format", async () => { + const proof = { ...makeProof([makeReview(SIGNER_A)]), format: "something-else" } as unknown as ProofPackage; + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch(/unknown proof format/i); + }); + + it("survives a signature checker that throws", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { + checkSignature: async () => { + throw new Error("cbor decode failed"); + }, + }); + expect(result.valid).toBe(false); + expect(result.reviews[0]?.errors.join(" ")).toMatch(/cbor decode failed/); + }); +}); diff --git a/src/__tests__/documentsVaultPreview.test.ts b/src/__tests__/documentsVaultPreview.test.ts new file mode 100644 index 00000000..d5312bd3 --- /dev/null +++ b/src/__tests__/documentsVaultPreview.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; + +import type { VaultTrustView } from "@/lib/vault-trust-types"; + +type Props = { props: { view: VaultTrustView | null } }; + +const MODULE = "@/lib/documents/vault-preview"; + +/** + * The preview embeds the demo vault inside a wallet page, which puts a + * filesystem read and a hash graph on the request path of a treasury surface. + * Two things must hold: the props Next hands the client are actually + * serialisable, and a vault that fails to build hides the panel instead of + * taking the page down. + */ +describe("documents vault preview page", () => { + afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + }); + + it("supplies a view that survives Next's JSON serialisation", async () => { + const { loadVaultPreviewProps } = await import(MODULE); + const result = { props: loadVaultPreviewProps() } as Props; + + expect(result.props.view).not.toBeNull(); + expect(result.props.view!.hubs.length).toBeGreaterThan(0); + expect(result.props.view!.notes.length).toBeGreaterThan(0); + expect(result.props.view!.rootHash).toMatch(/^[0-9a-f]{64}$/); + + // getServerSideProps props are JSON-serialised. A Map, Set, Date or + // undefined in there is a runtime "Error serializing" on the deployed page + // that no type check catches, so assert the round trip is lossless. + const roundTripped: unknown = JSON.parse(JSON.stringify(result.props.view)); + expect(roundTripped).toEqual(result.props.view); + }); + + it("hides the panel instead of 500-ing when the vault will not build", async () => { + jest.resetModules(); + jest.doMock("@/lib/vault-trust", () => ({ + loadVaultTrustView: () => { + throw new Error("vault trust graph: Trust cycle: A -> B -> A"); + }, + })); + const errors = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + const { loadVaultPreviewProps } = await import(MODULE); + const result = { props: loadVaultPreviewProps() } as Props; + + // Degraded, not thrown: a Markdown edit in an unrelated directory must not + // be able to take down a wallet's Documents section. + expect(result.props.view).toBeNull(); + expect(errors).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/draftSync.test.ts b/src/__tests__/draftSync.test.ts new file mode 100644 index 00000000..b688b5b6 --- /dev/null +++ b/src/__tests__/draftSync.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@jest/globals"; + +import { decideDraftSync } from "@/lib/documents/draft-sync"; + +/** + * The failure mode this guards against is silent data loss, so every branch is + * pinned β€” especially the two that would destroy work: adopting the server's + * copy over unsaved edits, and rolling an author backwards on a stale poll. + */ +describe("decideDraftSync", () => { + it("adopts on first load", () => { + expect( + decideDraftSync({ baseRevision: null, dirty: false, remoteRevision: 3 }), + ).toBe("adopt"); + }); + + it("adopts on first load even mid-typing β€” there is no base to conflict with", () => { + expect( + decideDraftSync({ baseRevision: null, dirty: true, remoteRevision: 3 }), + ).toBe("adopt"); + }); + + it("does nothing when there is no draft on the server", () => { + expect( + decideDraftSync({ + baseRevision: null, + dirty: false, + remoteRevision: null, + }), + ).toBe("ignore"); + }); + + it("follows a newer revision while nothing is unsaved", () => { + expect( + decideDraftSync({ baseRevision: 4, dirty: false, remoteRevision: 5 }), + ).toBe("adopt"); + }); + + it("refuses to overwrite unsaved edits", () => { + expect( + decideDraftSync({ baseRevision: 4, dirty: true, remoteRevision: 5 }), + ).toBe("conflict"); + }); + + it("ignores its own revision echoed back", () => { + expect( + decideDraftSync({ baseRevision: 5, dirty: false, remoteRevision: 5 }), + ).toBe("ignore"); + }); + + it("never rolls the author back on a stale poll", () => { + // A poll issued before our save can answer after it. Adopting that older + // body would silently undo what was just written. + expect( + decideDraftSync({ baseRevision: 6, dirty: false, remoteRevision: 5 }), + ).toBe("ignore"); + expect( + decideDraftSync({ baseRevision: 6, dirty: true, remoteRevision: 5 }), + ).toBe("ignore"); + }); +}); diff --git a/src/__tests__/mcpTools.test.ts b/src/__tests__/mcpTools.test.ts index a7fe7eee..340cb2f4 100644 --- a/src/__tests__/mcpTools.test.ts +++ b/src/__tests__/mcpTools.test.ts @@ -34,6 +34,8 @@ describe("MCP tool registry", () => { "governance_open_proposals", "ballot_upsert", "ballot_publish_rationale", + "document_list", + "document_get", ]); }); @@ -102,6 +104,18 @@ describe("MCP tool registry", () => { } }); + it("lets the wallet lookup select by signer, policy or address", () => { + // Exactly-one-selector is enforced in the run body (JSON Schema can't + // express it without oneOf, which the MCP client UIs render poorly), so + // the schema must not `require` any single selector. + const tool = MCP_TOOLS.find((t) => t.name === "multisig_lookup_wallet"); + const props = tool?.inputSchema.properties as Record; + expect(Object.keys(props)).toEqual( + expect.arrayContaining(["pubKeyHashes", "scriptHash", "address"]), + ); + expect(tool?.inputSchema.required).toBeUndefined(); + }); + it("caps the governance page size", () => { const tool = MCP_TOOLS.find( (t) => t.name === "governance_list_active_proposals", diff --git a/src/__tests__/notificationWorker.test.ts b/src/__tests__/notificationWorker.test.ts index 58110c14..1b946009 100644 --- a/src/__tests__/notificationWorker.test.ts +++ b/src/__tests__/notificationWorker.test.ts @@ -8,8 +8,10 @@ jest.mock("@/lib/notifications/channels/email/resend", () => ({ })); import { + NOTIFICATION_EVENT_BALLOT_DEADLINE, NOTIFICATION_EVENT_EMAIL_VERIFY, NOTIFICATION_EVENT_SIGNATURE_REQUIRED, + NOTIFICATION_EVENT_THRESHOLD_REACHED, NOTIFICATION_STATUS_PENDING, NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, } from "@/lib/notifications/events"; @@ -49,6 +51,8 @@ function makeSetting(overrides: Record) { emailOptIn: true, notifyTransactionSignatures: true, notifySignableSignatures: true, + notifyThresholdReached: true, + notifyBallotDeadlines: true, ...overrides, }; } @@ -152,6 +156,89 @@ describe("drainNotificationOutbox preference re-check", () => { }); }); + it("skips a threshold-reached delivery when that toggle is off", async () => { + const delivery = makeDelivery({ + id: "delivery_threshold", + eventType: NOTIFICATION_EVENT_THRESHOLD_REACHED, + }); + const db = makeDb( + [delivery], + [makeSetting({ notifyThresholdReached: false })], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ + id: "delivery_threshold", + status: "skipped_disabled", + }); + }); + + it("still sends a threshold-reached delivery when only signature toggles are off", async () => { + const delivery = makeDelivery({ + id: "delivery_threshold_ok", + eventType: NOTIFICATION_EVENT_THRESHOLD_REACHED, + }); + const db = makeDb( + [delivery], + [ + makeSetting({ + notifyTransactionSignatures: false, + notifySignableSignatures: false, + }), + ], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(results[0]).toMatchObject({ + id: "delivery_threshold_ok", + status: "sent", + }); + }); + + it("skips a ballot-deadline delivery when that toggle is off", async () => { + const delivery = makeDelivery({ + id: "delivery_ballot", + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + resourceType: "ballot", + }); + const db = makeDb( + [delivery], + [makeSetting({ notifyBallotDeadlines: false })], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ + id: "delivery_ballot", + status: "skipped_disabled", + }); + }); + + it("gates transaction-keyed ballot-deadline deliveries by the same toggle", async () => { + const delivery = makeDelivery({ + id: "delivery_ballot_tx", + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + resourceType: "transaction", + }); + const db = makeDb( + [delivery], + [makeSetting({ notifyBallotDeadlines: false })], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ + id: "delivery_ballot_tx", + status: "skipped_disabled", + }); + }); + it("treats a missing settings row as no email at send time", async () => { const delivery = makeDelivery({ id: "delivery_missing" }); const db = makeDb([delivery], []); diff --git a/src/__tests__/ogCards.test.ts b/src/__tests__/ogCards.test.ts new file mode 100644 index 00000000..2a9259a8 --- /dev/null +++ b/src/__tests__/ogCards.test.ts @@ -0,0 +1,105 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +import { + OG_CARD, + OG_IMAGE_HEIGHT, + OG_IMAGE_PATH, + OG_IMAGE_VERSION, + OG_IMAGE_WIDTH, + getRouteSeo, + ogImageUrl, + routeSeo, +} from "@/lib/seo"; + +/** + * The social cards are committed PNGs produced by scripts/generate-og-image.mjs + * and referenced by path from seo.ts. Nothing at build time links the two, so a + * renamed or forgotten card would ship as a broken og:image and only surface + * when someone shared a link. These tests are that link. + */ + +const PUBLIC_DIR = join(process.cwd(), "public"); + +/** Read width/height straight out of the PNG IHDR chunk β€” no image library. */ +function pngSize(absPath: string): { width: number; height: number } { + const buf = readFileSync(absPath); + expect(buf.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a"); + expect(buf.subarray(12, 16).toString("ascii")).toBe("IHDR"); + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; +} + +const allCards = [OG_IMAGE_PATH, ...Object.values(OG_CARD)]; + +describe("Open Graph cards", () => { + it.each(allCards)("%s exists in /public", (cardPath) => { + expect(existsSync(join(PUBLIC_DIR, cardPath))).toBe(true); + }); + + it.each(allCards)("%s is a %sx%s PNG", (cardPath) => { + // Facebook, X and LinkedIn all crop away from 1.91:1; an off-size card gets + // letterboxed or centre-cropped through the headline. + expect(pngSize(join(PUBLIC_DIR, cardPath))).toEqual({ + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, + }); + }); + + it("gives every card a distinct file", () => { + expect(new Set(allCards).size).toBe(allCards.length); + }); + + it("only points routes at cards that exist", () => { + for (const [pathname, entry] of Object.entries(routeSeo)) { + if (!entry.image) continue; + expect([pathname, existsSync(join(PUBLIC_DIR, entry.image))]).toEqual([ + pathname, + true, + ]); + } + }); + + it("pairs every route-level card with alt text", () => { + for (const [pathname, entry] of Object.entries(routeSeo)) { + if (!entry.image) continue; + expect([pathname, entry.imageAlt?.length ?? 0]).not.toEqual([pathname, 0]); + } + }); +}); + +describe("getRouteSeo β€” card resolution", () => { + it("returns the route's own card", () => { + expect(getRouteSeo("/governance").image).toBe(OG_CARD.governance); + expect(getRouteSeo("/roadmap/graph").image).toBe(OG_CARD.roadmapGraph); + }); + + it("shares the DRep card between the explorer and a single DRep", () => { + expect(getRouteSeo("/governance/drep/[id]").image).toBe( + getRouteSeo("/governance/drep").image, + ); + }); + + it("falls back to the site card for routes without one", () => { + expect(getRouteSeo("/").image).toBe(OG_IMAGE_PATH); + expect(getRouteSeo("/wallets/[wallet]").image).toBe(OG_IMAGE_PATH); + }); +}); + +describe("ogImageUrl", () => { + it("returns an absolute, version-tagged URL", () => { + const url = ogImageUrl(OG_CARD.blog); + expect(url).toMatch(/^https?:\/\//); + expect(url).toContain(OG_CARD.blog); + expect(url.endsWith(`?v=${OG_IMAGE_VERSION}`)).toBe(true); + }); + + it("defaults to the site card", () => { + expect(ogImageUrl()).toContain(OG_IMAGE_PATH); + }); + + it("appends rather than replaces an existing query string", () => { + expect(ogImageUrl("/og/blog.png?foo=1")).toContain( + `?foo=1&v=${OG_IMAGE_VERSION}`, + ); + }); +}); diff --git a/src/__tests__/resolveScript.test.ts b/src/__tests__/resolveScript.test.ts new file mode 100644 index 00000000..2f4c205a --- /dev/null +++ b/src/__tests__/resolveScript.test.ts @@ -0,0 +1,246 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); +const providerGetMock = jest.fn<(path: string) => Promise>(); +const deserializeAddressMock = jest.fn<(address: string) => unknown>(); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, +})); + +jest.mock("@/utils/get-provider", () => ({ + __esModule: true, + getProvider: () => ({ + get: providerGetMock, + }), +})); + +jest.mock("@meshsdk/core", () => ({ + __esModule: true, + deserializeAddress: (address: string) => deserializeAddressMock(address), +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/resolveScript")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); +}); + +const scriptHash = "1".repeat(56); +const scriptAddress = "addr_test1scriptaddress"; +const keyAddress = "addr_test1keyaddress"; +const sigA = "b".repeat(56); +const sigB = "c".repeat(56); +const scriptJson = { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: sigA.toUpperCase() }, + { type: "sig", keyHash: sigB }, + ], +}; +const notFound = { + response: { data: { error: "Not Found", status_code: 404 } }, +}; + +function makeRequest(query: Record): NextApiRequest { + return { + method: "GET", + headers: {}, + query, + } as unknown as NextApiRequest; +} + +describe("resolveScript API", () => { + it("rejects a request with neither selector", async () => { + const res = createMockResponse(); + await handler(makeRequest({ network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(providerGetMock).not.toHaveBeenCalled(); + }); + + it("rejects a request with both selectors", async () => { + const res = createMockResponse(); + await handler( + makeRequest({ scriptHash, address: scriptAddress, network: "0" }), + res, + ); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an invalid scriptHash", async () => { + const res = createMockResponse(); + await handler(makeRequest({ scriptHash: "nope", network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an invalid network", async () => { + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "7" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an address without a script payment credential", async () => { + deserializeAddressMock.mockReturnValue({ + pubKeyHash: "d".repeat(56), + scriptHash: "", + stakeCredentialHash: "", + stakeScriptCredentialHash: "", + }); + const res = createMockResponse(); + await handler(makeRequest({ address: keyAddress, network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Address is not a multisig (script) address", + }); + }); + + it("rejects an address that does not deserialize", async () => { + deserializeAddressMock.mockImplementation(() => { + throw new Error("bad bech32"); + }); + const res = createMockResponse(); + await handler(makeRequest({ address: "garbage", network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("resolves sig hashes for a script hash", async () => { + providerGetMock.mockImplementation(async (path: unknown) => { + if (path === `/scripts/${scriptHash}/json`) return { json: scriptJson }; + throw new Error(`Unexpected provider path: ${String(path)}`); + }); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash: scriptHash.toUpperCase(), network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: null, + scriptJson, + sigHashes: [sigA, sigB], + }); + expect(res.setHeader).toHaveBeenCalledWith( + "Cache-Control", + "public, max-age=300, stale-while-revalidate=600", + ); + }); + + it("resolves via a multisig address and carries its stake credential", async () => { + deserializeAddressMock.mockReturnValue({ + pubKeyHash: "", + scriptHash, + stakeCredentialHash: "", + stakeScriptCredentialHash: "2".repeat(56), + }); + providerGetMock.mockResolvedValue({ json: scriptJson }); + + const res = createMockResponse(); + await handler(makeRequest({ address: scriptAddress, network: "1" }), res); + + expect(deserializeAddressMock).toHaveBeenCalledWith(scriptAddress); + expect(providerGetMock).toHaveBeenCalledWith(`/scripts/${scriptHash}/json`); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: "2".repeat(56), + scriptJson, + sigHashes: [sigA, sigB], + }); + }); + + it("returns an empty resolution when the provider has no such script", async () => { + providerGetMock.mockRejectedValue(notFound); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: null, + scriptJson: null, + sigHashes: [], + }); + expect(res.setHeader).toHaveBeenCalledWith( + "Cache-Control", + "public, max-age=60, stale-while-revalidate=120", + ); + }); + + it("returns an empty resolution for a Plutus script (no timelock json)", async () => { + providerGetMock.mockResolvedValue({ json: null }); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: null, + scriptJson: null, + sigHashes: [], + }); + }); + + it("surfaces unsupported script json with no signers", async () => { + const weird = { type: "mystery" }; + providerGetMock.mockResolvedValue({ json: weird }); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: null, + scriptJson: weird, + sigHashes: [], + }); + }); + + it("returns 500 on unexpected provider failures", async () => { + providerGetMock.mockRejectedValue(new Error("boom")); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(500); + errorSpy.mockRestore(); + }); + + it("stops when the rate limiter rejects the request", async () => { + applyRateLimitMock.mockReturnValue(false); + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + expect(corsMock).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects non-GET methods", async () => { + const res = createMockResponse(); + await handler( + { ...makeRequest({ scriptHash }), method: "POST" } as NextApiRequest, + res, + ); + expect(res.status).toHaveBeenCalledWith(405); + }); +}); diff --git a/src/__tests__/signDataRoundTrip.test.ts b/src/__tests__/signDataRoundTrip.test.ts new file mode 100644 index 00000000..05def933 --- /dev/null +++ b/src/__tests__/signDataRoundTrip.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "@jest/globals"; +import { MeshWallet, checkSignature, generateNonce } from "@meshsdk/core"; + +import { sign } from "@/utils/signing"; + +/** + * End-to-end round trip for `sign()`, against the REAL @meshsdk helpers. + * + * The existing signing.test.ts mocks `checkSignature` and `generateNonce`, so it + * proves the control flow (throw when verification fails) but can never prove + * that verification actually succeeds for a genuine signature. A bug lived in + * exactly that gap: `sign()` verified against `generateNonce(payload)` β€” which + * is `hex(payload + 32 random characters)` β€” while the wallet had signed + * `payload`, so verification returned false for every honest signature and + * `sign()` threw "Signature failed verification" every single time. + * + * This test signs with a real key and asserts the round trip closes, so no mock + * can hide the same class of defect again. + */ + +// Deterministic throwaway key. Never used for anything but this test. +const MNEMONIC = Array(24).fill("solution"); + +async function testWallet() { + const wallet = new MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: MNEMONIC }, + }); + await wallet.init(); + const address = + (await wallet.getUsedAddresses())[0] ?? + (await wallet.getUnusedAddresses())[0]; + if (!address) throw new Error("test wallet produced no address"); + return { wallet, address }; +} + +// A canonical sign-off statement, the shape src/lib/documents/payload.ts emits. +const PAYLOAD = JSON.stringify({ + action: "approve", + contentHash: "a".repeat(64), + domain: "mesh-multisig.document-signoff.v1", + versionNumber: 1, +}); + +describe("sign() round trip against real @meshsdk helpers", () => { + it("returns a signature that verifies, instead of throwing", async () => { + const { wallet, address } = await testWallet(); + + const signature = await sign(PAYLOAD, wallet, 0, address); + + expect(signature.signature).toEqual(expect.any(String)); + expect(signature.key).toEqual(expect.any(String)); + await expect(checkSignature(PAYLOAD, signature, address)).resolves.toBe( + true, + ); + }, 60_000); + + it("verifies against the payload, never against a freshly generated nonce", async () => { + // This is the defect stated as a property. `generateNonce` appends random + // characters, so a nonce built client-side cannot be what the wallet signed. + // Verifying against it is always false β€” which is why sign() must not. + const { wallet, address } = await testWallet(); + const signature = await wallet.signData(PAYLOAD, address); + + await expect(checkSignature(PAYLOAD, signature, address)).resolves.toBe( + true, + ); + await expect( + checkSignature(generateNonce(PAYLOAD), signature, address), + ).resolves.toBe(false); + }, 60_000); + + it("rejects a signature made over different bytes", async () => { + const { wallet, address } = await testWallet(); + const signature = await wallet.signData(PAYLOAD, address); + + await expect( + checkSignature(`${PAYLOAD} tampered`, signature, address), + ).resolves.toBe(false); + }, 60_000); +}); diff --git a/src/__tests__/signTransaction.bot.test.ts b/src/__tests__/signTransaction.bot.test.ts index 0544cd2d..070b1d95 100644 --- a/src/__tests__/signTransaction.bot.test.ts +++ b/src/__tests__/signTransaction.bot.test.ts @@ -19,6 +19,12 @@ const submitTxWithScriptRecoveryMock: jest.Mock = jest.fn(); const findWalletMock: jest.Mock = jest.fn(); const findTransactionMock: jest.Mock = jest.fn(); const updateManyTransactionMock: jest.Mock = jest.fn(); +const enqueueThresholdReachedMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/notifications/center", () => ({ + __esModule: true, + enqueueThresholdReachedNotifications: enqueueThresholdReachedMock, +})); jest.mock("@/lib/cors", () => ({ __esModule: true, @@ -166,9 +172,56 @@ beforeEach(() => { txHex: "deadbeef-merged", }); (updateManyTransactionMock as any).mockResolvedValue({ count: 1 }); + (enqueueThresholdReachedMock as any).mockResolvedValue([]); }); describe("signTransaction bot API", () => { + it("hands the before/after signer sets to the threshold notifier", async () => { + const req = { + method: "POST", + headers: makeBearerAuth(), + body: { + walletId: "wallet-1", + transactionId: "tx-1", + address: BOT_TEST_ADDRESS, + signature: "aa".repeat(64), + key: "bb".repeat(64), + broadcast: false, + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(enqueueThresholdReachedMock).toHaveBeenCalledTimes(1); + expect(enqueueThresholdReachedMock.mock.calls[0]![1]).toMatchObject({ + resourceType: "transaction", + resourceId: "tx-1", + previousSignedAddresses: [], + signedAddresses: [BOT_TEST_ADDRESS], + actorAddress: BOT_TEST_ADDRESS, + txHash: null, + }); + }); + + it("still records the witness when the threshold notifier throws", async () => { + (enqueueThresholdReachedMock as any).mockRejectedValueOnce(new Error("boom")); + const req = { + method: "POST", + headers: makeBearerAuth(), + body: { + walletId: "wallet-1", + transactionId: "tx-1", + address: BOT_TEST_ADDRESS, + signature: "aa".repeat(64), + key: "bb".repeat(64), + broadcast: false, + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(200); + }); + it("returns 403 when bot is not cosigner", async () => { (getBotWalletAccessMock as any).mockResolvedValue({ allowed: true, role: "observer" }); const req = { diff --git a/src/__tests__/signing.test.ts b/src/__tests__/signing.test.ts index 81d46b3d..7c04a325 100644 --- a/src/__tests__/signing.test.ts +++ b/src/__tests__/signing.test.ts @@ -32,30 +32,43 @@ describe("signing.ts source contract", () => { // failure path. We mock the @meshsdk/core helpers because they pull in // CSL/serialization which is heavyweight for a unit test. // --------------------------------------------------------------------------- -const checkSignatureMock = jest.fn< - (nonce: string, signature: { signature: string; key: string }, address?: string) => Promise ->(); -const generateNonceMock = jest.fn<(payload: string) => string>(); +const checkSignatureMock = + jest.fn< + ( + nonce: string, + signature: { signature: string; key: string }, + address?: string, + ) => Promise + >(); jest.unstable_mockModule("@meshsdk/core", () => ({ __esModule: true, checkSignature: checkSignatureMock, - generateNonce: generateNonceMock, })); const { sign } = await import("../utils/signing"); type MockWallet = { - signData: jest.Mock<(payload: string, address?: string) => Promise<{ signature: string; key: string }>>; + signData: jest.Mock< + ( + payload: string, + address?: string, + ) => Promise<{ signature: string; key: string }> + >; getRewardAddresses: jest.Mock<() => Promise>; }; function createWallet(overrides?: Partial): MockWallet { return { - signData: jest.fn<(payload: string, address?: string) => Promise<{ signature: string; key: string }>>( - async () => ({ signature: "deadbeef", key: "cafe" }), - ), - getRewardAddresses: jest.fn<() => Promise>(async () => ["stake_addr"]), + signData: jest.fn< + ( + payload: string, + address?: string, + ) => Promise<{ signature: string; key: string }> + >(async () => ({ signature: "deadbeef", key: "cafe" })), + getRewardAddresses: jest.fn<() => Promise>(async () => [ + "stake_addr", + ]), ...overrides, } as MockWallet; } @@ -63,8 +76,6 @@ function createWallet(overrides?: Partial): MockWallet { describe("sign", () => { beforeEach(() => { checkSignatureMock.mockReset(); - generateNonceMock.mockReset(); - generateNonceMock.mockReturnValue("nonce-payload"); }); it("role=0 signs with the user payment address and returns the signature", async () => { @@ -92,16 +103,16 @@ describe("sign", () => { it("throws when the chosen role has no resolved address", async () => { const wallet = createWallet(); - await expect(sign("payload", wallet as never, 0, undefined)).rejects.toThrow( - /missing address/i, - ); + await expect( + sign("payload", wallet as never, 0, undefined), + ).rejects.toThrow(/missing address/i); }); it("throws when checkSignature returns false (no silent ternary fallback)", async () => { checkSignatureMock.mockResolvedValueOnce(false); const wallet = createWallet(); - await expect(sign("payload", wallet as never, 0, "addr_test_user")).rejects.toThrow( - /Signature failed verification/i, - ); + await expect( + sign("payload", wallet as never, 0, "addr_test_user"), + ).rejects.toThrow(/Signature failed verification/i); }); }); diff --git a/src/__tests__/summonWallet.test.ts b/src/__tests__/summonWallet.test.ts new file mode 100644 index 00000000..36307e66 --- /dev/null +++ b/src/__tests__/summonWallet.test.ts @@ -0,0 +1,90 @@ +import { buildWallet } from "../utils/common"; +import { Wallet as DbWallet } from "@prisma/client"; +import { RawImportBodies } from "../types/wallet"; + +describe("Summon Wallet Capabilities", () => { + const network = 0; // Testnet + + const mockSummonWallet: DbWallet & { rawImportBodies: RawImportBodies } = { + id: "test-summon-uuid", + name: "Test Summon Wallet", + description: "A test summon wallet", + address: "addr_test1wpnlxv2xv988tvv9z06m6pax76r98slymr6uzy958tclv6sgp98k8", + type: "atLeast", + numRequiredSigners: 2, + signersAddresses: ["addr_test1vpu5vl76u73su6p0657cw6q0657cw6q0657cw6q0657cw6q0657cw"], + signersStakeKeys: [], + signersDRepKeys: [], + scriptCbor: "8201828200581caf000000000000000000000000000000000000000000000000000000008200581cb0000000000000000000000000000000000000000000000000000000", + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + profileImageIpfsUrl: null, + stakeCredentialHash: null, + dRepId: "", + rawImportBodies: { + multisig: { + address: "addr_test1wpnlxv2xv988tvv9z06m6pax76r98slymr6uzy958tclv6sgp98k8", + payment_script: "8200581c00000000000000000000000000000000000000000000000000000000", + stake_script: "8200581c11111111111111111111111111111111111111111111111111111111", + } + } + } as any; + + it("should correctly populate capabilities for a Summon wallet with staking", () => { + const wallet = buildWallet(mockSummonWallet, network); + + expect(wallet.capabilities).toBeDefined(); + expect(wallet.capabilities!.canStake).toBe(true); + expect(wallet.capabilities!.canVote).toBe(false); + expect(wallet.capabilities!.address).toBe(mockSummonWallet.rawImportBodies.multisig!.address); + expect(wallet.capabilities!.stakeAddress).toBeDefined(); + expect(wallet.capabilities!.stakeAddress).toMatch(/^stake_test/); + }); + + it("should correctly populate capabilities for a Summon wallet without staking", () => { + const mockNoStake = { + ...mockSummonWallet, + rawImportBodies: { + multisig: { + ...mockSummonWallet.rawImportBodies.multisig, + stake_script: undefined + } + } + }; + const wallet = buildWallet(mockNoStake, network); + + expect(wallet.capabilities!.canStake).toBe(false); + expect(wallet.capabilities!.stakeAddress).toBeUndefined(); + }); + + it("should correctly handle Summon wallets with unordered CBOR lists", () => { + // Swap the two sigs in the CBOR string to make it "unordered" + // Original: 82 01 82 [sigA] [sigB] + // [sigA] = 8200581caf00000000000000000000000000000000000000000000000000000000 (32 bytes = 64 chars) + // [sigB] = 8200581cb00000000000000000000000000000000000000000000000000000000 (32 bytes = 64 chars) + const sigA = "8200581caf00000000000000000000000000000000000000000000000000000000"; + const sigB = "8200581cb00000000000000000000000000000000000000000000000000000000"; + const unorderedCbor = "820182" + sigB + sigA; + + const mockUnordered = { + ...mockSummonWallet, + rawImportBodies: { + multisig: { + ...mockSummonWallet.rawImportBodies.multisig, + payment_script: unorderedCbor + } + } + }; + + const wallet = buildWallet(mockUnordered, network); + + // The address should still be the one from metadata, even if we can't decode the "unordered" CBOR correctly + // This ensures compatibility with legacy scripts that might not follow modern canonical rules. + expect(wallet.capabilities!.address).toBe(mockSummonWallet.rawImportBodies.multisig!.address); + expect(wallet.scriptCbor).toBe(unorderedCbor); + + // Even if decoding fails, we should still have a nativeScript object (fallback) + expect(wallet.nativeScript).toBeDefined(); + }); +}); diff --git a/src/__tests__/thresholdReached.test.ts b/src/__tests__/thresholdReached.test.ts new file mode 100644 index 00000000..5ee79f73 --- /dev/null +++ b/src/__tests__/thresholdReached.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +jest.mock("@/lib/notifications/worker", () => ({ + __esModule: true, + drainNotificationOutbox: jest.fn(async () => []), +})); + +import { + crossedSignatureThreshold, + enqueueThresholdReachedNotifications, +} from "@/lib/notifications/center"; +import { + NOTIFICATION_EVENT_THRESHOLD_REACHED, + NOTIFICATION_STATUS_PENDING, + NOTIFICATION_STATUS_SKIPPED_NO_EMAIL, +} from "@/lib/notifications/events"; +import { resolveWalletSignerRecipients } from "@/lib/notifications/recipients"; +import { renderThresholdReachedEmail } from "@/lib/notifications/templates/thresholdReached"; + +const wallet = { + id: "wallet_1", + name: "Treasury", + signersAddresses: ["addr_creator", "addr_second", "addr_third"], + numRequiredSigners: 2, + type: "atLeast", +}; + +function makeSetting(address: string, overrides: Record = {}) { + return { + walletId: "wallet_1", + signerAddress: address, + email: `${address}@example.com`, + emailNormalized: `${address}@example.com`, + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + notifyThresholdReached: true, + notifyBallotDeadlines: true, + ...overrides, + }; +} + +function makeDb(settings: unknown[]) { + const upsert = jest.fn( + async (args: { create: Record }) => args.create, + ); + return { + db: { + walletSignerNotificationSetting: { + findMany: jest.fn(async () => settings), + }, + notificationDelivery: { upsert }, + }, + upsert, + }; +} + +describe("crossedSignatureThreshold", () => { + it("fires only when the count moves from below to at-or-above required", () => { + expect( + crossedSignatureThreshold({ wallet, previousSignedCount: 1, signedCount: 2 }), + ).toBe(true); + expect( + crossedSignatureThreshold({ wallet, previousSignedCount: 0, signedCount: 1 }), + ).toBe(false); + expect( + crossedSignatureThreshold({ wallet, previousSignedCount: 2, signedCount: 3 }), + ).toBe(false); + expect( + crossedSignatureThreshold({ wallet, previousSignedCount: 0, signedCount: 3 }), + ).toBe(true); + }); + + it("uses the signer count for 'all' wallets and 1 for 'any'", () => { + expect( + crossedSignatureThreshold({ + wallet: { ...wallet, type: "all", numRequiredSigners: null }, + previousSignedCount: 2, + signedCount: 3, + }), + ).toBe(true); + expect( + crossedSignatureThreshold({ + wallet: { ...wallet, type: "any" }, + previousSignedCount: 0, + signedCount: 1, + }), + ).toBe(true); + }); +}); + +describe("resolveWalletSignerRecipients", () => { + it("includes every signer except excluded ones and reports skip reasons", async () => { + const { db } = makeDb([makeSetting("addr_creator"), makeSetting("addr_second")]); + + const result = await resolveWalletSignerRecipients(db as any, { + walletId: "wallet_1", + signerAddresses: wallet.signersAddresses, + preferenceField: "notifyThresholdReached", + excludeAddresses: ["addr_second", null], + }); + + expect(result.eligible.map((r) => r.address)).toEqual(["addr_creator"]); + expect(result.skipped).toEqual([ + { address: "addr_third", reason: NOTIFICATION_STATUS_SKIPPED_NO_EMAIL }, + ]); + }); +}); + +describe("enqueueThresholdReachedNotifications", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("does nothing when the update did not cross the threshold", async () => { + const { db, upsert } = makeDb([makeSetting("addr_creator")]); + + const deliveries = await enqueueThresholdReachedNotifications(db as any, { + wallet, + resourceType: "transaction", + resourceId: "tx_1", + previousSignedAddresses: ["addr_creator"], + signedAddresses: ["addr_creator"], + actorAddress: "addr_creator", + }); + + expect(deliveries).toEqual([]); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("notifies the creator and other signers but not the actor once crossed", async () => { + const { db, upsert } = makeDb([ + makeSetting("addr_creator"), + makeSetting("addr_second"), + makeSetting("addr_third", { notifyThresholdReached: false }), + ]); + + const deliveries = await enqueueThresholdReachedNotifications(db as any, { + wallet, + resourceType: "transaction", + resourceId: "tx_1", + previousSignedAddresses: ["addr_creator"], + signedAddresses: ["addr_creator", "addr_second"], + actorAddress: "addr_second", + description: "Pay contractor", + txHash: "abc123", + }); + + expect(upsert).toHaveBeenCalledTimes(2); + const byAddress = new Map( + deliveries.map((delivery: any) => [delivery.recipientAddress, delivery]), + ); + expect(byAddress.get("addr_creator")).toMatchObject({ + eventType: NOTIFICATION_EVENT_THRESHOLD_REACHED, + status: NOTIFICATION_STATUS_PENDING, + recipientEmail: "addr_creator@example.com", + idempotencyKey: + "threshold.reached:email:transaction:tx_1:wallet_1:addr_creator", + subject: "Signatures complete: Treasury", + }); + expect(byAddress.get("addr_third")).toMatchObject({ + status: "skipped_disabled", + recipientEmail: null, + }); + expect(byAddress.has("addr_second")).toBe(false); + const payload = byAddress.get("addr_creator")!.payload as Record; + expect(payload.txHash).toBe("abc123"); + expect(String(payload.text)).toContain("Submitted to the network."); + }); +}); + +describe("renderThresholdReachedEmail", () => { + const base = { + walletName: "Treasury