Skip to content

feat(cashu): C4 — escrow primitives (2-of-3 NUT-11 lock, sign, redeem, reclaim)#236

Open
grunch wants to merge 6 commits into
feat/cashu-c2-wallet-corefrom
feat/cashu-c4-escrow-primitives
Open

feat(cashu): C4 — escrow primitives (2-of-3 NUT-11 lock, sign, redeem, reclaim)#236
grunch wants to merge 6 commits into
feat/cashu-c2-wallet-corefrom
feat/cashu-c4-escrow-primitives

Conversation

@grunch

@grunch grunch commented Jul 25, 2026

Copy link
Copy Markdown
Member

Phase C4 of docs/cashu/README.md — the cryptographic heart of Cashu-mode
trading. No UI, no bridge surface, so a review can be about correctness alone.

Stacked on #235 (C2). Base is feat/cashu-c2-wallet-core; GitHub will
retarget to main when C2 merges. The escrow operations hang off the wallet
that phase introduced.

What it does

The escrow is one NUT-11 P2PK secret per proof, locked 2-of-3 (§2 of the doc):

data          = P_S                (seller)
pubkeys       = [P_B, P_M]         (buyer, Mostro)
n_sigs        = 2                  (any two of the three)
sigflag       = SIG_INPUTS
locktime      = now + escrow_locktime_days
refund        = [P_S]              (seller alone, after locktime)
n_sigs_refund = 1
  • xonly_to_cashu_pubkey — the 02-prefix mapping the daemon applies in
    cashu_pubkey_from_xonly_hex. Anything that is not exactly 64 hex characters
    is rejected rather than padded or truncated: a silently reinterpreted key
    locks funds to nobody, and nothing downstream would notice.
  • escrow_conditions / fee_conditions — the 2-of-3 above, and a plain
    1-of-1 to Mostro for the fee. The fee is a payment, not an escrow; giving it
    the escrow's conditions would make it unspendable for the node.
  • verify_escrow_token — the client-side mirror of the daemon's composite
    check, run per proof. A token whose first proof is correct and whose
    second is locked to the builder alone would pass a spot check and walk away
    with the difference. Also checks mint and total amount.
  • sign_proofs / combine_and_redeem / reclaim_after_locktime — one
    signature per proof, keyed by that proof's secret, matching the wire form C0
    pinned. A missing peer signature fails before the mint is contacted, so a
    half-signed spend is never attempted.

