Skip to content

feat(cashu): add_cashu_escrow_action lock handler — Track A TA-1#829

Open
grunch wants to merge 4 commits into
mainfrom
feat/cashu-ta1-lock-handler
Open

feat(cashu): add_cashu_escrow_action lock handler — Track A TA-1#829
grunch wants to merge 4 commits into
mainfrom
feat/cashu-ta1-lock-handler

Conversation

@grunch

@grunch grunch commented Jul 20, 2026

Copy link
Copy Markdown
Member

Stacked on #828 (CF-5). Review/merge CF-5 first; the base will retarget to main once CF-5 lands. The diff shown here is TA-1 only.

What & why

The first functional Cashu flow (docs/cashu/02-track-a-lock.md, TA-1): fills in the CF-5 stub add_cashu_escrow_action with the full escrow-lock algorithm. A cashu-mode node now accepts a seller's AddCashuEscrow, fully validates the 2-of-3 token against the mint and the order's trade keys, atomically advances WaitingPayment → Active, publishes the updated order event, and notifies the buyer to send fiat.

Validate-fully-then-commit discipline (same as release_action): compute/verify first, persist second, notify last. A validation or persistence failure leaves the order exactly as it was.

Algorithm (§4)

# Step Reject reason
1 resolve the order
2 sender must be the order's seller trade key InvalidPeer
3 status must be WaitingPayment NotAllowedByStatus
4 extract the CashuLockProof InvalidCashuToken
5 proof.mint_url == configured mint (cheap pre-check) InvalidMintUrl
6 bind {P_B, P_S, P_M} to this order (reject mismatched proof keys; derive the mint-validation keys from the order, never the proof) InvalidCashuToken
7 verify_escrow_token (2-of-3 + seller-recovery locktime floor + mint + amount + DLEQ + unspent) InvalidCashuToken / CashuMintUnavailable
8 update_order_cashu_escrow CAS (lock + advance in one write); zero rows ⇒ idempotent no-op
9 publish the Active order event (best-effort)
10 notify buyer + seller with CashuEscrowLocked

The escrow token locks order.amount exactly (Option 2 — the Mostro fee is a separate token added in TA-1f).

Security core (step 6)

The 2-of-3 must lock to the keys Mostro already holds for the order, never attacker-chosen keys. The handler both rejects a proof whose stated buyer/seller/mostro_pubkey disagree with the order, and derives the {P_B, P_S, P_M} handed to verify_escrow_token from the order (the seller is event.sender, already authorised in step 2) — so the proof's own key fields can never widen the accepted set.

Tests

  • Deterministic pre-mint rejections: wrong sender ⇒ InvalidPeer; wrong status; missing proof ⇒ InvalidCashuToken (order untouched).
  • cashu_reason error mapping (mint-unreachable ⇒ retryable CashuMintUnavailable; bad token ⇒ InvalidCashuToken; bad URL ⇒ InvalidMintUrl).
  • The CF-5 dispatch_cashu gate test is updated: AddCashuEscrow is no longer in the InvalidAction list (it runs the real handler now).

Follow-up — mint-backed integration test

The happy-path (valid lock → Active + buyer notified) and the replay no-op require constructing a real 2-of-3 P2PK token (locktime + refund=[P_S] + DLEQ) via a wallet mint+swap against the CF-3 nutshell mint. The CF-3 harness (tests/common/mod.rs) currently ships only connectivity helpers (mint_url_from_env, wait_for_mint, mint_get_json) — no 2-of-3 token builder yet. That builder + the env-gated #[ignore] happy-path/replay tests are a focused follow-up, not folded in here to keep the PR reviewable.

Checklist

  • cargo fmt --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test — 1026 passed
  • Off-by-default: no behaviour change when Cashu is disabled

Depends on #828 (CF-5). Refs: docs/cashu/02-track-a-lock.md (TA-1).


🧪 Manual testing — step by step

