Skip to content

feat(cashu): seller-funded fee token (Option 2) — Track A TA-1f#832

Open
grunch wants to merge 1 commit into
feat/cashu-ta3-restore-monitorfrom
feat/cashu-ta1f-fee-token
Open

feat(cashu): seller-funded fee token (Option 2) — Track A TA-1f#832
grunch wants to merge 1 commit into
feat/cashu-ta3-restore-monitorfrom
feat/cashu-ta1f-fee-token

Conversation

@grunch

@grunch grunch commented Jul 20, 2026

Copy link
Copy Markdown
Member

Stacked on #831 (TA-3) → #830 (TA-2) → #829 (TA-1) → #828 (CF-5). Review/merge the stack in order; the base retargets as each lands. The diff shown is TA-1f only.

What & why

Cashu fee collection (docs/cashu/02-track-a-lock.md §4A, Option 2 — funder-pays-at-lock). Mostro is deliberately out of the money path in Cashu mode (the buyer redeems the 2-of-3 in full), so it cannot skim a fee output from the escrow token. Instead the seller funds the whole Mostro fee (2 * order.fee) as a separate P2PK-1-of-1 token locked to P_M, submitted in the same AddCashuEscrow.

Total revenue and dev-fee accounting stay identical to Lightning; only the buyer/seller split moves (the seller bears the buyer's half too, since nothing can deduct from a token the buyer redeems freely).

Changes

  • Migration: cashu_fee_token + cashu_fee_redeemed_at on orders; new cashu_fee_proofs (y PRIMARY KEY, order_id, created_at) anti-reuse table.
  • CashuClient::verify_fee_token + verify_fee_condition (P2PK 1-of-1 to P_M, amount == 2*order.fee, sat unit, DLEQ, unspent) + proof_ys_hex. Offline unit tests for the condition and Y derivation.
  • db::update_order_cashu_escrow_with_fee: a transactional CAS that persists the escrow, the fee token, and every fee-proof Y in one transaction:
    • crash-safety — a crash between lock and redeem can't leave a locked order whose fee is unrecorded (the redeem is retryable from the DB);
    • anti-reuse (TOCTOU) — the y PRIMARY KEY makes two concurrent submissions of the same fee token roll back the second (CashuLockOutcome::FeeReuse). The NUT-07 unspent check only stops sequential reuse; this closes the concurrent gap.
    • plus find_pending_cashu_fee_redeems / mark_cashu_fee_redeemed.
  • Handler: when mostro.fee > 0, require + validate the fee token after the escrow token (missing ⇒ CashuSignatureMissing), persist it in the atomic CAS, reject cross-order reuse (InvalidCashuToken). Fee-free nodes accept a None fee token unchanged.
  • Scheduler: cashu-only job_retry_cashu_fee_redeem surfaces the pending-redeem backlog.

Tests

  • Transactional CAS: Locked (fee persisted, not yet redeemed) / StatusMismatch no-op / FeeReuse rolls back the escrow write too.
  • Pending-redeem lifecycle (find_pending…mark…redeemed).
  • The 1-of-1 fee condition (accept p_m; reject wrong key / extra key / 2-of-N) and deterministic Y derivation.
  • The migrated test schema gains the fee columns + table.

Scoped follow-up — the live redeem

The live redeem (sign each fee proof with P_M, swap at the mint, stamp cashu_fee_redeemed_at) is deliberately not in this PR. It needs (a) an ecash revenue store — Cashu mode has no LND to melt the fee to, so the swapped proofs must be persisted — and (b) mint-backed tests. job_retry_cashu_fee_redeem is the seam where it lands. Until then fee tokens are validated, persisted, and anti-reuse-guarded, so no revenue is lost — only deferred. This split matches the spec: TA-1f "ships the crash-safe fee persistence and cashu_fee_proofs anti-reuse guard".

Checklist

  • cargo fmt --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test — 1033 passed
  • Off-by-default: no behaviour change when Cashu is disabled; fee columns/table unreachable while Cashu is off

Depends on #831/#830/#829/#828 and mostro-core 0.14.1 (CashuLockProof.fee_token). Refs: docs/cashu/02-track-a-lock.md (TA-1f, §4A).


🧪 Manual testing — step by step

Prereqs: the Cashu-mode mostrod setup from CF-5 (#828) — mint up (docker compose -f docker-compose.cashu.yml up -d), [cashu] enabled = true. This branch is the top of the Track A stack (contains CF-5 + TA-1 + TA-2 + TA-3 + TA-1f). Fee collection only engages when the node charges a fee:

[mostro]
fee = 0.006   # any value > 0 turns on the fee token requirement

Run with RUST_LOG=mostro=info.

Scope note. The live fee redeem (sign with P_M, swap at the mint, stamp cashu_fee_redeemed_at) is a scoped follow-up — it needs an ecash revenue store. This PR validates, persists, and anti-reuse-guards the fee token; the scheduler job surfaces the pending backlog. The steps below verify exactly that.

1. Fee token is required when the node charges a fee

  1. With [mostro] fee > 0, drive an order to WaitingPayment (take flow) and submit AddCashuEscrow without a fee_token.
  2. Expected: CantDo(CashuSignatureMissing) — the order stays WaitingPayment, nothing is locked. ✔️
  3. A fee-free node (fee = 0) accepts a None fee token unchanged (no fee required). ✔️

2. Valid fee token locks + persists atomically

  1. Submit AddCashuEscrow with the 2-of-3 escrow token and a P2PK-1-of-1 fee token locked to P_M worth 2 * order.fee, both minted on the test mint.
  2. Expected: the order advances to Active, and the fee is persisted crash-safely in the same write:
    SELECT status, cashu_fee_token, cashu_fee_redeemed_at FROM orders WHERE id = '<id>';
    -- status = 'active', cashu_fee_token = '<token>', cashu_fee_redeemed_at = NULL
    SELECT y, order_id FROM cashu_fee_proofs WHERE order_id = '<id>';
    -- one row per fee proof
    ✔️

3. Cross-order fee reuse is rejected (anti-TOCTOU)

  1. Submit a second order's AddCashuEscrow reusing the same fee token.
  2. Expected: CantDo(InvalidCashuToken), and the second order's escrow write is rolled back (still WaitingPayment, no cashu_escrow_token) — the cashu_fee_proofs.y PRIMARY KEY blocks the reuse inside the transaction. ✔️

4. Pending-redeem backlog is surfaced

  1. After a successful fee-carrying lock, watch the cashu-only scheduler job (runs every 60 s):
    cashu fee redeem: 1 fee token(s) pending collection (live mint swap not yet wired — see TA-1f follow-up)
    
    The fee token is persisted and retryable from the DB; no revenue is lost, only deferred. ✔️

5. Automated tests

# transactional CAS + anti-reuse (offline)
cargo test --bin mostrod cashu_fee_cas
# 1-of-1 fee condition + Y derivation (offline)
cargo test --bin mostrod fee_condition
cargo test --bin mostrod fee_proof_ys
# mint-backed verify_fee_token
CASHU_TEST_MINT_URL=http://127.0.0.1:3338 cargo test cashu -- --ignored --nocapture

Expected: all green — Locked / StatusMismatch no-op / FeeReuse rollback, the fee condition (accept P_M; reject wrong/extra key or 2-of-N), and deterministic Y derivation. ✔️

@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: 85f5ce96-9960-4024-b5d3-ae5fba4800b9

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-ta1f-fee-token

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: 49361481c2

ℹ️ 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 thread src/cashu/mod.rs

// The NUT-11 tag block is optional for a bare 1-of-1 (absent ⇒
// SIG_INPUTS, n_sigs = 1, no extra keys). When present, enforce it.
if let Some(conditions) = conditions {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject refundable Cashu fee tokens

When a seller includes NUT-11 locktime/refund tags on the fee token, this branch still accepts it because it only checks sig_flag, n_sigs, and extra signing pubkeys. The existing escrow validation treats refund_keys as an alternate post-locktime spend path, so a malicious seller can submit a fee token that is initially valid but later spendable by the refund key instead of being exclusively controlled by Mostro, especially while live redemption is deferred. Reject refund/locktime tags here, or enforce a safe fee-token recovery policy.

Useful? React with 👍 / 👎.

// is validated against the mint alongside the escrow token — both must
// be valid before any state changes. A fee-free node (`mostro.fee == 0`)
// accepts a `None` fee token.
let charges_fee = Settings::get_mostro().fee > 0.0;

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 Skip fee tokens when the stored fee is zero

This gates fee-token enforcement on the configured percentage rather than the actual per-order fee. For small orders where get_fee rounds the stored order.fee to 0 even though mostro.fee > 0 (for example a 100-sat order at a 0.3% fee), the handler requires a fee_token and then expects a 0-sat token, so otherwise valid zero-fee orders cannot be locked. Use the calculated order fee (order.fee > 0) to decide whether a fee token is required.

Useful? React with 👍 / 👎.

Add Cashu fee collection: the seller funds the WHOLE Mostro fee
(`2 * order.fee`) as a separate P2PK-1-of-1 token locked to Mostro's
arbitrator key, submitted alongside the 2-of-3 escrow in the same
`AddCashuEscrow` (docs/cashu/02-track-a-lock.md §4A). This keeps total revenue
and dev-fee accounting identical to Lightning; only the buyer/seller split
moves (the seller bears the buyer's half too, since the buyer redeems the
escrow token freely).

- Migration: `cashu_fee_token` + `cashu_fee_redeemed_at` on `orders`, and a
  `cashu_fee_proofs (y PRIMARY KEY, order_id, created_at)` anti-reuse table.
- `CashuClient::verify_fee_token` + `verify_fee_condition` (P2PK 1-of-1 to
  `P_M`, amount, sat unit, DLEQ, unspent) and `proof_ys_hex`. Offline unit
  tests for the condition + Y derivation.
- `db::update_order_cashu_escrow_with_fee`: transactional CAS that persists the
  escrow, the fee token, and every fee-proof `Y` in ONE transaction — crash
  can't leave a locked order whose fee is unrecorded, and the `y` PRIMARY KEY
  makes two concurrent submissions of the same fee token roll back the second
  (`CashuLockOutcome::FeeReuse`), closing the TOCTOU that the unspent check
  (sequential-only) leaves open. Plus `find_pending_cashu_fee_redeems` /
  `mark_cashu_fee_redeemed`.
- Handler: when `mostro.fee > 0`, require + validate the fee token after the
  escrow token (missing ⇒ `CashuSignatureMissing`), persist it in the atomic
  CAS, and reject cross-order reuse (`InvalidCashuToken`). Fee-free nodes
  accept a `None` fee token unchanged.
- Scheduler: cashu-only `job_retry_cashu_fee_redeem` surfaces the pending
  redeem backlog from the DB.

Tests: the transactional CAS (Locked / StatusMismatch no-op / FeeReuse
rollback), pending-redeem lifecycle, and the 1-of-1 fee condition + Y
derivation. The migrated test schema gains the fee columns + table.

Scoped follow-up: the LIVE redeem (sign each fee proof with P_M, swap at the
mint, stamp `cashu_fee_redeemed_at`) needs an ecash revenue store (Cashu mode
has no LND to melt to) and mint-backed tests — the retry job body is the seam.
Until then fee tokens are validated, persisted, and anti-reuse-guarded, so no
revenue is lost, only deferred.

Depends on TA-1 (+ mostro-core 0.14.1 fee_token). Base: feat/cashu-ta3-restore-monitor
Refs: docs/cashu/02-track-a-lock.md (TA-1f, §4A)
@grunch
grunch force-pushed the feat/cashu-ta3-restore-monitor branch from d39115c to 456c3f4 Compare July 21, 2026 01:50
@grunch
grunch force-pushed the feat/cashu-ta1f-fee-token branch from 4936148 to 4a8c6b8 Compare July 21, 2026 01:50
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