The spike (docs/cashu/cdk-spike.md, in #235) established that cdk exposes
NUT-11 with custom tags in full — so none of this hand-builds secrets or witness
encodings, which is what kept the phase to this size.

Tests

Unit (run in CI): the 02 mapping against a fresh key; five malformed-key
shapes rejected, including a 33-byte compressed key passed where x-only was
expected; the built condition asserted field by field against §2, so a wrong
default cannot hide behind a passing round trip; a past locktime refused at
construction; and verification refused for every way an escrow can be wrong —
wrong seller key, n_sigs=1, an extra refund key, a bare P2PK, a missing
counterparty, a locktime shorter than required.

Integration (#[ignore], needs MOSTRO_TEST_MINT_URL) — the "done when" of
the phase:

  • full lock → verify → sign → combine → redeem against a real mint;
  • one signature alone cannot move an escrow (the seller's premature reclaim is
    refused by the mint);
  • an impostor's signature does not settle one;
  • a short-changed escrow fails verification before anything is submitted.
docker run -p 3338:3338 cashubtc/nutshell:latest poetry run mint
MOSTRO_TEST_MINT_URL=http://localhost:3338 cargo test -- --ignored

Verification

cargo test                                  149 passed, 14 ignored
cargo clippy --locked -- -D warnings        clean
cargo check --target wasm32-unknown-unknown clean
flutter analyze                             no issues

No frb-generate needed — this phase adds nothing under rust/src/api/.

Test plan

  • Run the ignored suite against a funded nutshell wallet; all four pass.
  • Confirm the compressed keys this produces match the daemon's for the same
    x-only input (compare against cashu_pubkey_from_xonly_hex on the Track A
    branch).
  • Nothing in a Lightning-mode build reaches this module — it has no callers
    until C5.

Next: C5 wires this into the take flow.


Manual verification

This PR is pure cryptography with no UI, so the verification that matters is
the integration suite — and it now genuinely runs (it could not before; see the
last commit). The security properties are the point: verify those four tests
fail if you break the code
, not just that they pass.

Already run on this branch

cargo test                                  152 passed, 14 ignored
cargo clippy --locked -- -D warnings        clean
cargo check --target wasm32-unknown-unknown clean
flutter analyze                             no issues

Plus the integration suite against a real mint — 8/8, twice in a row
(nutshell 0.20.3).

A · Integration suite against a live mint

docker run -d --name nutshell -p 3338:3338 \
  -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 \
  -e MINT_BACKEND_BOLT11_SAT=FakeWallet \
  -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY \
  -e MINT_RATE_LIMIT=FALSE \
  cashubtc/nutshell:latest poetry run mint

cd rust
MOSTRO_TEST_MINT_URL=http://localhost:3338 \
  cargo test --lib -- --ignored --test-threads=1 cashu::

Expect 8 passed, and 8 again on a second run. MINT_RATE_LIMIT=FALSE is
required for the second run (nutshell rate limits by default).

Four of those eight are the security properties, against a real mint with real
blind signatures:

Test Proves
an_escrow_locks_and_settles_with_two_of_three_signatures the happy path works end to end: lock → verify → seller signs → buyer combines and redeems
one_signature_is_not_enough_to_move_an_escrow the seller cannot take the escrow back alone before the locktime
a_signature_from_the_wrong_key_does_not_settle_an_escrow an impostor's signature plus a real one does not satisfy the 2-of-3
an_escrow_for_the_wrong_amount_fails_verification a short-changed escrow is caught before anything is submitted

B · Confirm the fix that this PR's review found

The verifier used to accept extra pubkeys, which let a seller add a key they
also control and spend the escrow alone. To convince yourself it is closed:

cd rust
cargo test --lib verification_rejects_every_way_an_escrow_can_be_wrong

Then break it deliberately — in rust/src/cashu/escrow.rs, delete the
pubkeys.len() != 2 check — and re-run. The test must fail on the
"extra pubkey" row. Put it back.

Do the same with three_parties_must_be_three_different_keys by removing the
ensure_distinct() call in escrow_conditions.

C · Key encoding against the daemon

The 02-prefix mapping must match the daemon's cashu_pubkey_from_xonly_hex
exactly, or the escrow locks to a key nobody holds. cargo test --lib an_xonly_key_maps_to_the_daemons_compressed_form covers the shape; if you have
the daemon's Track A branch checked out, feed the same x-only hex to both and
compare the output byte for byte.

D · Nothing else moved

This PR adds a module and two crate-internal accessors on the wallet. It has
no callers until C5, so a Lightning build cannot reach any of it. The
regression check is simply that cargo test and the app behave as on the base
branch.

…, reclaim)

Phase C4 of docs/cashu/README.md, the cryptographic heart of Cashu-mode
trading. No UI and no bridge surface, so review can be about correctness alone.

Stacked on C2 (#235) — the escrow methods hang off the wallet from that phase.

`rust/src/cashu/escrow.rs`:
- `xonly_to_cashu_pubkey` — the `02`-prefix mapping the daemon applies in
  `cashu_pubkey_from_xonly_hex`. Rejects anything that is not exactly 64 hex
  chars instead of padding or truncating: a silently reinterpreted key locks
  funds to nobody, and nothing downstream would notice.
- `escrow_conditions` — the 2-of-3 of §2: data = P_S, pubkeys = [P_B, P_M],
  n_sigs = 2, SIG_INPUTS, locktime, refund = [P_S], n_sigs_refund = 1.
- `fee_conditions` — 1-of-1 to Mostro. The fee is a payment, not an escrow;
  conditions would make it unspendable for the node.
- `verify_conditions` / `verify_escrow_token` — the client-side mirror of the
  daemon's composite check, run *per proof*: a token whose first proof is
  correct and whose second is locked to the builder alone would pass a spot
  check and walk away with the difference.
- `sign_proofs`, `combine_and_redeem`, `reclaim_after_locktime` — one signature
  per proof keyed by that proof's secret, matching the wire form C0 pinned. A
  missing peer signature fails before the mint is contacted.

The spike (docs/cashu/cdk-spike.md) established that cdk exposes NUT-11 with
custom tags in full, so none of this hand-builds secrets or witness encodings.

Tests
- Unit: the `02` mapping against a fresh key; five malformed-key shapes
  rejected; the built condition checked field by field against §2 (a wrong
  default cannot hide behind a passing round trip); a past locktime refused at
  construction; and verification refused for every way an escrow can be wrong —
  wrong seller, n_sigs=1, an extra refund key, a bare P2PK, a missing
  counterparty, a locktime shorter than required.
- Integration (`#[ignore]`, MOSTRO_TEST_MINT_URL): full lock → verify → sign →
  combine → redeem against a real mint; one signature alone cannot move an
  escrow; an impostor's signature does not settle one; a short-changed escrow
  fails verification.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@grunch, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a1a16697-76dc-4f93-b61d-0f54190da60c

📥 Commits

Reviewing files that changed from the base of the PR and between 529a5a7 and 60453c2.

📒 Files selected for processing (3)
  • rust/src/cashu/escrow.rs
  • rust/src/cashu/mod.rs
  • rust/src/cashu/wallet.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cashu-c4-escrow-primitives

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@grunch

grunch commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

Review — C4 (strict pass)

Note: GitHub does not allow Request changes on your own PR, so this is posted as a comment. Treat it as 🔴 Request changes — blocking. Finding 1 is a spendability bug in the verifier that the buyer's safety depends on, and the test suite in this PR asserts around it without catching it.

Scope reviewed: rust/src/cashu/escrow.rs, rust/src/cashu/wallet.rs (accessors), rust/src/cashu/mod.rs.


🛑 Critical

1. verify_conditions accepts extra pubkeys — the 2-of-3 is not verified to be a 2-of-3rust/src/cashu/escrow.rs:146-152

if !pubkeys.contains(&parties.buyer) || !pubkeys.contains(&parties.mostro) {
    bail!("InvalidEscrowToken: buyer or Mostro key missing");
}

This checks the two keys are present. It never checks nothing else is.

A seller can build an escrow with pubkeys = [P_B, P_M, P_attacker], where P_attacker is a key they also control. data = P_S, n_sigs = 2, so the condition is satisfied by any two of {P_S, P_B, P_M, P_attacker} — and the seller holds two of them. They can drain the escrow unilaterally the moment it is funded, and this verifier says the token is good.

Every other field is exact-matched, including refund_keys (keys == &vec![parties.seller]), which makes the omission look deliberate on a read. It should be the same shape:

if pubkeys.len() != 2
    || !pubkeys.contains(&parties.buyer)
    || !pubkeys.contains(&parties.mostro)
{
    bail!("InvalidEscrowToken: pubkeys must be exactly [buyer, mostro]");
}

Note verification_rejects_every_way_an_escrow_can_be_wrong covers the missing-key case and the extra-refund-key case — the extra pubkey case is the one hole in an otherwise thorough table, which is what let it through. Add it to that table.


🔴 Major

2. No currency-unit checkverify_escrow_token()

token.value() sums proof amounts irrespective of unit. A token denominated in something other than sat with the right numeric total passes expected_amount. cdk's receive enforces the unit later, so this is not exploitable end to end today — but this function is documented as the pre-submit mirror of the daemon's check, and the daemon does bind the unit. Add token.unit() against CurrencyUnit::Sat next to the mint check.

3. No distinctness check on the three partiesescrow_conditions() / verify_conditions()

Nothing rejects buyer == seller, mostro == seller, or buyer == mostro. A degenerate set collapses the 2-of-3 into something weaker, and whether one signature can satisfy n_sigs = 2 under a duplicated key is mint-implementation-defined — which is exactly the kind of thing not to leave to the mint. Reject at construction and in verification.

4. Reserved-proof leak on a failed confirmbuild_locked_token()

Same defect as create_token in #235, and it matters more here: the amount is the whole escrow, not a user-chosen send. A confirm failure leaves the escrow amount reserved and invisible, and C5 then reports the wallet as short. Revoke on failure.


🟡 Minor

5. combine_and_redeem matches peer signatures by first hit — if a token ever carried two proofs with the same secret, the second would get the first's signature and the swap would fail at the mint with an opaque error. Secrets are unique in practice; a HashMap<&str, &ProofSignature> built once would be both clearer and O(n) instead of O(n²).

6. reclaim_after_locktime cannot detect "too early" locally — it signs and submits, and relies on the mint to refuse. The comment says so, which is honest, but the token's own locktime is right there and comparing it to now would turn a confusing mint error into a precise one.


✅ What is right

  • Per-proof verification rather than per-token, with the reasoning stated (a token whose first proof is correct and whose second is not). That is the non-obvious half of this file and it is right.
  • Rejecting a key that is not exactly 64 hex chars instead of padding or truncating — including the 33-byte compressed key passed where x-only was expected, which is the mistake a caller will actually make.
  • escrow_conditions asserted field by field in the tests rather than through a round trip, so a wrong default cannot hide.
  • Refusing a past locktime at construction: an already-refundable escrow is worthless to the buyer and should never exist.
  • The integration tests test the security properties (one signature is not enough; an impostor's signature does not settle), not just the happy path.

Test gaps

  • The extra-pubkey case (finding 1) — the single most important missing row in verification_rejects_every_way_an_escrow_can_be_wrong.
  • No unit-mismatch case.
  • No degenerate-parties case.
  • sign_proofs has no unit test at all; its output shape (one entry per proof, keyed by that proof's secret) is a wire contract with the daemon and is currently only exercised inside the ignored integration tests.

grunch added 2 commits July 25, 2026 09:03
…-of-3

Addresses the strict review on #236.

Critical
- `verify_conditions` checked that the buyer and Mostro keys were *present* in
  `pubkeys`, never that nothing else was. A seller could lock with
  `pubkeys = [P_B, P_M, P_attacker]` where they hold the extra key: with
  `data = P_S` and `n_sigs = 2`, seller + attacker is two of four, and the
  escrow is spendable unilaterally the moment it is funded — while this
  verifier called the token good. Now matched exactly, like every other field.
  The test table gained the row that was missing, which is what let it through.

Major
- No currency-unit check. `Token::value()` sums proof amounts irrespective of
  denomination, so a token in another unit with the right numeric total passed
  the amount check. Unit is now asserted before the amount.
- Nothing rejected `buyer == seller`, `seller == mostro` or `buyer == mostro`.
  A duplicate collapses the threshold, and whether one signature then satisfies
  `n_sigs = 2` is mint-implementation-defined. `EscrowParties::ensure_distinct`
  runs at construction and again in verification.
- `build_locked_token` leaked the whole escrow amount as reserved proofs when
  `confirm` failed (it consumes the handle, so nothing else could release
  them). It now reclaims and reports how much came back.

Minor
- `combine_and_redeem` scanned the peer signatures per proof; indexed by secret
  once instead.
- `reclaim_after_locktime` relied on the mint to refuse a premature spend. The
  locktime is in the secret, so it now says "the refund path opens in N
  seconds" instead of surfacing an opaque mint error.

Tests: the extra-pubkey case, the three degenerate-party cases, and a
wrong-key-with-right-count case that keeps the membership check honest now that
the count check fires first.
@grunch

grunch commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

grunch added 3 commits July 25, 2026 12:11
…mint

Same treatment as C2, applied to the escrow suite once a real nutshell showed
the tests could not pass at all, and then could not pass twice.

- The four funds-using tests asserted "fund the seller wallet first" and
  returned. They now fund themselves through `mint_for_test`, so a reviewer can
  actually run them.
- Fixed seeds plus a fresh DB replay NUT-13 blinding secrets; the mint answers
  "Blinded Message is already signed" on the second run. Seeds are unique per
  process and per call now.
- The settlement test pinned the redeemed amount and the seller's balance to the
  face value. nutshell's default keyset charges a swap fee, so redeeming 16 sat
  yields less and locking costs more. The face value is what a validator checks;
  the assertions bound the rest.
- `one_signature_is_not_enough_to_move_an_escrow` expected the *mint* to refuse
  a premature reclaim. Since the local locktime check added in this PR, the
  client refuses first with the time remaining — a better message for the same
  property. Either refusal is accepted.

Verified 8/8 against nutshell 0.20.3, twice in a row (MINT_RATE_LIMIT=FALSE —
the mint rate limits by default).
@grunch

grunch commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai Review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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