From e6fa296d7139355b61aa48f03594ceabc8c19e3e Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Sat, 13 Jun 2026 10:44:20 +0200 Subject: [PATCH 01/30] fix(db): cap pg pool + stop retrying pool saturation (Supabase EMAXCONNSESSION) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production was 500ing every query with "(EMAXCONNSESSION) max clients reached in session mode - max clients are limited to pool_size: 15". Verified root cause (live + multi-agent audit): - src/server/db.ts created the @prisma/adapter-pg pool with no `max`, so node-postgres defaulted to 10 connections per warm Vercel instance. A couple of instances overrun Supabase's session-mode pool (15 client slots) -> EMAXCONNSESSION on every query, including user.createUser. - The retry wrapper amplified it: it called $connect() against the dead pool between retries and, once connectionTimeoutMillis is finite, would treat the "timeout exceeded when trying to connect" acquire error as a retryable connection error. Changes: - Cap the pool: max: 2, idleTimeoutMillis 10s, connectionTimeoutMillis 10s (finite timeout fails fast instead of pg's default infinite wait). - isConnectionError(): never retry pool-saturation errors (max clients reached / pool_size / EMAXCONNSESSION / connect-timeout). - Drop the $connect() reconnect between retries (the driver-adapter pool reconnects lazily; forcing connect just adds load). The Prisma globalThis singleton was verified correct and left unchanged. The 5 interactive $transaction blocks are pure-DB (no external I/O held), so no leak fix is required for this to hold. NOTE (maintainer action, env — cannot be done in code): point production DATABASE_URL at the Supabase TRANSACTION pooler (port 6543, ?pgbouncer=true) and keep DIRECT_URL on the direct connection (5432) for migrations. The session pooler (5432) is the wrong mode for serverless; this code change is the necessary client-side cap and works as an interim mitigation too. Co-Authored-By: Claude Fable 5 --- src/server/db.ts | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/server/db.ts b/src/server/db.ts index 29b22d06..76a648d8 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -9,6 +9,22 @@ const INITIAL_RETRY_DELAY_MS = 500; // Check if error is a connection error that should be retried const isConnectionError = (error: unknown): boolean => { + // Pool saturation must NEVER be retried. Retrying hammers an already + // saturated pool and amplifies the outage. This covers the Supabase + // Supavisor session-mode limit (EMAXCONNSESSION / "max clients reached") + // and the node-postgres pool acquire timeout ("timeout exceeded when + // trying to connect"). Fail fast so the request errors instead of looping. + if (error instanceof Error) { + const m = error.message.toLowerCase(); + if ( + m.includes("max clients reached") || + m.includes("pool_size") || + m.includes("emaxconnsession") || + m.includes("timeout exceeded when trying to connect") + ) { + return false; + } + } if (error instanceof Prisma.PrismaClientKnownRequestError) { // P1001: Can't reach database server // P1008: Operations timed out @@ -55,14 +71,9 @@ const withRetry = async ( } await new Promise((resolve) => setTimeout(resolve, delay)); - - // Try to reconnect before retrying - try { - await prismaClient.$connect(); - } catch { - // Ignore connection errors here, let the retry handle it - } - + + // Do NOT call $connect() here: the pg driver-adapter pool reconnects + // lazily, and forcing a connect against a saturated pool only adds load. return withRetry(operation, retries - 1); } throw error; @@ -104,7 +115,20 @@ const createPrismaClient = () => { // Prisma 7 requires a driver adapter (or Accelerate) instead of a schema/url // connection. The pg adapter connects via the pooled DATABASE_URL; migrations // use the direct connection configured in prisma.config.ts. - const adapter = new PrismaPg({ connectionString: env.DATABASE_URL }); + // Cap the node-postgres pool per serverless instance. Without `max`, + // node-postgres defaults to 10 connections/instance — a few warm Vercel + // instances overrun Supabase's session-mode pool (15 client slots) and + // every query fails with EMAXCONNSESSION. A small max keeps connections + // well under the pooler limit and preserves transaction-mode multiplexing; + // concurrent intra-request queries queue rather than deadlock (they each + // release per statement). Finite timeouts make a saturated pool fail fast + // instead of hanging on the pg default connectionTimeoutMillis: 0 (infinite). + const adapter = new PrismaPg({ + connectionString: env.DATABASE_URL, + max: 2, + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 10_000, + }); const client = new PrismaClient({ adapter, From 7bf5cb34ca51f30041df1d814e1263b573e04bf5 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Sat, 13 Jun 2026 11:24:56 +0200 Subject: [PATCH 02/30] docs(roadmap): month-by-month breakdown with per-owner tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Break grouped quarters into individual Month 2–12 sections, each with Quirin/Andre task tables matching Month 1's format - Add Document Sign-Off flagship feature (MVP→v1→v2→v3) woven across months - Add Mesh 2.0 upgrade; extend FROST research to include Lemour PQC multi-sig - Shift schedule one month earlier (June work completed); April is buffer - Drop completed items (Aiken crowdfund, full address, pagination, collateral, 404) Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 281 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 220 insertions(+), 61 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7374f8b1..a4abe6fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,59 +42,216 @@ Status of M1 tasks. Last updated 2026-04-23. --- -## Months 2–3 — June–July 2026 +## Month 2 — June 2026 -**Direction:** Authentication, Summon migration, collateral service, minor fixes. +**Focus:** Mesh 2.0 upgrade and CI improvements. -- Improved authentication — nonce-based auth, wallet connection fixes, registration flow (#135, #53) -- Summon migration — land API routes and wallet import (PR #212, PR #208) -- Collateral service — 22 ADA → 4 UTxOs for proxy collateral (#221) -- Full address verification (#196) -- Transaction pagination (#30) -- Better 404 page (#22) -- Monthly report +**Quirin** + +| Task | Issues | +|------|--------| +| Mesh 2.0 upgrade — migrate to Mesh SDK 2.0 | | + +**Andre** + +| Task | Issues | +|------|--------| +| CI improvements | | + +--- + +## Month 3 — July 2026 + +**Focus:** On-chain wallet discovery and FROST kickoff. + +**Quirin** + +| Task | Issues | +|------|--------| +| FROST research kickoff | #220 | + +**Andre** + +| Task | Issues | +|------|--------| +| Wallet V2 — on-chain registration and discovery | #33 | + +--- + +## Month 4 — August 2026 + +**Focus:** Document Sign-Off MVP — build (see [Flagship feature](#flagship-feature--document-sign-off)). + +**Quirin** + +| Task | Issues | +|------|--------| +| Document Sign-Off MVP (build) — 5-table data model, four routes, CIP-8 signature enforcement, version-hash binding | | + +**Andre** + +| Task | Issues | +|------|--------| +| Document Sign-Off MVP (build) — Documents section UI, six-state lifecycle, signer review screen | | + +--- + +## Month 5 — September 2026 + +**Focus:** Document Sign-Off MVP — ship (8–10 wk effort completes). + +**Quirin** + +| Task | Issues | +|------|--------| +| Document Sign-Off MVP (ship) — proof export (JSON + PDF), verify route | | + +**Andre** + +| Task | Issues | +|------|--------| +| Document Sign-Off MVP (ship) — diffs where feasible, status grouping, polish | | +| Monthly report | | --- -## Months 4–6 — August–October 2026 +## Month 6 — October 2026 -**Direction:** Governance, smart contracts, and on-chain wallet discovery. +**Focus:** Document Sign-Off provenance, FROST findings, hardware wallets. -- Aiken crowdfund integration (PR #164) -- Governance metadata fix (#122) -- Proxy voting polish and documentation -- Wallet V2 — on-chain registration and discovery (#33) -- Pending transactions on homepage (#125) -- FROST research kickoff (#220) -- Backlog cleanup, dependency/security updates -- Monthly reports +**Quirin** + +| Task | Issues | +|------|--------| +| Document Sign-Off v1 — Provenance (history, diff & rollback, richer audit export) | | +| FROST research — deliver findings, PoC, go/no-go | #220 | + +**Andre** + +| Task | Issues | +|------|--------| +| Hardware wallet support — Ledger/Trezor | #44 | --- -## Months 7–9 — November 2026–January 2027 +## Month 7 — November 2026 + +**Focus:** Governance polish, dApp connector, bot platform. + +**Quirin** -**Direction:** Ecosystem integrations and developer experience. +| Task | Issues | +|------|--------| +| Governance metadata fix | #122 | +| dApp connector — external dApps request multi-sig transactions | | -- Hardware wallet support — Ledger/Trezor (#44) -- Bot platform v2 — SDK, webhooks, example bots -- dApp connector — external dApps request multi-sig transactions -- API documentation and developer portal -- FROST research — deliver findings, PoC, go/no-go (#220) -- Monthly reports +**Andre** + +| Task | Issues | +|------|--------| +| Pending transactions on homepage | #125 | +| Bot platform v2 — SDK, webhooks, example bots | | --- -## Months 10–12 — February–April 2027 +## Month 8 — December 2026 + +**Focus:** Proxy voting, testing, developer experience. + +**Quirin** + +| Task | Issues | +|------|--------| +| Proxy voting polish and documentation | | +| Transaction builder & tRPC integration tests | #255 | + +**Andre** + +| Task | Issues | +|------|--------| +| API documentation and developer portal | | +| Backlog cleanup, dependency/security updates | | +| Monthly report | | + +--- + +## Month 9 — January 2027 + +**Focus:** Document Sign-Off checkpoints, vesting, growth. + +**Quirin** + +| Task | Issues | +|------|--------| +| Document Sign-Off v2 — Checkpoints (opt-in on-chain anchoring in Cardano metadata) | | +| Vesting — time-locked multi-sig contracts | #81 | + +**Andre** + +| Task | Issues | +|------|--------| +| User profiles and contacts | | + +--- + +## Month 10 — February 2027 + +**Focus:** Invite flow and discovery. + +**Quirin** + +| Task | Issues | +|------|--------| +| Invite flow | PR #67 | + +**Andre** + +| Task | Issues | +|------|--------| +| Discover page — browse wallets, DAOs, governance | #52 | + +--- + +## Month 11 — March 2027 + +**Focus:** Polish, wrap-up, and forward-looking research. + +**Quirin** + +| Task | Issues | +|------|--------| +| Performance and UX audit | | +| Final summary report — activity, outcomes, gaps, next steps | | + +**Andre** + +| Task | Issues | +|------|--------| +| Document Sign-Off v3 — Collaboration & standards (CRDT/QES bridge — scoped as research) | | +| Monthly report | | + +--- + +## Month 12 — April 2027 + +**Focus:** Buffer / catch-up — absorb slippage from earlier months, finalize reporting, plan next cycle. + +No fixed feature commitments; reserved for spillover, stabilization, and next-roadmap planning. + +--- + +## Flagship feature — Document Sign-Off + +A wallet-native, off-chain document approval layer: bind approval to an exact version hash, inherit the wallet's signer set + threshold, and collect CIP-8 sign-off (approve/reject) per signer. No new chain, no new token, no change to the transaction model — delivered as a Documents section inside the wallet. -**Direction:** Growth features, polish, and wrap-up. +| Phase | Scope | Months | +|-------|-------|--------| +| MVP — Sign-off | Documents section, six-state lifecycle, version-hash binding, signer review, exportable JSON+PDF proof. Off-chain. | M4–M5 | +| v1 — Provenance | Revision history first-class, diff & rollback, richer audit export (off-chain). | M6 | +| v2 — Checkpoints | Optional on-chain anchoring of a version's hash + parent in Cardano tx metadata. | M9 | +| v3 — Collaboration & standards | Real-time co-authoring (CRDT), metadata standard (CIP candidate), eIDAS/EUDI QES bridge. | M11 (research) | -- Vesting — time-locked multi-sig contracts (#81) -- User profiles and contacts -- Discover page — browse wallets, DAOs, governance (#52) -- Performance and UX audit -- Invite flow (PR #67) -- Final summary report — activity, outcomes, gaps, next steps -- Monthly reports +**Data model:** five entities (`Document`, `DocumentVersion`, `DocumentReview`, `DocumentSignerSnapshot`, `DocumentEvent`) + optional `Checkpoint`, all reusing wallet signer identity and threshold. Approval belongs to a version, never a mutable container; a new version starts a fresh round at zero approvals. --- @@ -102,10 +259,11 @@ Status of M1 tasks. Last updated 2026-04-23. | Topic | Description | Months | Owner | |-------|-------------|--------|-------| -| **FROST multi-sig wallets** | Research FROST (Flexible Round-Optimized Schnorr Threshold) signatures for Cardano. Evaluate feasibility of replacing or complementing native script multi-sig with threshold Schnorr signatures — smaller on-chain footprint, better privacy (single signature on-chain), and flexible threshold schemes. Investigate Cardano-compatible FROST libraries, protocol readiness, and migration path from current native scripts. | 6-9 | Quirin | +| **FROST & PQC multi-sig wallets** | Research FROST (Flexible Round-Optimized Schnorr Threshold) signatures for Cardano. Evaluate feasibility of replacing or complementing native script multi-sig with threshold Schnorr signatures — smaller on-chain footprint, better privacy (single signature on-chain), and flexible threshold schemes. Investigate Cardano-compatible FROST libraries, protocol readiness, and migration path from current native scripts. Also evaluate **Lemour post-quantum (PQC) multi-sig** — lattice-based threshold signatures for long-term quantum resistance — as a forward-looking alternative/complement to FROST. | M3 (kickoff) – M6 (findings) | Quirin | **Research deliverables:** - Written summary of FROST vs native script trade-offs +- Assessment of Lemour PQC multi-sig — maturity, libraries, and quantum-resistance trade-offs vs FROST - Proof-of-concept if libraries are available - Go/no-go recommendation for integration into the platform @@ -145,33 +303,34 @@ Aggregated view of the 12-month roadmap split by contributor. Each task has a si - [M1] Fix transaction loading bug (#211) - [M1] Handle external PR — Summon API routes (PR #212) - [M1] Fix legacy wallet compatibility bug -- [M2–3] Improved authentication — nonce-based auth, wallet connection fixes, registration flow (#135, #53) -- [M2–3] Full address verification (#196) -- [M2–3] Transaction pagination (#30) -- [M4–6] Aiken crowdfund integration (PR #164) -- [M4–6] Governance metadata fix (#122) -- [M4–6] Proxy voting polish and documentation -- [M4–6] FROST research kickoff (#220) -- [M7–9] dApp connector — external dApps request multi-sig transactions -- [M7–9] FROST research — deliver findings, PoC, go/no-go (#220) -- [M10–12] Vesting — time-locked multi-sig contracts (#81) -- [M10–12] Performance and UX audit -- [M10–12] Invite flow (PR #67) -- [M10–12] Final summary report +- [M2] Mesh 2.0 upgrade — migrate to Mesh SDK 2.0 +- [M3] FROST research kickoff (#220) +- [M4–5] Document Sign-Off MVP — data model, routes, CIP-8 enforcement, proof export +- [M6] Document Sign-Off v1 — Provenance (history, diff & rollback, audit export) +- [M6] FROST research — deliver findings, PoC, go/no-go (#220) +- [M7] Governance metadata fix (#122) +- [M7] dApp connector — external dApps request multi-sig transactions +- [M8] Proxy voting polish and documentation +- [M8] Transaction builder & tRPC integration tests (#255) +- [M9] Document Sign-Off v2 — Checkpoints (opt-in on-chain anchoring) +- [M9] Vesting — time-locked multi-sig contracts (#81) +- [M10] Invite flow (PR #67) +- [M11] Performance and UX audit +- [M11] Final summary report ### Andre - [M1] Improve repository infrastructure — preprod environment and comprehensive smoke CI - [M1] CI smoke tests on real chain (#213) - [M1] Handle external PR — capability-based metadata (PR #208) -- [M2–3] Summon migration — land API routes and wallet import (PR #212, PR #208) -- [M2–3] Collateral service — 22 ADA → 4 UTxOs for proxy collateral (#221) -- [M2–3] Better 404 page (#22) -- [M4–6] Wallet V2 — on-chain registration and discovery (#33) -- [M4–6] Pending transactions on homepage (#125) -- [M4–6] Backlog cleanup, dependency/security updates -- [M7–9] Hardware wallet support — Ledger/Trezor (#44) -- [M7–9] Bot platform v2 — SDK, webhooks, example bots -- [M7–9] API documentation and developer portal -- [M10–12] User profiles and contacts -- [M10–12] Discover page — browse wallets, DAOs, governance (#52) +- [M2] CI improvements +- [M3] Wallet V2 — on-chain registration and discovery (#33) +- [M4–5] Document Sign-Off MVP — Documents UI, six-state lifecycle, signer review, diffs +- [M6] Hardware wallet support — Ledger/Trezor (#44) +- [M7] Pending transactions on homepage (#125) +- [M7] Bot platform v2 — SDK, webhooks, example bots +- [M8] API documentation and developer portal +- [M8] Backlog cleanup, dependency/security updates +- [M9] User profiles and contacts +- [M10] Discover page — browse wallets, DAOs, governance (#52) +- [M11] Document Sign-Off v3 — Collaboration & standards (research) From 46f2891f49587f76d3f6b22a572c881da91aa734 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Sat, 13 Jun 2026 11:31:40 +0200 Subject: [PATCH 03/30] fix(governance): verify/merge witnesses with core-cst to fix DRep-vote signature mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRep votes (and any tx with Conway voting_procedures) failed client-side with "Wallet returned witness that does not verify against tx body hash", and would be rejected on-chain (InvalidWitnessesUTXOW). Root cause: the tx is BUILT with core-cst (MeshTxBuilder's default CardanoSDK serializer), so the wallet signs the body hash core-cst produces. But the witness verify + merge used core-csl (whisky) calculateTxHash / Transaction reconstruction, which re-serializes the body to different bytes (voting_procedures map order, set tag 258) → a different hash → valid witnesses fail to verify. The two serializers agree for ordinary txs, so only Conway-vote-shaped txs broke. Move the verify/merge/hash onto core-cst (the 2.0 stack, already a dependency), so build and verify use one encoder and the original body bytes every signer signed are preserved: - mergeSignerWitnesses: verify new witnesses against resolveTxHash(originalTx) and merge via addVKeyWitnessSetToTransaction (preserves body bytes). Drops the body-swap workaround — every co-signer now signs the same stored body. - filterWitnessesToScripts: rebuild the witness set with core-cst so dropping extraneous vkeys no longer re-encodes the body. - diagnoseTxWitnesses + the server signature check in transactions.ts: hash with resolveTxHash so vote signatures are recognised (equal to the old hash for ordinary txs). Verified invariants (tests): adding/filtering vkeys via core-cst preserves resolveTxHash; a witness over resolveTxHash verifies; co-signers accumulate without re-encoding. tsc clean; full suite 362 passed. Follow-up (not in this PR): the server v1 bot path (signTransaction.ts + addUniqueVkeyWitnessToTx) still uses core-csl calculateTxHash and needs the same core-cst migration for bot-submitted votes. Co-Authored-By: Claude Fable 5 --- src/__tests__/mergeSignerWitnesses.test.ts | 196 ++++++++++----------- src/server/api/routers/transactions.ts | 8 +- src/utils/txScriptRecovery.ts | 8 +- src/utils/txSignUtils.ts | 167 +++++++++--------- 4 files changed, 187 insertions(+), 192 deletions(-) diff --git a/src/__tests__/mergeSignerWitnesses.test.ts b/src/__tests__/mergeSignerWitnesses.test.ts index 00e0971f..baed18dc 100644 --- a/src/__tests__/mergeSignerWitnesses.test.ts +++ b/src/__tests__/mergeSignerWitnesses.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "@jest/globals"; -import { csl, calculateTxHash } from "@meshsdk/core-csl"; +import { csl } from "@meshsdk/core-csl"; +import { resolveTxHash } from "@meshsdk/core-cst"; -import { mergeSignerWitnesses } from "@/utils/txSignUtils"; +import { + mergeSignerWitnesses, + filterWitnessesToScripts, +} from "@/utils/txSignUtils"; function buildMinimalTxHex(): string { const inputs = csl.TransactionInputs.new(); @@ -30,78 +34,30 @@ function buildMinimalTxHex(): string { return csl.Transaction.new(body, witnesses, undefined).to_hex(); } -function witnessSetHexFor( - signer: csl.PrivateKey, - bodyHashHex: string, -): string { +// A witness-set-only payload (CIP-30 partial sign) signed over the core-cst +// body hash of `bodyHashHex` — i.e. exactly the bytes the wallet was handed. +function witnessSetHexFor(signer: csl.PrivateKey, bodyHashHex: string): string { const sig = signer.sign(Buffer.from(bodyHashHex, "hex")); const witness = csl.Vkeywitness.new(csl.Vkey.new(signer.to_public()), sig); const witnesses = csl.Vkeywitnesses.new(); witnesses.add(witness); - const witnessSet = csl.TransactionWitnessSet.new(); witnessSet.set_vkeys(witnesses); return witnessSet.to_hex(); } -// Build a full signed Transaction hex whose body bytes differ from -// `originalTxHex` (one input flipped) and whose vkey signature targets the -// wallet's body — the shape a wallet returns when it re-canonicalises CBOR -// before signing. -function fullSignedTxHexWithDifferentBody( - originalTxHex: string, - signer: csl.PrivateKey, -): { hex: string; walletBodyHashHex: string } { - const inputs = csl.TransactionInputs.new(); - inputs.add( - csl.TransactionInput.new( - csl.TransactionHash.from_bytes(Buffer.from("11".repeat(32), "hex")), - 0, - ), - ); - - const orig = csl.Transaction.from_hex(originalTxHex); - const body = csl.TransactionBody.new( - inputs, - orig.body().outputs(), - orig.body().fee(), - undefined, - ); - - // Build a transient tx (no witnesses) just to take its body hash. - const probeHex = csl.Transaction.new( - body, - csl.TransactionWitnessSet.new(), - undefined, - ).to_hex(); - const walletBodyHashHex = calculateTxHash(probeHex); - - const sig = signer.sign(Buffer.from(walletBodyHashHex, "hex")); - const vkeys = csl.Vkeywitnesses.new(); - vkeys.add(csl.Vkeywitness.new(csl.Vkey.new(signer.to_public()), sig)); - const witnessSet = csl.TransactionWitnessSet.new(); - witnessSet.set_vkeys(vkeys); - - // Re-parse the body so it's not consumed by the probe tx above. - return { - hex: csl.Transaction.new( - csl.TransactionBody.from_bytes(body.to_bytes()), - witnessSet, - undefined, - ).to_hex(), - walletBodyHashHex, - }; -} - describe("mergeSignerWitnesses", () => { - it("returns an empty invalidVkeyPubKeysHex when the new vkey verifies", () => { + it("accepts a vkey signed over the core-cst body hash and preserves the body", () => { const txHex = buildMinimalTxHex(); const signer = csl.PrivateKey.generate_ed25519(); - const payload = witnessSetHexFor(signer, calculateTxHash(txHex)); + const payload = witnessSetHexFor(signer, resolveTxHash(txHex)); const result = mergeSignerWitnesses(txHex, payload); expect(result.invalidVkeyPubKeysHex).toEqual([]); + // Body bytes are unchanged: the persisted/submitted hash equals what the + // wallet signed — no node-side InvalidWitnessesUTXOW. + expect(resolveTxHash(result.txHex)).toEqual(resolveTxHash(txHex)); expect( csl.Transaction.from_hex(result.txHex).witness_set().vkeys()?.len(), ).toBe(1); @@ -110,10 +66,7 @@ describe("mergeSignerWitnesses", () => { it("flags a vkey whose signature targets a different body", () => { const txHex = buildMinimalTxHex(); const signer = csl.PrivateKey.generate_ed25519(); - - // Sign a hash that does NOT match the body — simulates a wallet that - // re-canonicalised the CBOR before signing, producing a witness whose - // signature verifies against the wallet's re-encoded body but not ours. + // Signed over a hash that isn't this body's — must be reported, not adopted. const payload = witnessSetHexFor(signer, "ff".repeat(32)); const result = mergeSignerWitnesses(txHex, payload); @@ -122,51 +75,22 @@ describe("mergeSignerWitnesses", () => { .toString("hex") .toLowerCase(); expect(result.invalidVkeyPubKeysHex).toEqual([expectedPubKey]); - - // The merged tx still contains the witness (callers decide what to do); - // we just surface the validity verdict. - expect( - csl.Transaction.from_hex(result.txHex).witness_set().vkeys()?.len(), - ).toBe(1); - }); - - it("adopts the wallet's body when the wallet returned a full signed tx whose body differs and there are no pre-existing witnesses", () => { - // First-signer scenario: the wallet re-canonicalised the body before - // signing. We should use the wallet's body (so the signature verifies) - // and report no invalid vkeys. - const txHex = buildMinimalTxHex(); - const signer = csl.PrivateKey.generate_ed25519(); - const { hex: walletSignedHex, walletBodyHashHex } = - fullSignedTxHexWithDifferentBody(txHex, signer); - - // Sanity: the wallet's body hash is NOT the original body hash. - expect(walletBodyHashHex).not.toEqual(calculateTxHash(txHex)); - - const result = mergeSignerWitnesses(txHex, walletSignedHex); - - expect(result.invalidVkeyPubKeysHex).toEqual([]); - expect(calculateTxHash(result.txHex)).toEqual(walletBodyHashHex); }); - it("does not re-verify witnesses that were already present", () => { + it("merges a co-signer without re-verifying existing witnesses and keeps the body stable", () => { const txHex = buildMinimalTxHex(); const existingSigner = csl.PrivateKey.generate_ed25519(); const newSigner = csl.PrivateKey.generate_ed25519(); - // Pre-seed an "invalid" existing witness (signed against the wrong body). - // mergeSignerWitnesses should not flag it; only newly merged ones. - const wrongHashHex = "ff".repeat(32); - const sig = existingSigner.sign(Buffer.from(wrongHashHex, "hex")); - const existingWitness = csl.Vkeywitness.new( - csl.Vkey.new(existingSigner.to_public()), - sig, - ); + // Pre-seed an existing witness signed against the wrong body. The merge must + // not re-flag it (only newly added witnesses are verified). + const sig = existingSigner.sign(Buffer.from("ff".repeat(32), "hex")); const tx = csl.Transaction.from_hex(txHex); const witnessSet = csl.TransactionWitnessSet.from_bytes( tx.witness_set().to_bytes(), ); const vkeys = csl.Vkeywitnesses.new(); - vkeys.add(existingWitness); + vkeys.add(csl.Vkeywitness.new(csl.Vkey.new(existingSigner.to_public()), sig)); witnessSet.set_vkeys(vkeys); const seededTxHex = csl.Transaction.new( csl.TransactionBody.from_bytes(tx.body().to_bytes()), @@ -174,16 +98,86 @@ describe("mergeSignerWitnesses", () => { tx.auxiliary_data(), ).to_hex(); - // Merge a valid signature from a different signer. - const goodPayload = witnessSetHexFor( - newSigner, - calculateTxHash(seededTxHex), - ); + const goodPayload = witnessSetHexFor(newSigner, resolveTxHash(seededTxHex)); const result = mergeSignerWitnesses(seededTxHex, goodPayload); expect(result.invalidVkeyPubKeysHex).toEqual([]); + expect(resolveTxHash(result.txHex)).toEqual(resolveTxHash(seededTxHex)); expect( csl.Transaction.from_hex(result.txHex).witness_set().vkeys()?.len(), ).toBe(2); }); }); + +describe("filterWitnessesToScripts", () => { + it("drops vkeys not required by the native script while preserving the body", () => { + // Build a tx whose witness set carries a native script requiring signer A, + // plus vkey witnesses from A (required) and B (extraneous). + const A = csl.PrivateKey.generate_ed25519(); + const B = csl.PrivateKey.generate_ed25519(); + + const inputs = csl.TransactionInputs.new(); + inputs.add( + csl.TransactionInput.new( + csl.TransactionHash.from_bytes(Buffer.from("00".repeat(32), "hex")), + 0, + ), + ); + const outputs = csl.TransactionOutputs.new(); + const outAddr = csl.EnterpriseAddress.new( + csl.NetworkInfo.testnet_preview().network_id(), + csl.Credential.from_keyhash(A.to_public().hash()), + ).to_address(); + outputs.add( + csl.TransactionOutput.new(outAddr, csl.Value.new(csl.BigNum.from_str("1000000"))), + ); + const body = csl.TransactionBody.new( + inputs, + outputs, + csl.BigNum.from_str("100000"), + undefined, + ); + + const nativeScript = csl.ScriptPubkey.new(A.to_public().hash()); + const scripts = csl.NativeScripts.new(); + scripts.add(csl.NativeScript.new_script_pubkey(nativeScript)); + + const probeHex = csl.Transaction.new( + csl.TransactionBody.from_bytes(body.to_bytes()), + csl.TransactionWitnessSet.new(), + undefined, + ).to_hex(); + const bodyHash = resolveTxHash(probeHex); + + const vkeys = csl.Vkeywitnesses.new(); + vkeys.add(csl.Vkeywitness.new(csl.Vkey.new(A.to_public()), A.sign(Buffer.from(bodyHash, "hex")))); + vkeys.add(csl.Vkeywitness.new(csl.Vkey.new(B.to_public()), B.sign(Buffer.from(bodyHash, "hex")))); + const witnessSet = csl.TransactionWitnessSet.new(); + witnessSet.set_vkeys(vkeys); + witnessSet.set_native_scripts(scripts); + + const txHex = csl.Transaction.new( + csl.TransactionBody.from_bytes(body.to_bytes()), + witnessSet, + undefined, + ).to_hex(); + + const filtered = filterWitnessesToScripts(txHex); + + // B is dropped, A kept, body unchanged. + expect( + csl.Transaction.from_hex(filtered).witness_set().vkeys()?.len(), + ).toBe(1); + expect(resolveTxHash(filtered)).toEqual(resolveTxHash(txHex)); + const keptPub = csl.Transaction.from_hex(filtered) + .witness_set() + .vkeys()! + .get(0) + .vkey() + .public_key() + .as_bytes(); + expect(Buffer.from(keptPub).toString("hex")).toEqual( + Buffer.from(A.to_public().as_bytes()).toString("hex"), + ); + }); +}); diff --git a/src/server/api/routers/transactions.ts b/src/server/api/routers/transactions.ts index f2bd71a3..bf85643c 100644 --- a/src/server/api/routers/transactions.ts +++ b/src/server/api/routers/transactions.ts @@ -1,6 +1,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { csl, calculateTxHash } from "@meshsdk/core-csl"; +import { csl } from "@meshsdk/core-csl"; +import { resolveTxHash } from "@meshsdk/core-cst"; import { resolvePaymentKeyHash } from "@meshsdk/core"; import { buildMultisigWallet } from "@/utils/common"; import { getProvider } from "@/utils/get-provider"; @@ -248,7 +249,10 @@ export const transactionRouter = createTRPCRouter({ // Check if any signature matches wallet signers let hasValidSignature = false; const signedAddresses: string[] = []; - const txHashHex = calculateTxHash(parsedTx.to_hex()).toLowerCase(); + // Verify signatures against the core-cst body hash (the build serializer), + // so multisig signatures on Conway votes are recognised. Equal to the old + // core-csl hash for ordinary txs. + const txHashHex = resolveTxHash(parsedTx.to_hex()).toLowerCase(); const txHashBytes = Buffer.from(txHashHex, "hex"); for (let i = 0; i < vkeyWitnesses.len(); i++) { diff --git a/src/utils/txScriptRecovery.ts b/src/utils/txScriptRecovery.ts index b9e5f146..45442e75 100644 --- a/src/utils/txScriptRecovery.ts +++ b/src/utils/txScriptRecovery.ts @@ -3,7 +3,8 @@ import { deserializeAddress, serializeNativeScript, } from "@meshsdk/core"; -import { csl, deserializeNativeScript, calculateTxHash } from "@meshsdk/core-csl"; +import { csl, deserializeNativeScript } from "@meshsdk/core-csl"; +import { resolveTxHash } from "@meshsdk/core-cst"; import type { MultisigSubmissionWallet, ScriptRecoveryWallet, @@ -506,7 +507,10 @@ export function diagnoseTxWitnesses(txHex: string): { stale: { pubKeyHex: string; keyHashHex: string }[]; } { const tx = csl.Transaction.from_hex(txHex); - const bodyHash = calculateTxHash(txHex).toLowerCase(); + // Hash with core-cst (the build serializer) so this matches the body hash the + // node computes from the submitted bytes — core-csl's calculateTxHash + // re-serializes the body and would false-flag valid Conway-vote witnesses. + const bodyHash = resolveTxHash(txHex).toLowerCase(); const bodyHashBytes = Buffer.from(bodyHash, "hex"); const vkeys = tx.witness_set().vkeys(); const total = vkeys ? vkeys.len() : 0; diff --git a/src/utils/txSignUtils.ts b/src/utils/txSignUtils.ts index 1c095da1..876f0bed 100644 --- a/src/utils/txSignUtils.ts +++ b/src/utils/txSignUtils.ts @@ -1,9 +1,34 @@ -import { csl, calculateTxHash } from "@meshsdk/core-csl"; +import { csl } from "@meshsdk/core-csl"; +import { + resolveTxHash, + addVKeyWitnessSetToTransaction, + Transaction as CstTransaction, + TxCBOR, + CborSet, + VkeyWitness, +} from "@meshsdk/core-cst"; import { decodeNativeScriptFromCsl, collectSigKeyHashes, } from "@/utils/nativeScriptUtils"; +// The tx is BUILT with core-cst (MeshTxBuilder's default CardanoSDK serializer), +// so the wallet signs the body hash core-cst produces. The old verify path used +// core-csl's calculateTxHash, which re-serializes the body to *different* bytes +// (notably Conway `voting_procedures` map order / set tag 258) — a different +// hash, so valid witnesses failed to verify ("witness does not verify against tx +// body hash"). Everything here now hashes and merges via core-cst so build and +// verify agree, and the original body bytes every signer signed are preserved. +// +// core-cst VkeyWitness.toCore() returns a [pubKeyHex, signatureHex] tuple. +function cstVkeyPubKeyHex(vkw: { toCore: () => unknown }): string { + const core = vkw.toCore() as unknown; + const pub = Array.isArray(core) + ? (core[0] as string) + : ((core as { vkey: string }).vkey); + return String(pub).toLowerCase(); +} + function toKeyHashHex(publicKey: csl.PublicKey): string { return Array.from(publicKey.hash().to_bytes()) .map((byte) => byte.toString(16).padStart(2, "0")) @@ -139,97 +164,59 @@ export function addUniqueVkeyWitnessToTx( }; } -function extractFullSignedTx( - signedPayloadHex: string, -): csl.Transaction | undefined { - try { - return csl.Transaction.from_hex(signedPayloadHex); - } catch { - return undefined; - } -} - export function mergeSignerWitnesses( originalTxHex: string, signedPayloadHex: string, ): { txHex: string; invalidVkeyPubKeysHex: string[] } { const originalTx = csl.Transaction.from_hex(originalTxHex); - const originalBodyBytes = Buffer.from(originalTx.body().to_bytes()); - - // Some wallets re-canonicalise the tx body before signing (notably with Conway - // `voting_procedures`). Their vkey witness then targets *their* body hash, not - // ours. If the wallet returned a full Transaction (vs just a witness set) and - // we have no pre-existing witnesses to invalidate, prefer their body — that's - // what the signature is actually over. - const signedTx = extractFullSignedTx(signedPayloadHex); - const existingVkeysCount = originalTx.witness_set().vkeys()?.len() ?? 0; - let bodyToUse = csl.TransactionBody.from_bytes(originalTx.body().to_bytes()); - if (signedTx) { - const walletBodyBytes = Buffer.from(signedTx.body().to_bytes()); - const bodiesDiffer = !originalBodyBytes.equals(walletBodyBytes); - if (bodiesDiffer && existingVkeysCount === 0) { - // [ballot-witness-diag] First signer: adopt the wallet's re-canonicalised - // body so the signature matches (unchanged behaviour, now logged). - bodyToUse = csl.TransactionBody.from_bytes(signedTx.body().to_bytes()); - console.warn( - "[ballot-witness-diag] wallet re-canonicalised tx body; adopting wallet body (first signer)", - ); - } else if (bodiesDiffer) { - // [ballot-witness-diag] Co-signer: body-swap is skipped because earlier - // witnesses exist, so this signature stays bound to the wallet's encoding - // and may be stale against the stored body we persist/submit. - console.warn( - "[ballot-witness-diag] wallet re-canonicalised tx body but existing witnesses present — co-signer witness may be stale against stored body", - { existingVkeysCount }, - ); - } - } - - const witnessSetClone = csl.TransactionWitnessSet.from_bytes( - originalTx.witness_set().to_bytes(), - ); + // Key hashes already witnessed by earlier co-signers — don't re-verify those. const existingKeyHashes = new Set(); - const existingVkeys = witnessSetClone.vkeys(); + const existingVkeys = originalTx.witness_set().vkeys(); if (existingVkeys) { for (let i = 0; i < existingVkeys.len(); i++) { existingKeyHashes.add(toKeyHashHex(existingVkeys.get(i).vkey().public_key())); } } - const mergedVkeys = cloneVkeyWitnesses(witnessSetClone); - + // The wallet's freshly-added vkey witnesses (it returns a witness-set-only + // payload on partial sign, or a full tx), deduped against existing. const incomingVkeys = extractVkeyWitnesses(signedPayloadHex); - mergeUniqueWitnesses(mergedVkeys, incomingVkeys); - - witnessSetClone.set_vkeys(mergedVkeys); - - const mergedTx = csl.Transaction.new( - bodyToUse, - witnessSetClone, - originalTx.auxiliary_data(), - ); - if (!originalTx.is_valid()) { - mergedTx.set_is_valid(false); + const newVkeys = csl.Vkeywitnesses.new(); + const seen = new Set(); + for (let i = 0; i < incomingVkeys.len(); i++) { + const witness = incomingVkeys.get(i); + const keyHash = toKeyHashHex(witness.vkey().public_key()); + if (existingKeyHashes.has(keyHash) || seen.has(keyHash)) continue; + seen.add(keyHash); + newVkeys.add(witness); } - const txHex = mergedTx.to_hex(); - - // Verify each *newly added* vkey witness against the merged tx body hash. - // Even after the body-swap recovery above, a wallet that returns *only* a - // witness set (no body) can still produce a witness over an encoding we - // don't have. Surface that so callers can abort before persisting. + // Verify each new witness against the core-cst hash of the ORIGINAL body — + // the exact bytes the wallet signed. No more body-swap workaround: with a + // consistent (core-cst) encoder there's nothing to reconcile, and every + // co-signer signs the same stored body. + const bodyHashBytes = Buffer.from(resolveTxHash(originalTxHex), "hex"); const invalidVkeyPubKeysHex: string[] = []; - const bodyHashBytes = Buffer.from(calculateTxHash(txHex), "hex"); - for (let i = 0; i < mergedVkeys.len(); i++) { - const witness = mergedVkeys.get(i); + for (let i = 0; i < newVkeys.len(); i++) { + const witness = newVkeys.get(i); const pubKey = witness.vkey().public_key(); - if (existingKeyHashes.has(toKeyHashHex(pubKey))) continue; if (!pubKey.verify(bodyHashBytes, witness.signature())) { invalidVkeyPubKeysHex.push(toPubKeyHex(pubKey)); } } + // Merge the new witnesses into the original tx WITHOUT re-encoding the body. + // addVKeyWitnessSetToTransaction parses with the same (core-cst) serializer + // used to build the tx and preserves the original body bytes, so the + // persisted/submitted body hash stays equal to what every signer signed. + let txHex = originalTxHex; + if (newVkeys.len() > 0) { + const newWitnessSet = csl.TransactionWitnessSet.new(); + newWitnessSet.set_vkeys(newVkeys); + txHex = addVKeyWitnessSetToTransaction(originalTxHex, newWitnessSet.to_hex()); + } + return { txHex, invalidVkeyPubKeysHex }; } @@ -268,13 +255,14 @@ export function filterWitnessesToScripts(txHex: string): string { return txHex; } - const filteredVkeys = csl.Vkeywitnesses.new(); + // Pub keys (by hex) whose key hash is required by a native script — keep only + // these. Analysis is read-only via core-csl; the rebuild below is core-cst. + const allowedPubKeyHexes = new Set(); let removed = 0; for (let i = 0; i < existingVkeys.len(); i++) { - const w = existingVkeys.get(i); - const kh = toKeyHashHex(w.vkey().public_key()); - if (allowedKeyHashes.has(kh)) { - filteredVkeys.add(w); + const pub = existingVkeys.get(i).vkey().public_key(); + if (allowedKeyHashes.has(toKeyHashHex(pub))) { + allowedPubKeyHexes.add(toPubKeyHex(pub)); } else { removed += 1; } @@ -284,21 +272,26 @@ export function filterWitnessesToScripts(txHex: string): string { return txHex; } - const witnessSetClone = csl.TransactionWitnessSet.from_bytes( - witnessSet.to_bytes(), + // Drop the extraneous vkeys WITHOUT re-encoding the body: rebuild the witness + // set with core-cst, which preserves the original body bytes (so the + // remaining witnesses stay valid against the submitted body hash). + const cstTx = CstTransaction.fromCbor(TxCBOR(txHex)); + const cstWitnessSet = cstTx.witnessSet(); + const cstVkeys = cstWitnessSet.vkeys(); + if (!cstVkeys) { + return txHex; + } + const keptVkeys = [...cstVkeys.values()].filter((vkw) => + allowedPubKeyHexes.has(cstVkeyPubKeyHex(vkw)), ); - witnessSetClone.set_vkeys(filteredVkeys); - - const filteredTx = csl.Transaction.new( - csl.TransactionBody.from_bytes(tx.body().to_bytes()), - witnessSetClone, - tx.auxiliary_data(), + cstWitnessSet.setVkeys( + CborSet.fromCore( + keptVkeys.map((vkw) => vkw.toCore()), + VkeyWitness.fromCore, + ), ); - if (!tx.is_valid()) { - filteredTx.set_is_valid(false); - } - - return filteredTx.to_hex(); + cstTx.setWitnessSet(cstWitnessSet); + return cstTx.toCbor(); } export { From 9c81ba141544b84ec8076aad813464a6ecbff2d0 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Sat, 13 Jun 2026 12:08:46 +0200 Subject: [PATCH 04/30] feat(mobile): viewport-fit, dynamic viewport height, and safe-area insets Foundational mobile fixes (PR 1 of the UX/mobile quick-wins pass): - viewport meta gains viewport-fit=cover so env(safe-area-inset-*) resolves (without it the insets are always 0). - Full-height containers use 100dvh instead of 100vh/h-screen so the layout isn't clipped by mobile-Safari / wallet-webview dynamic toolbars (_app, layout root + inner content column). - Main header grows by the safe-area top inset on mobile so it clears the notch/status bar, and honors side insets in landscape. - Mobile nav drawer offsets by the safe-area top, uses 100dvh, and pads the bottom for the home indicator. - Bottom Sheet variant pads the bottom safe area. Desktop is unchanged (insets are 0; header rule is scoped below md). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/common/overall-layout/layout.tsx | 4 ++-- src/components/ui/metatags.tsx | 5 ++++- src/components/ui/mobile-navigation.tsx | 8 ++++++-- src/components/ui/sheet.tsx | 2 +- src/pages/_app.tsx | 2 +- src/styles/globals.css | 13 +++++++++++++ 6 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index 8b86a74f..c8039084 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -520,7 +520,7 @@ export default function RootLayout({ const walletIdForMenu = useMemo(() => (router.query.wallet as string) || lastVisitedWalletId || undefined, [router.query.wallet, lastVisitedWalletId]); return ( -
+
{/* Skip link for keyboard users */}