Prereqs: the same Cashu-mode mostrod setup as CF-5 (#828) — the throwaway mint up (docker compose -f docker-compose.cashu.yml up -d, http://127.0.0.1:3338) and [cashu] enabled = true. This branch is stacked on CF-5, so check it out (it contains CF-5 + TA-1). Run with RUST_LOG=mostro=info.

Scope note. TA-1 is the lock handler. Driving an order to WaitingPayment through the normal flow needs the Cashu take branch (TA-2 / #830, higher on the stack), and building a valid 2-of-3 escrow token needs a Cashu-capable client. So the interactive end-to-end lock is exercised once TA-2 is also checked out + a token-building client is available. On this branch the deterministic rejection paths and the mint-backed library checks are what you verify directly.

1. Deterministic rejection paths (no token needed)

Seed a WaitingPayment sell order for a known seller trade key (create+take on the TA-2 stack, or insert a row directly). Then submit AddCashuEscrow and confirm the CantDoReason:

  1. Wrong sender — submit from a key that is not the order's seller → CantDo(InvalidPeer). ✔️
  2. Wrong status — submit against an order that is not WaitingPaymentCantDo(NotAllowedByStatus). ✔️
  3. No lock proof — submit AddCashuEscrow with a non-CashuLockProof payload → CantDo(InvalidCashuToken). ✔️
  4. Wrong mint — a proof whose mint_url differs from the node's configured mint → CantDo(InvalidMintUrl). ✔️

The order is left unchanged after every rejection (status still WaitingPayment, cashu_escrow_token still NULL). ✔️

2. Unit + mint-backed library checks

  1. Deterministic unit tests (offline):
    cargo test --bin mostrod add_cashu_escrow
    Expected: the rejection-path + cashu_reason mapping tests pass. ✔️
  2. Mint-backed CashuClient::verify_escrow_token checks against the live mint:
    CASHU_TEST_MINT_URL=http://127.0.0.1:3338 cargo test cashu -- --ignored --nocapture
    Expected: the DLEQ / unspent / condition checks the lock handler composes pass against the running nutshell mint. ✔️

3. Happy-path lock (full stack — TA-2 + token-building client)

  1. On the full Track A stack, take a Cashu order (TA-2) so it sits in WaitingPayment, then have the seller's client build the 2-of-3 token (locktime + refund=[P_S], DLEQ) and submit AddCashuEscrow.
  2. Expected: the order advances WaitingPayment → Active in one atomic write, the updated order event is published, and both parties receive CashuEscrowLocked (the buyer's cue to send fiat). A replayed submission is a safe no-op (matches zero rows). ✔️

The integration PR of the Cashu foundation (docs/cashu/01-fundamentals.md §6).
A node with `[cashu] enabled = true` now boots with NO Lightning node,
connects its configured mint, and runs a Cashu event loop in which every
trade action is rejected with `CantDo(InvalidAction)` — the feature tracks
replace those arms one at a time. With Cashu disabled the daemon is
behaviourally identical to `main`.

- `main.rs`: branch on `Settings::is_cashu_enabled()`. Cashu mode skips
  `LndConnector::new()` and the LN status probe, connects the mint via
  `CashuClient::connect` (exit(1) if unreachable, mirroring LND-refusal),
  attaches it to the context, and returns `run_cashu(ctx)`. The spam-gate
  warm-up is extracted into `install_spam_gate()` and shared by both paths;
  the Lightning branch is otherwise unchanged.
- `app.rs`: factor the transport/validation prologue and the post-dispatch
  error tail out of `run()` into `accept_event` / `finalize_dispatch`, shared
  VERBATIM by `run` and the new `run_cashu` so the two loops cannot drift.
  Add `dispatch_cashu` with the closed allow-list (`Orders`,
  `LastTradeIndex`, `RestoreSession`, `TradePubkey` → `handle_message_action_no_ln`;
  `AddCashuEscrow` → the TA-1 stub; everything else → `InvalidAction`).
- `app/add_cashu_escrow.rs` (new): stub `add_cashu_escrow_action` returning
  `InvalidAction`; Track A fills the body in its own file (G-1).
- `app/context.rs`: `AppContext` carries `Option<Arc<CashuClient>>` with a
  `with_cashu_client` setter and `cashu_client()` accessor (`None` in
  Lightning mode). The `escrow: Arc<dyn EscrowBackend>` field the spec
  sketches is deferred: `run()` still threads `&mut LndConnector` and an
  unused `Arc<dyn EscrowBackend>` would be dead weight — added when Track B
  needs it.
- `scheduler.rs`: gate the LN-only jobs (cancel-orders, retry-payments,
  dev-fee, bond payouts, stranded-bond reconcile) behind
  `!is_cashu_enabled()` so they never call `LndConnector::new()` on a node
  with no LND; make `job_info_event_send` self-skip when `LN_STATUS` is
  absent instead of panicking.

Tests: `dispatch_cashu` blocks every order-lifecycle action with
`InvalidAction` and routes the allow-list through the no-LN handler; the
CF-5 stub returns `InvalidAction`. Full suite green (1023) with Cashu off.

Refs: docs/cashu/01-fundamentals.md (CF-5)
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • develop

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e103949a-6869-48ac-a993-a2364ae7be92

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cashu-ta1-lock-handler

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2f032a652

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +71 to +73
order
.check_status(Status::WaitingPayment)
.map_err(MostroCantDo)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle already-locked retries before status rejection

If Mostro crashes or loses the in-memory send queue after the CAS has stored the escrow and moved the order to Active, but before the CashuEscrowLocked notifications are sent, the seller's retry refetches the order and exits here with NotAllowedByStatus. That makes the documented idempotent retry/no-op path unreachable for post-commit failures, so the buyer can be left without the cue to send fiat even though the escrow is locked; handle the already-locked order before this rejection or replay the post-commit notifications.

Useful? React with 👍 / 👎.

Comment on lines +148 to +152
let locked = update_order_cashu_escrow(
pool,
order.id,
&configured_mint,
&proof.token,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject escrow proofs already locked to another order

This CAS is scoped only to the current order.id; when the same buyer/seller trade keys and amount are used on another order, the exact same unspent Cashu token passes the mint/key/amount checks again and can be stored on both orders. Because the mint only sees the proofs as unspent until one redemption, this can mark multiple trades Active while only one redeemable escrow exists; add a cross-order proof/Y uniqueness check before accepting the token.

Useful? React with 👍 / 👎.

grunch added 3 commits July 20, 2026 22:30
…ashu mode

Two review findings from PR #828:

- Bond-expiry paths in `job_expire_pending_older_orders` open
  `LndConnector::new()` via the bond-release helpers. Cashu mode has no LND,
  so gate all three release calls (WaitingMakerBond, taker-bond, range maker
  bond) behind `!Settings::is_cashu_enabled()`. Bonds are mutually exclusive
  with Cashu (CF-1), so a cashu node carries no bond rows by construction; this
  only matters defensively for a reused/mode-switched DB, where the order still
  expires but no doomed LND connection is attempted.
- The Cashu boot branch early-returns before the RPC startup block, silently
  skipping the admin gRPC server. Warn when `[rpc].enabled = true` in Cashu
  mode so the missing API is visible instead of a surprise (the LN-independent
  RPC subset is a follow-up — the server currently requires an LND client).

Skipped (with reasons posted on the PR): the `check_trade_index`-before-dispatch
note (pre-existing shared-prologue behaviour, identical to the Lightning loop;
the actions become valid in TA-2 where the check belongs) and the boot-init DRY
hoist (it reorders the Lightning boot, violating the byte-identical guarantee).

cargo fmt + clippy -D warnings + full suite (1023) green.
Fill in the CF-5 stub with the full escrow-lock algorithm
(docs/cashu/02-track-a-lock.md §4). A `cashu`-mode node now accepts a
seller's `AddCashuEscrow`, fully validates the 2-of-3 token against the mint
and the order's trade keys, atomically advances `WaitingPayment → Active`,
publishes the updated order event, and notifies the buyer to send fiat.

Validate-fully-then-commit discipline (same as `release_action`):

1. resolve the order;
2. authorise the sender == the order's seller trade key (else `InvalidPeer`);
3. require `WaitingPayment` status;
4. extract the `CashuLockProof` (absent ⇒ `InvalidCashuToken`);
5. bind the mint: `proof.mint_url` must equal the configured mint
   (`InvalidMintUrl`) — a cheap pre-check; step 7 enforces the authoritative
   token↔mint binding;
6. bind `{P_B, P_S, P_M}` to THIS order — reject a proof whose stated keys
   disagree with the order, and derive the keys handed to the mint from the
   order (never from the proof), so the 2-of-3 can only lock to keys Mostro
   already holds;
7. `verify_escrow_token` (2-of-3 + seller-recovery locktime floor
   `now + escrow_locktime_days` + mint + amount + DLEQ + unspent); mint
   unreachable ⇒ `CashuMintUnavailable`, malformed ⇒ `InvalidCashuToken`;
8. `update_order_cashu_escrow` CAS (lock + status advance in one write);
   zero rows ⇒ idempotent no-op (replay/concurrent), no notification;
9. publish the Active order event (best-effort, logged on failure);
10. notify buyer + seller with `CashuEscrowLocked`.

The escrow token locks `order.amount` exactly (Option 2 — the Mostro fee is a
separate token added in TA-1f).

Tests: the deterministic pre-mint rejection paths (wrong sender ⇒
`InvalidPeer`, wrong status, missing proof ⇒ `InvalidCashuToken`) and the
`cashu_reason` error mapping. The mint-backed happy-path + replay tests are a
follow-up: they need a 2-of-3 token builder in the CF-3 harness (which today
ships only connectivity helpers).

Depends on CF-5 (dispatch seam + cashu_client). Base: feat/cashu-cf5-boot-integration
Refs: docs/cashu/02-track-a-lock.md (TA-1)
TA-1 implements AddCashuEscrow, so dispatch_cashu no longer routes it to
InvalidAction — it runs the real lock handler. Remove it from the
'blocks every order-lifecycle action' assertion list.
@grunch
grunch force-pushed the feat/cashu-ta1-lock-handler branch from e2f032a to 760467f Compare July 21, 2026 01:50
Base automatically changed from feat/cashu-cf5-boot-integration to main July 22, 2026 19:33
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