Skip to content

feat: V2 unpoisonable swaps and recovery (internal review) - #2

Closed
SatsAndSports wants to merge 6 commits into
feat/sigall-v1from
feat/unpoisonable-sigall
Closed

SatsAndSports wants to merge 6 commits into
feat/sigall-v1from
feat/unpoisonable-sigall

Conversation

@SatsAndSports

@SatsAndSports SatsAndSports commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Purpose and review base

Internal review of the first working V2 unpoisonable swap implementation.

This PR targets SatsAndSports/cdk:feat/sigall-v1, our copy of Rob Woodgate's length-framed SIG_ALL branch at 8064df40. It builds on cashubtc/cdk#2232 and cashubtc/nuts#404, so the diff contains our additions rather than Rob's framing work.

Proposal: Unpoisonable Blinded Outputs.

Implemented

Authorization and tweak derivation

  • Optional unpoisonable: true on the swap request; absent/false retains ordinary processing.
  • Existing SIG_ALL spending conditions are required. Ordinary swaps retain both accepted signature formats from Rob's PR; unpoisonable swaps accept only the length-framed digest tagged Cashu_SigAllUnpoisonable_v1.
  • Authorization commits to the original outputs. Ordinary and unpoisonable signatures cannot be mixed to satisfy the unpoisonable threshold.
  • Generate a private, fresh 32-byte R, then derive one shared nonzero scalar t using BIP-340-style tagged SHA-256 over R || uint32_be(counter), with tag Cashu_UnpoisonableTweak_secp256k1_v1 and rejection sampling starting at counter zero.
  • Derive every V2 execution point additively as B' = B + t*G. Reject duplicate original points using CDK's existing validation.

Existing saga integration

  • Reuse the existing swap saga, signing path, compensation, and recovery mechanisms.
  • TX1: reserve inputs and tweaked output points. Original output points are not checked against previously signed/reserved database records.
  • Retry output conflicts or a point-at-infinity result with fresh R, up to 16 attempts. Input failures and other errors remain terminal.
  • Signing: sign the reserved tweaked points using the existing blind-signing/DLEQ implementation.
  • TX2: store signatures, update input state and R in one batched proof UPDATE, and record completion in the same transaction.
  • Return success and publish spent notifications only after TX2 commits. No R is persisted during setup or exposed for pending inputs.

Storage and recovery

  • SQLite and PostgreSQL migrations add nullable proof.unpoisonable_r with a 32-byte/spent-state constraint. Ordinary records retain NULL; the output/signature table is unchanged.
  • R is carried in memory until finalization. Incomplete swaps use existing compensation on recovery.
  • Successful swap responses and NUT-07 state entries expose optional unpoisonable_r as 64 hex characters. Absent fields are omitted.
  • Live spent notifications and subscription snapshots include committed R; the FFI proof-state representation preserves it.
  • Existing NUT-09 restoration retrieves signatures by the reconstructed tweaked points.

Tests and validation

Implemented coverage includes:

  • Signature-mode separation, SIG_ALL requirement, and multisig threshold behavior.
  • Fixed tweak vector, signing linearity, and output ordering.
  • A complete mint-method flow that succeeds with a previously signed original output, obtains R from NUT-07, restores through NUT-09, verifies DLEQ against the tweaked pair, removes the additive tweak, preserves the unchanged DLEQ using r + t as the effective blinding scalar, and spends the resulting ordinary proof.
  • Deterministically forced output conflicts, point-at-infinity retry, retry exhaustion, and no retry of spent-input failures.
  • No R while pending or before finalization; signing failure; rollback after writing R; incomplete-saga cleanup; subscription snapshots.
  • Batched storage, redeemed-amount accounting, incomplete-batch rejection, rollback, and overwrite prevention on both database backends.
  • SQLite migration with existing proofs, database reopening, constraints, and representative old named-column reads.

Checks passed during implementation:

cargo fmt --all -- --check
cargo test -p cashu --features mint,wallet
cargo test -p cdk --lib
cargo test -p cdk-sqlite --lib
# With CDK_MINTD_DATABASE_URL pointing to a disposable PostgreSQL instance:
cargo test -p cdk-postgres --lib
cargo clippy -p cashu -p cdk-common -p cdk-sql-common -p cdk-sqlite -p cdk-postgres -p cdk --all-targets -- -D warnings
cargo clippy -p cdk-ffi --all-targets -- -D warnings
git diff --check

Latest full database test runs: 143 SQLite and 135 PostgreSQL tests passed. After the additive-tweak update, 627 CDK library tests, 585 cashu unit tests, and 13 cashu doctests passed, with 2 pre-existing ignored cashu tests. PostgreSQL tests ran against a local disposable PostgreSQL 17 container. The storage-only commit was also checked independently in an isolated checkout.

Remaining work

  • Full successful swap → stop/drop mint → reopen the file-backed database → recover and spend through a fresh mint instance. Current reopening and incomplete-saga tests do not replace this end-to-end restart test.
  • Focused unpoisonable HTLC receiver/refund tests, including negative cases.
  • Live subscription test asserting PENDING has no R and SPENT-with-R follows commit; current added subscription test exercises snapshots.
  • Runtime compatibility checks for older CDK and cashu-ts parsers receiving the additional NUT-07 field. Source inspection indicates they tolerate it.
  • Small Rust reference client exercising swap/checkstate/restore/spend over HTTP, including deliberately discarded swap responses. Current end-to-end tests call mint methods directly.
  • Broader interoperability vectors and equivalent Nutshell implementation.
  • Review and document the experimental wire format and capability advertisement before proposing upstream integration.

Scope is V2 swaps. General-wallet opt-in/recovery UX, melts, and V3 support are future work. Tweaked DLEQ proofs are verified before recovery and can remain transferable NUT-12 proofs by recording r + t as the effective V2 blinding scalar.

The additive migration tolerates representative older named-column queries, but full downgrade testing with an older mint binary remains to be done. Older code cannot serve R for recovery even when it tolerates the new column.

Commit guide

  1. 0f18819a: authorization and initial rejection guard.
  2. ac7c284c: tweak derivation and preparation.
  3. 1524e880: migrations and storage support.
  4. 66b04aa0: executable saga path and recovery responses/tests (replaces the temporary guard).
  5. cb3caf78: batch the spent-state and R write together.
  6. 14e25f36: switch V2 to one shared tagged-hash scalar and additive point tweaks.

@SatsAndSports

Copy link
Copy Markdown
Owner Author

Superseded by #3, which targets main directly and keeps ordinary SIG_ALL behavior unchanged. Review and follow-up discussion should continue there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant