Skip to content

feat(cashu): C5 — seller escrow lock flow (Track A) - #238

Open
grunch wants to merge 7 commits into
feat/cashu-c3-wallet-uifrom
feat/cashu-c5-escrow-lock-flow
Open

feat(cashu): C5 — seller escrow lock flow (Track A)#238
grunch wants to merge 7 commits into
feat/cashu-c3-wallet-uifrom
feat/cashu-c5-escrow-lock-flow

Conversation

@grunch

@grunch grunch commented Jul 25, 2026

Copy link
Copy Markdown
Member

Phase C5 of docs/cashu/README.md — the Cashu replacement for "the seller
pays the hold invoice", and the first end-to-end flow.

Stacked. Base feat/cashu-c3-wallet-ui (#237) → #236#235, with #234
merged in. Merge order: #234, #235, #236, #237, then this.

Read the daemon, didn't guess it

The plan flagged the escrow request's exact shape as unconfirmed. It is now
confirmed against the daemon's own branches (feat/cashu-ta2-take-flow,
feat/cashu-ta1f-fee-token):

  • the request is Action::WaitingSellerToPay + Payload::Order, status
    WaitingPayment, both trade pubkeys, no buyer invoice — so it is
    classified by payload shape, not by a new action;
  • the escrow token must be worth exactly order.amount;
  • the fee token is 2 * order.fee, where order.fee = round(fee * amount / 2).
    The daemon rounds the half, so doubling the rounded half is the only
    expression that agrees with it — round(fee * amount) differs by a satoshi on
    half of all amounts, and every one of those would be rejected;
  • the proof's stated pubkeys must equal the order's trade keys, which the daemon
    re-derives rather than trusting.

Rust

  • mostro/node_fee.rs — the node's advertised fee, read from the same 38385
    fetch that already yields PoW and the escrow mode, cleared on node switch. A
    malformed fee is discarded rather than stored: a wrong fee surfaces much later
    as an unexplained lock failure.

  • mostro/actions.rsadd_cashu_escrow, beside add_invoice.

  • api/cashu.rscashu_escrow_quote (amount, fee, total, balance, mint,
    locktime, all shown before the seller commits) and lock_escrow:

    1. refuse below amount + fee;
    2. build the escrow token, then verify it with the same check the daemon
      runs
      — catching a mistake here means the seller learns before publishing,
      not after;
    3. build the fee token when the node charges one;
    4. publish, then persist.

    Persistence deliberately follows the mint swap and happens even if the
    publish fails
    : the ecash is already committed by then, and a token we did
    not record is money we cannot find again. The daemon's handler is idempotent
    on re-submission, which is what makes that safe.

  • TradeInfo gains cashu_mint_url / cashu_escrow_token /
    cashu_locked_at, #[serde(default)] so rows written by older builds load.

  • A node switch now also drops the fee and disconnects the wallet, which was
    bound to the previous node's mint.

Dart

lock_escrow_screen.dart — the Cashu sibling of the pay-invoice screen: escrow,
fee and total against the balance, the mint, and when the seller can reclaim
unilaterally if Mostro vanishes. A short balance offers "fund your wallet"
rather than a failure. The take flow branches on isCashuAvailable: the buyer
goes straight to the trade (there is no invoice step in Cashu mode), the seller
to the lock screen. Strings in all five locales; unknown markers fall back.

A real bug this phase surfaced

api::escrow and api::cashu serialized the same globals with two different
mutexes. That only fails under parallel execution and reads as flakiness. Fixed
properly: one global, one lock (escrow_mode::test_lock).

Verification

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

./scripts/frb-generate.sh run after the rust/src/api/ changes. The wasm stub
grew the escrow surface so the bridge stays one codebase; it still refuses with
CashuUnsupportedOnWeb.

Test plan

  • Lightning node: take flow unchanged, no lock screen, lock_escrow
    returns CashuNotEnabled.
  • Cashu node + nutshell, funded wallet: take a buy order as seller → lock
    screen shows amount/fee/total; confirm; daemon accepts and the buyer is
    told to send fiat.
  • Verify the fee against the daemon's stored order.fee on an amount
    where the halves round differently (e.g. 500 sat at 0.6%) — this is the
    single most likely thing to be wrong.
  • Empty wallet: "fund your wallet" leads to the C3 wallet screen.
  • Kill the app between the mint swap and the publish: the token is still
    recorded against the trade, and re-submitting is accepted (the daemon's
    replay path).

