feat(cashu): add_cashu_escrow_action lock handler — Track A TA-1#829
feat(cashu): add_cashu_escrow_action lock handler — Track A TA-1#829grunch wants to merge 4 commits into
Conversation
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)
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 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".
| order | ||
| .check_status(Status::WaitingPayment) | ||
| .map_err(MostroCantDo)?; |
There was a problem hiding this comment.
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 👍 / 👎.
| let locked = update_order_cashu_escrow( | ||
| pool, | ||
| order.id, | ||
| &configured_mint, | ||
| &proof.token, |
There was a problem hiding this comment.
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 👍 / 👎.
…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.
e2f032a to
760467f
Compare
What & why
The first functional Cashu flow (
docs/cashu/02-track-a-lock.md, TA-1): fills in the CF-5 stubadd_cashu_escrow_actionwith the full escrow-lock algorithm. Acashu-mode node now accepts a seller'sAddCashuEscrow, fully validates the 2-of-3 token against the mint and the order's trade keys, atomically advancesWaitingPayment → 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)
InvalidPeerWaitingPaymentNotAllowedByStatusCashuLockProofInvalidCashuTokenproof.mint_url== configured mint (cheap pre-check)InvalidMintUrl{P_B, P_S, P_M}to this order (reject mismatched proof keys; derive the mint-validation keys from the order, never the proof)InvalidCashuTokenverify_escrow_token(2-of-3 + seller-recovery locktime floor + mint + amount + DLEQ + unspent)InvalidCashuToken/CashuMintUnavailableupdate_order_cashu_escrowCAS (lock + advance in one write); zero rows ⇒ idempotent no-opCashuEscrowLockedThe escrow token locks
order.amountexactly (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_pubkeydisagree with the order, and derives the{P_B, P_S, P_M}handed toverify_escrow_tokenfrom the order (the seller isevent.sender, already authorised in step 2) — so the proof's own key fields can never widen the accepted set.Tests
InvalidPeer; wrong status; missing proof ⇒InvalidCashuToken(order untouched).cashu_reasonerror mapping (mint-unreachable ⇒ retryableCashuMintUnavailable; bad token ⇒InvalidCashuToken; bad URL ⇒InvalidMintUrl).dispatch_cashugate test is updated:AddCashuEscrowis no longer in theInvalidActionlist (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 --checkcargo clippy --all-targets --all-features -- -D warningscargo test— 1026 passedDepends on #828 (CF-5). Refs:
docs/cashu/02-track-a-lock.md(TA-1).🧪 Manual testing — step by step
Prereqs: the same Cashu-mode
mostrodsetup 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 withRUST_LOG=mostro=info.1. Deterministic rejection paths (no token needed)
Seed a
WaitingPaymentsell order for a known seller trade key (create+take on the TA-2 stack, or insert a row directly). Then submitAddCashuEscrowand confirm theCantDoReason:CantDo(InvalidPeer). ✔️WaitingPayment→CantDo(NotAllowedByStatus). ✔️AddCashuEscrowwith a non-CashuLockProofpayload →CantDo(InvalidCashuToken). ✔️mint_urldiffers from the node's configured mint →CantDo(InvalidMintUrl). ✔️The order is left unchanged after every rejection (status still
WaitingPayment,cashu_escrow_tokenstill NULL). ✔️2. Unit + mint-backed library checks
cargo test --bin mostrod add_cashu_escrowcashu_reasonmapping tests pass. ✔️CashuClient::verify_escrow_tokenchecks against the live mint:CASHU_TEST_MINT_URL=http://127.0.0.1:3338 cargo test cashu -- --ignored --nocapture3. Happy-path lock (full stack — TA-2 + token-building client)
WaitingPayment, then have the seller's client build the 2-of-3 token (locktime +refund=[P_S], DLEQ) and submitAddCashuEscrow.WaitingPayment → Activein one atomic write, the updated order event is published, and both parties receiveCashuEscrowLocked(the buyer's cue to send fiat). A replayed submission is a safe no-op (matches zero rows). ✔️