Remaining: C6 (release), C7 (cooperative cancel), C8 (disputes), C9 (web
storage), C10 (resilience/backup).


Manual verification

Be honest about the ceiling here: the escrow lock cannot be exercised
end to end without a mostrod running Cashu mode, and no public node does. So
this PR splits into what can be proven today (the crypto, the arithmetic, the
gate, the Lightning regression) and what must wait for the daemon (an accepted
AddCashuEscrow). Both are listed, and the second is the reviewer's real risk.

Already run on this branch

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

Plus the integration suite against a real mint — 8/8, twice in a row
(nutshell 0.20.3), covering the escrow primitives this flow calls into.

A · The fee arithmetic — check this first

This is the single most likely thing to be wrong in production, because a
one-satoshi disagreement with the daemon is a rejection with no useful message.

cd rust
cargo test --lib node_fee

The rule is 2 * round(fee * amount / 2) — the daemon rounds the half, so
doubling the rounded half is the only expression that agrees with it.
the_total_fee_is_twice_the_rounded_half pins 500 sat at 0.6%, where the naive
round(fee * amount) gives 3 and the correct answer is 4.

Cross-check against a daemon if you can: create an order for 500 sat on a
node charging 0.6%, read order.fee from its database, and confirm the app's
quote shows exactly 2 × order.fee.

B · The critical fix from review — party derivation

The first version took the buyer key from counterparty_pubkey, which is empty
for a maker seller and the order-book key for a taker. Both produce an escrow
the buyer cannot spend, and the taker case swaps the funds before the daemon
rejects it.

cargo test --lib the_buyer_key_comes_from_the_escrow_request_not_the_order_book
cargo test --lib the_locktime_clears_the_daemons_floor

To convince yourself the plumbing is real rather than just the test: in
rust/src/api/cashu.rs, temporarily change trade.buyer_trade_pubkey back to
trade.counterparty_pubkey and run the lock flow (section D) — it must fail
with CashuEscrowRequestMissing on a maker, rather than building a token.

C · Lightning regression — the part that must not move

Run against the default (Lightning) node, with the developer override off.

  1. Take a buy order as the seller → you land on the hold invoice screen,
    exactly as on main. Not the escrow screen.
  2. Take a sell order as the buyer → the add-invoice / LN-address flow is
    unchanged.
  3. Complete a trade end to end. Nothing in this PR is reachable.
  4. Take an order within a second or two of app launch. This is the specific
    regression the review found: the routing gate used to be read before it had
    resolved, which could send a seller to the wrong screen. It must land on the
    hold invoice every time.

D · The escrow screen (override + local mint)

Setup is the same as #237 (nutshell + the developer override + mint URL), and
you will need a funded app wallet — fund it through the C3 wallet screen with a
token from docker exec nutshell poetry run cashu --host http://localhost:3338 send 64.

  1. Take a buy order as the seller with the override on → you land on
    Lock the escrow instead of the hold invoice screen.
  2. The screen states escrow, Mostro fee, total, your balance,
    the mint and when you can reclaim it. Check the total is
    amount + fee and that the fee matches section A.
  3. With an empty wallet: the button reads Fund your wallet and takes you
    to the C3 wallet screen — not a failure message.
  4. With funds: tapping Lock escrow swaps at the mint. Against a
    Lightning-only daemon the submission will be rejected — that is expected,
    and the interesting part is what the screen does: it must show a Retry
    sending
    button and the "locked but the node has not confirmed" note,
    because the funds have already moved and the daemon's handler is idempotent.
  5. Confirm the funds really moved: the wallet balance drops by roughly
    amount + fee (slightly more — the mint charges a swap fee, see below).
  6. Failures before the swap must not offer a retry. Turn the override off
    and open the escrow screen for a trade: you get a localized message and
    no retry button, because nothing moved.

E · Still needs a Cashu daemon

Everything below is unverifiable today and is where the remaining risk sits:

  • an AddCashuEscrow the daemon accepts;
  • the buyer receiving cashu-escrow-locked and being told to send fiat;
  • the locktime clearing the daemon's floor in practice (the margin is reasoned
    and unit-tested, not observed);
  • the fee token matching the daemon's stored order.fee on a live order.

When testing against the daemon's Track A branch, watch for InvalidCashuToken
and InvalidMintUrl — those are the two rejections this flow can still earn.

Known limitation

A mint that charges swap fees makes locking cost slightly more than
amount + mostro_fee, and cashu_escrow_quote does not account for that. A
seller holding exactly the total can fail at the mint. Flagged rather than
fixed here; it wants a decision about whether the quote should pad or the UI
should warn.

Phase C5 of docs/cashu/README.md: the Cashu replacement for "the seller pays
the hold invoice". Verified against the daemon's own Track A branches
(feat/cashu-ta2-take-flow, feat/cashu-ta1f-fee-token) rather than inferred.

What the daemon actually does, confirmed by reading it:
- the escrow request is `Action::WaitingSellerToPay` + `Payload::Order` with
  status WaitingPayment, both trade pubkeys and no buyer invoice;
- the escrow token must be worth *exactly* order.amount;
- the fee token is `2 * order.fee`, where order.fee is `round(fee * amount / 2)`
  — the daemon rounds the half, so doubling the rounded half is the only
  expression that agrees with it, and a satoshi of disagreement is a rejection;
- the proof's stated pubkeys must equal the order's trade keys, which the
  daemon re-derives rather than trusting.

Rust
- `mostro/node_fee.rs`: the node's advertised fee, read from the same 38385
  fetch as PoW and the escrow mode, cleared on node switch. `total_fee_sats`
  reproduces the daemon's rounding exactly; a malformed fee is discarded rather
  than stored, because a wrong fee is an unexplained lock failure later.
- `mostro/actions.rs`: `add_cashu_escrow` builder beside `add_invoice`.
- `api/cashu.rs`: `cashu_escrow_quote` (amount, fee, total, balance, mint,
  locktime — shown before the seller commits) and `lock_escrow`, which refuses
  below `amount + fee`, builds the escrow and fee tokens, verifies the escrow it
  just built with the same check the daemon runs, publishes, and persists.
  Persistence deliberately follows the mint swap and survives a failed publish:
  the ecash is already committed by then, and a token we did not record is
  money we cannot find again.
- `TradeInfo` gains `cashu_mint_url` / `cashu_escrow_token` / `cashu_locked_at`,
  `#[serde(default)]` so older rows still load.
- A node switch now also drops the fee and disconnects the wallet, which was
  bound to the previous node's mint.

Dart
- `lock_escrow_screen.dart`: the Cashu sibling of the pay-invoice screen —
  escrow, fee, total against the balance, mint, and when the seller can reclaim
  unilaterally. Short balance offers "fund your wallet" instead of a failure.
- The take flow branches on `isCashuAvailable`: buyer straight to the trade
  (there is no invoice step in Cashu mode), seller to the lock screen.
- Markers mapped to localized strings in all five locales, unknown ones falling
  back so no internal string reaches a user.

Also fixes a real test-isolation bug found here: `api::escrow` and `api::cashu`
serialized the same globals with two different mutexes, which fails only under
parallel execution. One global, one lock (`escrow_mode::test_lock`).

Stacked on C1b (#234), C2 (#235), C4 (#236) and C3 (#237).
@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: 20d729f6-f991-46f5-b19e-2827903360e6

📥 Commits

Reviewing files that changed from the base of the PR and between e701b4a and eb4adcf.

📒 Files selected for processing (29)
  • lib/core/app_routes.dart
  • lib/features/cashu/cashu_error_messages.dart
  • lib/features/cashu/providers/cashu_wallet_provider.dart
  • lib/features/cashu/screens/lock_escrow_screen.dart
  • lib/features/order/screens/take_order_screen.dart
  • lib/l10n/app_de.arb
  • lib/l10n/app_en.arb
  • lib/l10n/app_es.arb
  • lib/l10n/app_fr.arb
  • lib/l10n/app_it.arb
  • lib/l10n/app_localizations.dart
  • lib/l10n/app_localizations_de.dart
  • lib/l10n/app_localizations_en.dart
  • lib/l10n/app_localizations_es.dart
  • lib/l10n/app_localizations_fr.dart
  • lib/l10n/app_localizations_it.dart
  • rust/src/api/cashu.rs
  • rust/src/api/escrow.rs
  • rust/src/api/nostr.rs
  • rust/src/api/orders.rs
  • rust/src/api/types.rs
  • rust/src/cashu/escrow.rs
  • rust/src/cashu/mod.rs
  • rust/src/frb_generated.rs
  • rust/src/mostro/actions.rs
  • rust/src/mostro/escrow_mode.rs
  • rust/src/mostro/mod.rs
  • rust/src/mostro/node_fee.rs
  • test/features/cashu/screens/lock_escrow_screen_test.dart
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cashu-c5-escrow-lock-flow

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 — C5 (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, do not merge. Finding 1 means the flow this PR exists to deliver cannot succeed against a real daemon on either path, and the tests do not catch it because none of them reaches the party-derivation code.

Scope reviewed: rust/src/api/cashu.rs, rust/src/mostro/{node_fee,actions}.rs, rust/src/api/{orders,nostr,types}.rs, lib/features/cashu/screens/lock_escrow_screen.dart, lib/features/order/screens/take_order_screen.dart, l10n ×5.


🛑 Critical

1. The buyer's key is taken from the wrong field — the lock is rejected on both pathsrust/src/api/cashu.rs:315

let buyer_hex = trade.counterparty_pubkey.clone();

counterparty_pubkey is written in exactly two places, both at construction, and never updated afterwards (rust/src/api/orders.rs:844 and :1068):

  • Maker seller (line 844) — String::new(). EscrowParties::from_xonly_hex("") fails with InvalidTradeKey. A user who created a sell order can never fund the escrow. This is the most common seller in the product.
  • Taker seller (line 1068) — order.creator_pubkey, i.e. the pubkey on the Kind 38383 order-book event. The daemon validates proof.buyer_pubkey against order.get_buyer_pubkey(), which is the buyer's per-order trade key. These are different keys, so the daemon returns InvalidCashuToken — after the seller's ecash has already been swapped into a token locked to a key the buyer does not hold.

The second case is worse than a failed lock: the funds are committed at the mint before the rejection arrives, and the escrow is unspendable by the intended buyer.

The right source is the escrow request itself. The daemon's show_cashu_escrow_request sets both:

new_order.buyer_trade_pubkey  = Some(buyer_pubkey.to_string());
new_order.seller_trade_pubkey = Some(seller_pubkey.to_string());

classify_take_reply receives that Payload::Order(SmallOrder) and drops both fields. C5 must capture them — persisted on TradeInfo alongside cashu_mint_url — and lock_escrow must read the buyer key from there, never from counterparty_pubkey. It is also worth asserting our own trade key equals the daemon's seller_trade_pubkey before locking; a mismatch means the wrong trade key was derived and the escrow would be unspendable by us.


🔴 Major

2. The locktime is computed at exactly the daemon's floor, and evaluated earlier than the daemon evaluates itlock_escrow()

let locktime = now_secs() + locktime_days * SECONDS_PER_DAY;

The daemon's check is locktime >= now + escrow_locktime_days, evaluated when the submission is validated. Our now is strictly earlier than the daemon's now by the publish latency plus relay propagation. The comment argues that matching the floor is the shortest lock the daemon accepts — but matching it exactly means every lock is a race against network delay, and the losing side is a rejection with the funds already swapped.

Add a margin (an hour is invisible to the user and larger than any plausible propagation delay), or derive the locktime from a timestamp the daemon supplied.

3. The fee is computed from the node's current advertised rate, not the order's stored fee

node_fee is refreshed from the Kind 38385 fee tag; the daemon compares against order.fee, which was computed and stored when the order was created. An operator who changes the fee between order creation and the lock invalidates every in-flight order's fee token, and the client has no way to know.

The arithmetic itself is right and the rounding note is the good part of this PR — but the input is the wrong one. If the order does not carry its fee to the client today, that is worth raising upstream; until then the failure mode deserves an explicit error rather than a generic InvalidCashuToken.

4. The escrow is committed before the fee token is builtlock_escrow()

build_escrow_token confirms — funds swapped, irreversible — and only then is build_fee_token attempted. A failure there (insufficient balance for the fee after mint fees, mint hiccup) leaves the escrow locked, unsubmitted, and reclaimable only after the locktime. Prepare both sends, then confirm both; or on fee failure, immediately re-swap the escrow proofs back to the wallet and report it.

5. lock_escrow reads the balance without ensuring the wallet is connected

cashu_escrow_quote returns balance_sats: 0 when no wallet is bound, so a call that does not go through the screen (a retry, a future notification action) reports CashuInsufficientFunds on a fully funded wallet. Either connect inside lock_escrow or return CashuNotConnected.

6. take_order_screen reads the gate before it has resolvedlib/features/order/screens/take_order_screen.dart

ref.read(isCashuAvailableProvider) is false while escrowModeProvider is still AsyncLoading. Take an order quickly after launch on a Cashu node and the seller is routed to the Lightning pay-invoice screen, which will sit waiting for a hold invoice that never arrives. Await the provider (or block the take button until it resolves) rather than reading it optimistically.


🟡 Minor

7. No re-submit path after a failed publish. The code correctly persists the token even when the publish fails, and correctly notes the daemon's handler is idempotent — but nothing in the UI ever calls lock_escrow again, so the user is left with locked funds and a trade that looks stuck. The retry the design assumes does not exist yet.

8. node_fee::set_fee accepts 0.0 ≤ f < ∞ with no upper bound. A malformed tag of 2.0 (200%) is stored and produces a fee token larger than the escrow. Cap it at something sane (1.0) and warn.

9. now_secs() returns 0 on a clock before the epoch, which then produces a locktime in 1970 and a confusing InvalidEscrowConditions. Prefer surfacing the clock error.


✅ What is right

  • Reading the daemon's branches instead of inferring the contract. The fee-rounding discovery (2 * round(fee*amount/2), not round(fee*amount)) is exactly the class of bug that would have shipped and been near-impossible to diagnose from the client side, and node_fee's tests pin it with the 500-sat case where the two expressions diverge.
  • Verifying our own escrow token with the same check the daemon runs, before publishing.
  • Persisting the token even when the publish fails, with the reasoning stated: the ecash is already committed, and an unrecorded token is money we cannot find again.
  • Fixing the two-mutexes-one-global test isolation bug properly rather than papering over the flake.
  • The screen shows the total against the balance and offers "fund your wallet" instead of failing — the right shape for the most common seller error.

Test gaps

The Rust tests cover the gate (CashuNotEnabled on every entry point) and the fee arithmetic. Nothing covers:

  • party derivation — which is finding 1, and a single test constructing a maker TradeInfo and calling lock_escrow would have caught it;
  • the locktime actually landing above the daemon's floor;
  • the quote's insufficient-funds branch;
  • any Dart coverage of lock_escrow_screen (no widget test), including the "fund your wallet" branch that is the phase's stated acceptance criterion.

grunch added 6 commits July 25, 2026 09:14
…-flow

# Conflicts:
#	lib/l10n/app_de.arb
#	lib/l10n/app_en.arb
#	lib/l10n/app_es.arb
#	lib/l10n/app_fr.arb
#	lib/l10n/app_it.arb
#	lib/l10n/app_localizations.dart
#	lib/l10n/app_localizations_de.dart
#	lib/l10n/app_localizations_en.dart
#	lib/l10n/app_localizations_es.dart
#	lib/l10n/app_localizations_fr.dart
#	lib/l10n/app_localizations_it.dart
…yer key

Addresses the strict review on #238.

Critical
- `lock_escrow` took the buyer key from `TradeInfo.counterparty_pubkey`, which
  is written once at construction and never updated: empty for a maker seller
  (so a user who *created* the sell order could never fund the escrow) and the
  maker's order-book key for a taker seller — not the per-order trade key the
  daemon validates against. The taker case was the worse one: the daemon
  rejects, but only after the ecash has been swapped into a token locked to a
  key the buyer does not hold.

  The daemon states both keys in the escrow request (`SmallOrder`'s
  `buyer_trade_pubkey` / `seller_trade_pubkey`) and the client was discarding
  them. They are now carried through `classify_take_reply` for the taker path,
  captured from the status-sync arm for the maker path (where the request
  arrives as an ordinary message), persisted on `TradeInfo`, and read from
  there. `lock_escrow` also refuses when the stored seller key is not the one
  this device holds, rather than building an escrow nobody can spend.

Major
- The locktime matched the daemon's floor exactly, computed with *our* clock
  while the daemon evaluates its own later — every lock was a race against the
  publish delay, lost with the funds already swapped. A one-hour margin is
  invisible to a seller and larger than any plausible propagation.
- `cashu_escrow_quote` read the balance without connecting, so an unconnected
  wallet reported zero and a fully funded seller was told they had
  insufficient funds. It connects first, and an unreadable balance is an error
  rather than a zero.
- The escrow token was built before the fee token, so a fee failure stranded
  the whole escrow. The fee — the smaller and cheaper of the two — is built
  first.
- `take_order_screen` read `isCashuAvailableProvider` while it was still
  loading, routing a Cashu seller to a hold-invoice screen that would never
  fill. It awaits the provider now.

Minor
- A lock that failed *after* the mint swap now offers a retry, with the reason
  stated: the token is persisted and the daemon's handler is idempotent, so
  retrying is the only way out of a lost publish. Failures raised before any
  funds move deliberately do not offer it.
- `node_fee::set_fee` caps the fraction at 1.0; a malformed `2.0` tag would
  have produced a fee token twice the escrow.
- `now_secs` returns an error instead of 0 for a pre-epoch clock, which would
  have built a 1970 locktime and surfaced as an unexplained condition error.
- The marker→message mapping is shared with the wallet screen.

Note on the fee input: `SmallOrder` carries no fee, so the client must still
derive it from the node's advertised rate. An operator who changes the fee
between order creation and the lock will invalidate in-flight orders; that is
recorded as a known limitation rather than silently accepted.

Tests: the buyer key must come from the escrow request and not the order book;
the locktime clears a floor evaluated a minute later; and a widget suite for the
lock screen — quote shown before committing, funding offered on a short
balance, a marker explained rather than printed, and retry offered only after
the swap.
… doc

Four findings were valid, three were not.

Fixed
- `MostroInstance.parseEscrowMode` used `getOptional`, so a *present but blank*
  `escrow_mode` tag read as `unknown` while Rust's `parse_tags` reads the same
  event as `Lightning`. Two parsers, one event, different answers — the About
  screen and the Cashu gate could disagree about the same daemon. Now only an
  absent (or value-less) tag is unknown, matching Rust exactly.
- The dev card's post-frame seed captured `mintUrlOverride` during build and
  applied it after the frame. An override arriving in that gap is applied by
  `ref.listen` first, and the captured copy then overwrote it with the older
  value. The callback reads the current provider value instead.
- `api::escrow::snapshot` derived `mode` from one `get_resolved()` and
  `is_cashu_available` from `is_cashu_mode()`, which reads the globals again —
  a node switch between the two produced a snapshot whose mode and gate
  disagreed. `ResolvedEscrowMode::is_cashu_usable()` now expresses the gate
  against a value the caller already holds, and `is_cashu_mode()` is that
  applied to the current globals.
- `set_from_tags` logged unconditionally while only notifying on a change, so a
  reconnect that confirmed what we already knew still wrote an info line. Moved
  inside the `changed` branch.
- The C1 "Done when" in the plan still said flipping the override flips "the
  About section", contradicting the bullet directly above it. About reports
  what the node advertised; the override moves the resolved mode and the dev
  card's effective state, nothing else.

Not fixed, with reasons
- The shared test lock is already in place (`escrow_mode::test_lock`, used by
  both `api::escrow` and `api::cashu`); the finding describes the pre-fix state.
- The `hintText` is the literal `http://localhost:3338`, identical in every
  locale, on a `kDebugMode`-only card. Five ARB entries that translate nothing
  is maintenance without a reader.
- French `aboutDaysValue`: CLDR puts 0 in the `one` category for French, so
  "0 jour" is correct and the suggested change would introduce an error. The
  generated file is also not the source of truth and must not be hand-edited.

Tests: blank / whitespace / value-less / absent `escrow_mode` all pinned against
the Rust behaviour, and a widget suite for the dev card covering seeding, a
newer override winning, and in-progress typing surviving a no-op event.
Running the `#[ignore]`d suite against a real nutshell for the first time
found that it could not pass at all, and then that it could not pass twice.

- Every funds-using test asserted "fund the wallet first" and returned. There
  was no way for a reviewer to satisfy that, so five tests were documentation
  rather than verification. `CashuWallet::mint_for_test` mints from the mint
  itself, which settles instantly against a FakeWallet backend.
- Fixed seeds plus a fresh DB per run replay the same NUT-13 blinding secrets,
  and the mint answers "Blinded Message is already signed" on the second run.
  Seeds are now unique per process and per call.
- Two assertions pinned amounts the mint is free to reduce: nutshell's default
  keyset charges a swap fee, so an 8 sat token redeems for 7 and locking 16 sat
  costs the seller more than 16. The face value is what the daemon validates,
  so the assertions bound the redeemed amount instead of pinning it.
- `one_signature_is_not_enough_to_move_an_escrow` expected the mint to refuse a
  premature reclaim. Since the C4 round the client refuses first, with the time
  remaining — a better message for the same property. Accepts either.

Verified: 8/8 against nutshell 0.20.3, twice in a row. Note that nutshell rate
limits by default; the suite needs MINT_RATE_LIMIT=FALSE to run back to back.

Worth flagging for review rather than fixing here: a mint that charges swap
fees means a seller needs slightly more than `amount + mostro_fee` to lock an
escrow, and `cashu_escrow_quote` does not account for that.
…-flow

# Conflicts:
#	rust/src/cashu/escrow.rs
#	rust/src/cashu/wallet.rs
@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