diff --git a/crates/maxplayer-core/src/credential_proxy.rs b/crates/maxplayer-core/src/credential_proxy.rs index 63ba1b422..0a9fd5301 100644 --- a/crates/maxplayer-core/src/credential_proxy.rs +++ b/crates/maxplayer-core/src/credential_proxy.rs @@ -3495,8 +3495,16 @@ mod tests { // reproduce. #[tokio::test] async fn a_declared_over_cap_body_is_refused_before_the_upstream_sees_it() { - let (stub_addr, stub) = spawn_stub("UPSTREAM_OK").await; - let upstream = format!("http://{stub_addr}"); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // The upstream here is a listener nothing ever accepts from, which witnesses "never dialled" + // more strictly than a stub that reports what it served: a connection that IS dialled completes + // in the kernel's backlog whether or not anything accepts it, so an accept that finds nothing + // waiting is proof no connection was opened at all — not merely proof none was answered. + let upstream_listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let upstream = format!("http://{}", upstream_listener.local_addr().unwrap()); let engine = Arc::new(ProxyEngine::new([authority_of(&upstream).unwrap()])); let placeholder = mint_anthropic_placeholder(); engine @@ -3509,27 +3517,59 @@ mod tests { let proxy = start(Arc::clone(&engine), None).await.unwrap(); let port = proxy.local_addr().port(); - // One byte over the cap, declared up front: `reqwest` sets `content-length` for a sized body. - let over = vec![b'z'; MAX_REQUEST_BODY_BYTES + 1]; - let response = reqwest::Client::new() - .post(format!("http://127.0.0.1:{port}/v1/messages")) - .header("x-api-key", &placeholder) - .body(over) - .send() + // The HEADERS ALONE, on a socket this test drives itself. Handing `reqwest` a sized 32 MiB + 1 + // body made the client race its own upload against the answer: the proxy refuses from the + // header and closes, so the write failed before the response was read and the test saw a + // transport error instead of the `413`. Declaring the over-cap length and sending no body byte + // asks exactly the question the cap answers, with nothing to race. + let mut sock = tokio::net::TcpStream::connect(("127.0.0.1", port)) .await .unwrap(); - assert_eq!( - response.status(), - 413, - "a declared over-cap body keeps the buffered path's refusal" + let declared = MAX_REQUEST_BODY_BYTES + 1; + sock.write_all( + format!( + "POST /v1/messages HTTP/1.1\r\nhost: 127.0.0.1:{port}\r\n\ + x-api-key: {placeholder}\r\ncontent-length: {declared}\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + sock.flush().await.unwrap(); + + // Bounded: on a proxy that waited for the declared body before deciding, this test must fail + // on the deadline rather than hang forever. + let mut head = Vec::new(); + let mut tmp = [0u8; 1024]; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let n = sock.read(&mut tmp).await.unwrap(); + if n == 0 { + break; + } + head.extend_from_slice(&tmp[..n]); + if find_subslice(&head, b"\r\n\r\n").is_some() { + break; + } + } + }) + .await + .expect("the proxy must refuse a declared over-cap length from the headers alone"); + + let text = String::from_utf8_lossy(&head).to_string(); + let status_line = text.lines().next().unwrap_or_default().to_owned(); + assert!( + status_line.starts_with("HTTP/1.1 413"), + "a declared over-cap body keeps the buffered path's refusal; got {status_line:?}" ); // The upstream must never have been dialled: the refusal is decided from the header alone, so - // the real credential was never put on the wire for this request. Checked WITHOUT a timer — - // the stub completes only once it has served a connection, and the `413` above is already in - // hand, so "still running" is a settled fact here rather than a race against a deadline. + // the real credential was never put on the wire for this request. The `413` is already in hand + // above, so a connection, had one been made, would be sitting in this listener's backlog now. assert!( - !stub.is_finished(), + tokio::time::timeout(Duration::from_millis(500), upstream_listener.accept()) + .await + .is_err(), "an over-cap request must not reach the upstream at all" ); } diff --git a/crates/maxplayer-core/src/fee_remit.rs b/crates/maxplayer-core/src/fee_remit.rs new file mode 100644 index 000000000..a581a63e8 --- /dev/null +++ b/crates/maxplayer-core/src/fee_remit.rs @@ -0,0 +1,8613 @@ +//! Paying the accrued platform fee to the platform's Lightning address — **the one remit path in +//! the product**, and its first seller-fee payment code (operator melts existed before it). +//! +//! ## Who calls this, and when +//! +//! [`remit`] has exactly three callers, and all three are named here so a grep confirms it: +//! +//! 1. **The seller node's collect path** (`seller_node::run`, the `Collected::New` arm of the +//! receipt write), through [`remit_live_best_effort`] → [`remit_best_effort`] under +//! [`RemitTrigger::Collect`]. This is the mechanism, and the fast path: a fee the seller had to +//! remember to pay would not be a fee, so the node remits as a consequence of collecting, normally +//! within a second of the sale. It fires only when a receipt was journaled **New** — never on a +//! replayed wrap (`Duplicate`), never on an error — and it is **best-effort**: whatever happens +//! here is logged and journaled; it cannot fail the collect, delay the job being marked paid, or +//! change what the seller received. +//! 2. **The seller node's retry tick** (`seller_node::run`, an arm of the live loop's `select!`), +//! through the same two functions under [`RemitTrigger::Retry`]. This is the safety net behind +//! the fast path (stage 2a, addendum 2): a failed remittance retries on the node's own clock — base +//! 30 s, doubling to a 30-minute cap, full jitter — for as long as the node runs, and stops with +//! the loop. [`RemitBackoff`] is the pacing; [`RemitFlight`] keeps the two node paths to one +//! attempt in flight. +//! 3. **`maxplayer seller fees remit`** (`crates/maxplayer/src/seller_fees.rs`): inspection (the +//! default dry run resolves, quotes, prints the plan and the recent attempts, and moves nothing), +//! recovery (`--confirm` forces an attempt now, for an operator whose automatic path has been +//! failing), and reconciliation (an interrupted attempt is settled or released against the mint). +//! +//! The `[platform_fee] auto_remit` switch ([`crate::home::PlatformFeeConfig`]) turns BOTH node +//! paths off — the collect path's attempt and the retry tick — and does not touch accrual; +//! `--confirm` pays regardless. There is no startup sweep and no other call site. The node's two +//! attempts run on threads the node owns and drains on shutdown (bounded); see `seller_node::run`. +//! +//! ## What one attempt does +//! +//! Reconcile any in-flight attempt first; read the unremitted balance; resolve +//! [`PLATFORM_FEE_ADDRESS`] over LNURL-pay ([`crate::lnurl_pay`], fail-closed) and refuse below its +//! minimum — the expected steady state for small sellers, not an error; probe the mint's melt fee +//! reserve on the gross and invoice the **net**, so the fee comes OUT of the accrued amount; journal +//! the plan (which pins the receipts, records this process as the row's owner under a lease, and +//! refuses a duplicate — the idempotency that makes two concurrent collects pay at most once); raise +//! the **payment quote** and check its invoice + reserve alone against the hard [`MeltCeiling`] +//! (the reserve-only precheck; refused ⇒ the planned row is NOT written: it stays planned, unbound +//! and ours, receipts pinned, nothing spent, and the next attempt's reconciliation releases it — +//! addendum 10 §2); if the live reserve differs from the estimate, optionally **re-plan** ONCE onto a +//! new invoice; **prepare** the melt of that quote through +//! [`crate::wallet_ops::prepare_melt_payment_blocking`] — the wallet selects and reserves proofs in +//! its own database (it may fetch mint metadata/keysets, a GET; it posts nothing proof-bearing or +//! fee-bearing) and states the SDK's PREPARED proof-input fee and pre-melt swap fee; run the SDK's +//! **post-swap arithmetic** on those figures — the bound is the ACTUAL input fee recomputed on the +//! swapped split, not the prepared display: invoice + reserve + actual input fee + swap fee must fit +//! the gross (over, or the SDK would refuse after its swap ⇒ the prepared melt is cancelled locally +//! and the same before-fence refusal: row stays planned, receipts pinned, nothing posted); pass the +//! **pre-spend gate** — ONE compare-and-set +//! in the store advances the row `planned → spending` and BINDS that quote to it (only if still +//! planned, still ours, with more than [`SPEND_MARGIN`] of lease left at the clock read INSIDE the +//! store call; zero rows changed is a refusal, and the prepared melt is cancelled); then **confirm** +//! the prepared melt — the swap if one is required, then the melt request for exactly that quote, by +//! id — the same gated wallet `maxplayer wallet melt` uses (it honours `allow_real_mints`); it never +//! raises a second quote; settle. Every attempt that meant to pay is journaled with its outcome +//! (`fee_remit_attempts`), so a payout that keeps failing is visible in the read-out rather than +//! silent. +//! +//! ## The two invariants (stage 2a, addendum 3; the fence of addendum 4; the hold of addendum 6; the total bound of addendum 8) +//! +//! **Money hold (§1):** the seller never pays more than the fee it accrued — gross is the ceiling, +//! every cost of the payment comes out of it, and the ceiling is enforced at the moment of spending, +//! not estimated beforehand or regretted afterwards. The estimate at plan time is a plan; the quote +//! the wallet actually pays under is checked against `gross`, and a reserve that grew in between is +//! a refused, journaled, failed attempt with the balance intact. Since addendum 8 the bound is on the +//! **entire wallet debit**: the pinned CDK 0.17.2 wallet charges, on top of invoice + fee reserve, a +//! proof-input fee on the proofs it sends (`input_fee_ppk`, NUT-02) and — when its proofs do not fit +//! — the fee of a pre-melt swap it performs inside `confirm`; the delivered ceiling of rounds 1–6 +//! saw neither (verdict B4 at 19f30d3). Now the melt is PREPARED before the fence — `prepare_melt` +//! selects and reserves proofs in the wallet's own store and exposes `input_fee`, `swap_fee` and +//! `requires_swap` (`melt/mod.rs:428–450`) while posting no proof-bearing or fee-bearing request +//! (it may fetch mint metadata/keysets, a GET; the swap `melt/saga/mod.rs:687–697` and the melt +//! request `:907–911` both live inside `confirm`) — and [`MeltCeiling::admits_confirmable`] is taken on +//! those four figures; over ⇒ `PreparedMelt::cancel` (`:817–831`, best-effort local compensation: +//! proofs back to Unspent, quote released) and the ordinary before-fence refusal (the planned row +//! is NOT written: it stays planned, unbound and ours, its receipts pinned; ONE `REFUSED before +//! spending` line naming the total, its parts and the ceiling; backoff continues and the next +//! attempt's reconciliation releases the row as our own earlier attempt and re-plans — addendum +//! 10 §2) — a fee refusal never leaves a bound Spending row held. +//! +//! **The fee model is CDK's, not the prepared display's (addendum 9).** The prepared `input_fee` is +//! an ESTIMATE — the fee on the split of invoice + reserve before that fee is added +//! (`melt/saga/mod.rs:383–387`). `confirm` swaps to a target of invoice + reserve + that estimate +//! (`:678`), receives exactly the target's denomination split, RECOMPUTES the input fee on that +//! split (`:704`) and refuses — after the swap has been paid — when the target does not cover +//! invoice + reserve + the actual fee (`:706–712`). So this module runs that arithmetic itself, +//! twice, before any fee-bearing effect: **planning** ([`plan_confirmable_invoice`]) searches down +//! from gross − reserve for the largest invoice whose post-swap arithmetic holds and whose worst +//! case (invoice + reserve + actual input fee + swap fee) fits the gross — none ⇒ +//! [`Refusal::FeesDoNotFit`] — and re-checks the quote actually raised; **before the fence** +//! ([`confirm_would_succeed`]) the same check runs on the prepared figures and the keyset's +//! `input_fee_ppk` ([`crate::wallet_ops::MeltPreparation::input_fee_ppk`]) and refuses — prepared +//! melt cancelled, row left planned with its receipts pinned, one line — when the SDK would refuse +//! after its swap or the actual worst case exceeds the gross. The PREPARED total is not the bound: +//! a prepared total over the gross whose actual debit fits (19/2/1000/[32] ⇒ invoice 13, prepared +//! 20 > 19, actual 19 ≤ 19) is admitted. After payment the SDK's `FinalizedMelt::fee_paid` (`:139–148`: +//! proofs sent − invoice − change) already CONTAINS the actual input fee beside the Lightning fee; +//! the actual debit is invoice + `fee_paid` + swap fee, counted once — the prepared estimate is +//! printed, never added. A failed post-payment balance read prints `unknown`, never a computed +//! figure. +//! +//! **Bound, disclosed not solved (addendum 9 §1.5):** the mint's fee metadata can change between +//! prepare and confirm, and the SDK takes no caller maximum into `confirm`. A change there can make +//! `confirm` fail after the swap (the swap fee is gone, the row is bound Spending and HELD by the +//! PAID-only rule below) or cost more than the arithmetic predicted (the `WARNING` line after +//! payment). No exploit is claimed or reproduced; no changing-fee mint was measured. Likewise +//! **owed**: a cancelled preparation whose SDK compensation itself failed can leave a local proof +//! reservation — `open_wallet_async` only constructs the wallet and CDK `recover_incomplete_sagas` +//! is not run on this path (a blanket recovery could replay a still-live payer's saga); a supported +//! recovery path is owed, not wired. +//! +//! The fee-bearing fake wallet — whose `confirm` models the swap output split, the swap fee charged +//! even when the melt then fails, the recomputed input fee, the post-swap refusal and the inclusive +//! `fee_paid` — and the regressions +//! `a_fee_bearing_payment_whose_prepared_input_fee_differs_from_the_actual_pays_once_and_the_wallet_loses_at_most_the_gross` +//! (one swap, one melt, the wallet measured), +//! `a_fee_bearing_schedule_whose_prepared_figures_fit_but_post_swap_arithmetic_does_not_is_refused_before_the_fence` +//! (wallet delta 0, no swap, no melt, row left planned and pinned), +//! `a_fee_bearing_total_that_exceeds_the_gross_by_the_fee_is_refused_before_any_swap_or_melt`, +//! `fee_aware_planning_sizes_the_invoice_so_a_fee_bearing_payment_fits_without_reserve_slack` +//! (invoice 13, not 14), `fees_that_can_never_fit_are_refused_at_planning_not_at_payment` and +//! `a_failed_balance_read_after_a_fee_bearing_payment_prints_unknown_and_no_false_warning` hold +//! it; the reserve-grew case `a_reserve_that_grows_between_estimate_and_payment_is_refused_before_spending` +//! stands. +//! +//! **Ownership (§2), as the tests in this module prove it:** a row is paid only by the process that +//! planned it, only through the fence — [`SellerStore::admit_remittance_spend`], which two +//! processes cannot both pass — and only under the ONE quote the fence bound to it: the payer never +//! raises a second quote for a row it holds, and a quote it has not raised cannot be spoken for. +//! While that row is in flight — `planned` or `spending` — no second remittance against the same +//! balance can be planned by anyone: [`SellerStore::plan_remittance`] refuses inside its own +//! transaction, and a partial unique index refuses underneath it; neither predicate has a time +//! term. Every release is a conditional transition carrying its reason's predicate +//! ([`ReleaseOn`]): zero rows changed means the row moved under the releasing process, which then +//! holds. A `planned` row (fence not yet passed: nothing spent against it, and once released its +//! owner's fence changes zero rows) is released by another process only when its invoice's quote +//! is FAILED or UNPAID and expired, or its owner's lease of [`REMIT_LEASE`] has run out — never on +//! a live UNPAID alone, because UNPAID means "not yet", not "abandoned"; a planned row whose lease +//! ran down while its owner paused is refused by the owner's own fence, which reads the clock +//! inside the store's lock (`an_owner_whose_lease_ran_down_while_it_paused_is_refused_by_its_own_fence`; +//! released-then-refused: `an_owner_that_outlives_its_lease_is_released_and_its_fence_then_changes_zero_rows`, +//! `an_owner_paused_after_its_quote_and_past_its_lease_is_released_and_never_pays_that_quote`); +//! and a release decided on a stale planned snapshot changes zero rows once the owner's fence has +//! landed (`a_release_decided_on_a_stale_planned_snapshot_cannot_revoke_a_later_admission`). +//! +//! A `spending` row (fence passed, a quote bound: its owner may be mid-melt) **is released by +//! nobody, on no clock.** It settles when the mint reports its BOUND quote, asked by id, PAID; on +//! anything else — UNPAID however long past its expiry, FAILED, PENDING, UNKNOWN, a quote the wallet +//! does not know — it is HELD, and its receipts with it, until the mint says PAID or an operator +//! decides (no override exists in this round). **We do not infer terminality from a clock**, and +//! not from the mint's FAILED either, because the inspected CDK 0.17.2 mint implementation +//! (checksum-pinned source, read in the round-4 and round-5 verdicts — the implementation this +//! wallet is built on, not a measurement of whichever server a configured mint URL reaches) pays an +//! UNPAID *or FAILED* quote with no expiry check, and the wallet's own request, once past +//! `prepare_melt`, re-checks nothing: a +//! payment prepared before the quote expired can land after any observation a second process makes. +//! A release on "expired" or "FAILED" would therefore make the same gross payable twice +//! (`a_payment_prepared_before_expiry_cannot_be_doubled_by_a_release_after_it` schedules exactly +//! that ordering — A paused inside its payment after its last local check, the quote expiring, B +//! held with funds for a second payment in the same wallet — and counts one debit; +//! `a_bound_quote_expired_past_the_margin_is_held_and_its_owner_refuses_to_pay_it`, +//! `a_spending_row_is_not_released_when_its_lease_expires_and_its_owner_pays_exactly_once`, +//! `a_spending_row_is_reconciled_by_its_bound_quote_not_by_an_expired_estimate`, +//! `a_spending_rows_bound_quote_decides_its_release_on_the_full_path` — whose FAILED arm has the +//! mint pay the FAILED quote — and the table +//! `a_spending_row_is_never_released_by_reconciliation_only_settled`). What "at most one debit" +//! rests on is the exclusion: a second attempt is never admitted while a bound spending row +//! exists — at two boundaries. The ordinary one is RECONCILIATION: every `remit` run first finds the +//! in-flight row and, when it is SPENDING with a quote bound and the mint's answer about that quote +//! is anything but PAID, returns [`Refusal::SpendingHeld`] before it plans anything (this is where +//! B stops in (b1), (b2), (d) and the delayed-confirm test; a PLANNED row in flight is a different +//! case — (a)'s B returns [`Refusal::HeldByOwner`], (c)'s B releases the expired-lease row and pays +//! its own quote, (B2)'s B returns [`Refusal::RowChangedUnderMe`]). Behind it is the STORE: +//! [`SellerStore::plan_remittance`] refuses a second plan with `PlanRefused::InFlight` inside its own +//! `IMMEDIATE` transaction while any planned-or-spending row exists — the race-closing layer, reached +//! when two runs both saw no row (`two_racing_attempts_against_the_same_balance_record_exactly_one_remittance`) +//! and exercised directly, on B's own connection while A is paused inside its payment, in +//! `a_payment_prepared_before_expiry_cannot_be_doubled_by_a_release_after_it`. It does not rest on +//! when a quote dies. The cost is named, not hidden: a melt the mint +//! genuinely failed leaves the row held and every later remittance refused until an operator acts +//! (owed as later work; the CLI exits 3 and prints one `HELD:` line naming the row, the quote, the +//! mint's answer and the pinned sats). The owner's own reconciliation of its own `planned` row may +//! release on UNPAID: a process runs at most one attempt at a time ([`RemitFlight`] in the node; one +//! shot for the command), so its earlier attempt is over and, the fence never having been passed, +//! spent nothing. Its own `spending` row gets no such exception. +//! +//! **What the two-process tests prove, and their bound:** two processes, each on its own store +//! connection, debit an accrued balance at most once under every interleaving they schedule — +//! pauses after the plan, after the quote, after the fence, inside the payment after the wallet's +//! last local check, and between a release decision and its write; the lease and the quote expiring +//! while paused; distinct invoices; actual melts counted; the fake mint accepting UNPAID or FAILED +//! quotes regardless of expiry, as the inspected CDK 0.17.2 implementation does. What each shares +//! is stated per test, not assumed. One fake mint (the quote registry): (a), (b1), (b2), (c), (B2), +//! (d) and the delayed-confirm test. One effects clock handed from A to B: (b2), (c), (B2) and the +//! delayed-confirm test — (a), (b1) and (d) leave each process its own clock. One fake wallet (one +//! proof pool both processes select from): the delayed-confirm test only; the single-process +//! fee-bearing tests of addendum 8 each own a pool of their own. The older (b)/(c) cases and the +//! node's 2b test script the mint's answer on one process instead of reading a shared registry. +//! They do not run a real mint or a real wallet, and +//! `a_release_decided_on_a_stale_planned_snapshot…` moves its command clock (401) independently of +//! its effects clock (100) to force the SQL ordering — a synthetic time model, not a claim about +//! how a mint's clock behaves. +//! +//! Every effect on the world goes through [`RemitEffects`], so the decision logic is tested against +//! scripted effects without a network or a mint. Exactly one method of that trait spends: +//! [`RemitEffects::confirm_melt`] (on a melt [`RemitEffects::prepare_melt`] prepared and bounded). + +use std::fmt; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use crate::home::MaxplayerHome; +use crate::lnurl_pay::{self, HttpsFetch, LightningAddress, PayRequest, ResolvedInvoice}; +use crate::platform_fee::PLATFORM_FEE_ADDRESS; +use crate::seller_node::store::{ + FeeRemittance, OwnershipLost, PlanRefused, ReleaseOn, RemitAttempt, RemitAttemptOutcome, + RemitAttemptTrigger, RemitSettlement, RemittancePlan, RemittanceReplan, RemittanceState, + SellerStore, SettledBy, +}; +use crate::wallet_ops::{ + self, MeltCeiling, MeltEstimate, MeltOutcome, MeltPreparation, MeltQuoteState, MeltQuoteStatus, + WalletOpsError, +}; + +/// How many journaled attempts the command prints, newest first. +pub const RECENT_ATTEMPTS_SHOWN: usize = 5; + +/// How long a process's claim on a `planned` row stands (addendum 3 §2). Another process may +/// release the row on UNPAID / no-quote only after this has passed since the plan. Five minutes is +/// far longer than a melt needs to leave UNPAID: a melt that reaches the mint turns its quote +/// PENDING or PAID within seconds, and one that never reaches it errors out and ends the attempt. +pub const REMIT_LEASE: Duration = Duration::from_secs(5 * 60); + +/// How much of its lease an owner must still hold to be ADMITTED to a payment. Another process is +/// entitled to release a PLANNED row once its lease ends (a spending row it never releases), so an +/// admission that landed with less than this margin would race that release. A payer also refuses +/// to pay its bound quote inside this margin of the quote's expiry — to avoid a pointless attempt +/// the mint would likely turn down, NOT as a safety bound: nothing about a spending row's release +/// is inferred from this margin, or from any clock (addendum 6 §0–§1). Processes share one host +/// clock (the store is a local file); the margin covers scheduling pauses on that clock. +pub const SPEND_MARGIN: Duration = Duration::from_secs(60); + +/// Why [`RemitEffects::prepare_melt`] or [`RemitEffects::confirm_melt`] did not pay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MeltFailure { + /// The payment was refused by the wallet layer with NOTHING posted to the mint: the quote's + /// stored amount and reserve did not fit the [`MeltCeiling`], or — after `prepare_melt` + /// selected and reserved proofs in the wallet's own database — the ACTUAL worst-case debit + /// (invoice + fee reserve + the input fee the SDK recomputes on the swapped split + swap fee) + /// did not fit it, or the SDK would refuse after its swap, and the prepared melt was cancelled + /// (addendum 9 §1.1). Raised by [`RemitEffects::prepare_melt`], which the remittance calls + /// BEFORE the fence, so the row is still Planned: it is not written, its receipts stay pinned, + /// and the next attempt's reconciliation releases it (addendum 10 §2). Nothing left the wallet. + RefusedBeforeSpending(String), + /// The payment failed somewhere the caller cannot see: from `prepare_melt` (unknown or expired + /// quote, proofs short — still nothing posted, see the caller) or from `confirm_melt` (proofs may + /// or may not have reached the mint, or the mint refused the bound quote). After the fence the + /// spending row stays for reconciliation of its bound quote against the mint; the payer never + /// re-quotes. + Failed(String), +} + +/// A melt PREPARED by [`RemitEffects::prepare_melt`] and not yet decided: proofs selected and +/// reserved in the wallet's own store, the SDK's fee figures known and already admitted by the +/// ceiling, nothing posted to the mint. The remittance holds it across the fence, then +/// [`RemitEffects::confirm_melt`]s it (the payment) or [`RemitEffects::cancel_melt`]s it (nothing +/// to undo at the mint). +#[derive(Debug)] +pub struct PreparedMelt { + pub preparation: wallet_ops::MeltPreparation, + token: PreparedToken, +} + +#[derive(Debug)] +enum PreparedToken { + Live(wallet_ops::PreparedMeltPayment), + #[cfg(test)] + Fake(u64), +} + +impl PreparedMelt { + fn live(payment: wallet_ops::PreparedMeltPayment) -> Self { + Self { + preparation: payment.preparation.clone(), + token: PreparedToken::Live(payment), + } + } + + #[cfg(test)] + pub(crate) fn fake(preparation: wallet_ops::MeltPreparation, token: u64) -> Self { + Self { + preparation, + token: PreparedToken::Fake(token), + } + } + + #[cfg(test)] + pub(crate) fn fake_token(&self) -> Option { + match self.token { + PreparedToken::Fake(token) => Some(token), + PreparedToken::Live(_) => None, + } + } +} + +impl fmt::Display for MeltFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RefusedBeforeSpending(reason) => write!(formatter, "{reason}"), + Self::Failed(error) => write!(formatter, "{error}"), + } + } +} + +/// The remit path's effects on the world, behind a trait so the decision logic — what is paid, +/// when, and what is refused — is tested without a network or a mint. Exactly one method moves +/// money: [`Self::confirm_melt`]. Everything else reads or writes the wallet's own database +/// (raising a quote spends nothing; preparing a melt reserves proofs locally and posts nothing). +pub trait RemitEffects { + /// This process's opaque owner token for the rows it plans (addendum 3 §2): stable for the + /// life of the process, distinct across processes. The node's attempts and the command's run + /// each speak as one owner. + fn owner(&self) -> &str; + /// LNURL step 1–2: the destination's payRequest (callback + sendable bounds). + fn pay_request(&mut self, address: &LightningAddress) -> Result; + /// LNURL step 3–4: an invoice for exactly `amount_sats`. + fn invoice(&mut self, pay: &PayRequest, amount_sats: u64) -> Result; + /// A melt quote for the invoice — the mint's fee reserve — WITHOUT paying. The ESTIMATE, at + /// plan time: it sizes the net invoice and is journaled on the planned row as `melt_quote_id`. + fn melt_estimate(&mut self, bolt11: &str) -> Result; + /// **The payment quote** for the planned invoice (addendum 5 §1, rule 1 step 1): raised after + /// the plan is journaled, checked against the ceiling, then BOUND to the row by the fence — the + /// one quote [`Self::prepare_melt`] prepares and [`Self::confirm_melt`] pays. Raising it spends + /// nothing. The same wallet call as [`Self::melt_estimate`], distinguished so the two quotes' + /// roles are told apart in the ledger. + fn melt_quote(&mut self, bolt11: &str) -> Result; + /// **Prepare the payment, bounded on its total** (addendum 8 §1.1): re-checks the quote's stored + /// amount and reserve against the ceiling, then `prepare_melt(quote_id)` — the wallet selects + /// and RESERVES proofs in its own database and states the proof-input fee and any pre-melt swap + /// fee — then refuses unless invoice + reserve + input fee + swap fee ≤ the ceiling, cancelling + /// the prepared melt on refusal. Posts nothing to the mint; spends nothing. Called BEFORE the + /// fence, so a refusal leaves a Planned row, never a held one. + fn prepare_melt( + &mut self, + quote_id: &str, + ceiling: &MeltCeiling, + ) -> Result; + /// **The payment.** `confirm` on the prepared melt: the pre-melt swap if one is required, then + /// the melt request for the bound quote, BY ID. Never raises a quote. The only method here that + /// spends. + fn confirm_melt(&mut self, prepared: PreparedMelt) -> Result; + /// Release a prepared melt the fence (or the pay-time margin) refused: proofs back to Unspent + /// in the wallet's database, quote released. Nothing was posted, so nothing is undone at the + /// mint. + fn cancel_melt(&mut self, prepared: PreparedMelt) -> Result<(), MeltFailure>; + /// What the mint says about the melt quote(s) this wallet raised for the invoice, if any — + /// used to reconcile a PLANNED row (no quote bound yet) and a spending row admitted before + /// quotes were bound. + fn melt_status(&mut self, bolt11: &str) -> Result, String>; + /// What the mint says about ONE quote, by id — the quote a SPENDING row's admission bound + /// (addendum 5 §1, rule 2). `None` when this wallet never raised it. + fn melt_status_for_quote(&mut self, quote_id: &str) -> Result, String>; + /// Observation point: called once the plan is journaled, before the payment quote is raised. + /// The live effects do nothing here; tests pause here to interleave a second process against + /// the planned row (addendum 3 §2.2). + fn after_plan(&mut self, _planned: &FeeRemittance) {} + /// Observation point: called once the payment quote is raised and has passed the ceiling, before + /// the fence (addendum 5 §2, `AfterQuote`). The row is still `planned`; tests pause here. + fn after_quote(&mut self, _planned: &FeeRemittance, _quote: &MeltEstimate) {} + /// Observation point: called once the compare-and-set has admitted the melt (the row is + /// `spending`, bound to its quote) and before the payment. The live effects do nothing here; + /// tests pause here to interleave a second process against a SPENDING row (addendum 4 §1). + fn after_admit(&mut self, _admitted: &FeeRemittance) {} + /// Observation point: called with reconciliation's decision about the in-flight row, AFTER the + /// decision is taken and BEFORE the release / settle is written (addendum 5 §2, + /// `AfterDecision`). Tests pause here so another process can move the row under a decided + /// release, which must then change zero rows. + fn after_decision(&mut self, _row: &FeeRemittance, _decision: &Reconcile) {} + /// **The clock, read now.** The pre-spend fence compares the row's lease against the time at + /// the instant of admission — never the attempt's entry time, which may be arbitrarily stale by + /// then (addendum 4 §1.1). The live effects read the host clock; tests inject one so "the clock + /// advanced while A was paused" is real arithmetic in the store. + fn now_unix(&self) -> i64 { + host_now_unix() + } +} + +/// The host's unix clock in whole seconds; `0` if the clock is before the epoch (it is not). +fn host_now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_secs()).ok()) + .unwrap_or(0) +} + +/// This process's owner token: pid plus a boot nonce from the OS RNG, fixed for the life of the +/// process. Every [`LiveEffects`] in the process speaks as this one owner. +fn process_owner() -> &'static str { + static OWNER: OnceLock = OnceLock::new(); + OWNER.get_or_init(|| { + let mut nonce = [0u8; 8]; + let nonce = match getrandom::fill(&mut nonce) { + Ok(()) => u64::from_le_bytes(nonce), + Err(_) => 0, + }; + format!("pid{}-{nonce:016x}", std::process::id()) + }) +} + +/// The shipped effects: LNURL over https, the packaged CDK wallet at `home`, the home's default +/// mint (the first accepted mint — where a seller's receipts land). Every wallet call goes through +/// the `*_blocking` wrappers in [`crate::wallet_ops`], which refuse to run inside a Tokio runtime — +/// so the seller node drives this from a plain thread of its own, never from a task. +pub struct LiveEffects { + home: MaxplayerHome, + fetch: HttpsFetch, +} + +impl LiveEffects { + pub fn new(home: MaxplayerHome) -> Result { + let fetch = HttpsFetch::new().map_err(|error| error.to_string())?; + Ok(Self { home, fetch }) + } +} + +impl RemitEffects for LiveEffects { + fn owner(&self) -> &str { + process_owner() + } + + fn pay_request(&mut self, address: &LightningAddress) -> Result { + lnurl_pay::fetch_pay_request(&self.fetch, address).map_err(|error| error.to_string()) + } + + fn invoice(&mut self, pay: &PayRequest, amount_sats: u64) -> Result { + lnurl_pay::request_invoice(&self.fetch, pay, amount_sats).map_err(|error| error.to_string()) + } + + fn melt_estimate(&mut self, bolt11: &str) -> Result { + wallet_ops::melt_quote_blocking(&self.home, bolt11, None).map_err(|error| error.to_string()) + } + + fn melt_quote(&mut self, bolt11: &str) -> Result { + wallet_ops::melt_quote_blocking(&self.home, bolt11, None).map_err(|error| error.to_string()) + } + + fn prepare_melt( + &mut self, + quote_id: &str, + ceiling: &MeltCeiling, + ) -> Result { + wallet_ops::prepare_melt_payment_blocking(&self.home, quote_id, None, ceiling) + .map(PreparedMelt::live) + .map_err(|error| match error { + // The two typed refusals: the quote's own figures over the ceiling (before a proof + // was selected) or the actual worst-case debit — invoice + reserve + the input fee + // recomputed on the swapped split + swap fee — over it (prepared melt cancelled). + // Both: nothing fee-bearing posted, nothing left the wallet. Every other error from + // this step also posted nothing fee-bearing (prepare writes the wallet's own + // database and at most fetches mint metadata) but is opaque as to why. + refused @ (WalletOpsError::MeltExceedsCeiling { .. } + | WalletOpsError::MeltTotalExceedsCeiling { .. }) => { + MeltFailure::RefusedBeforeSpending(refused.to_string()) + } + other => MeltFailure::Failed(other.to_string()), + }) + } + + fn confirm_melt(&mut self, prepared: PreparedMelt) -> Result { + match prepared.token { + PreparedToken::Live(payment) => payment + .confirm() + .map_err(|error| MeltFailure::Failed(error.to_string())), + #[cfg(test)] + PreparedToken::Fake(_) => Err(MeltFailure::Failed( + "a fake prepared melt reached the live effects".to_owned(), + )), + } + } + + fn cancel_melt(&mut self, prepared: PreparedMelt) -> Result<(), MeltFailure> { + match prepared.token { + PreparedToken::Live(payment) => payment + .cancel() + .map_err(|error| MeltFailure::Failed(error.to_string())), + #[cfg(test)] + PreparedToken::Fake(_) => Err(MeltFailure::Failed( + "a fake prepared melt reached the live effects".to_owned(), + )), + } + } + + fn melt_status(&mut self, bolt11: &str) -> Result, String> { + wallet_ops::melt_status_for_invoice_blocking(&self.home, bolt11, None) + .map_err(|error| error.to_string()) + } + + fn melt_status_for_quote(&mut self, quote_id: &str) -> Result, String> { + wallet_ops::melt_status_for_quote_blocking(&self.home, quote_id, None) + .map_err(|error| error.to_string()) + } +} + +/// Who is running the attempt, and therefore whether it pays and how it is journaled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemitTrigger { + /// `maxplayer seller fees remit` without `--confirm`: resolve, quote, print, move nothing. + DryRun, + /// `maxplayer seller fees remit --confirm`: the operator's recovery path. Pays. + Command, + /// The seller node, after a receipt was journaled `Collected::New`. Pays. + Collect, + /// The seller node's retry tick (addendum 2): the loop's own clock, paced by [`RemitBackoff`]. + /// Pays. + Retry, +} + +impl RemitTrigger { + /// Whether this run may journal a plan and melt. + pub fn pays(self) -> bool { + !matches!(self, Self::DryRun) + } + + /// How the attempt is journaled — a dry run is not an attempt. + fn journal_as(self) -> Option { + match self { + Self::DryRun => None, + Self::Command => Some(RemitAttemptTrigger::Command), + Self::Collect => Some(RemitAttemptTrigger::Collect), + Self::Retry => Some(RemitAttemptTrigger::Retry), + } + } +} + +/// Why an attempt declined and moved nothing. [`Self::is_threshold`] separates the expected steady +/// state (nothing owed yet, or not enough to clear the destination's minimum) — which is accrued +/// silently and never journaled as an attempt — from the refusals an operator should see. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// The unremitted balance is zero. + NothingUnremitted, + /// The unremitted balance is below the destination's resolved `minSendable`. + BelowMinimum { unremitted: u64, min_sats: u64 }, + /// The unremitted balance is above the destination's resolved `maxSendable`; this path remits + /// the whole balance or nothing. + AboveMaximum { unremitted: u64, max_sats: u64 }, + /// The mint's melt fee reserve leaves nothing, leaves less than the minimum, or would take more + /// out of the wallet than was accrued. + ReserveDoesNotFit { gross: u64, reserve: u64 }, + /// The mint's melt fee reserve on the net invoice PLUS the proof fees the SDK is expected to + /// charge this wallet (proof-input fee, and a pre-melt swap fee when its proofs do not fit) + /// would take more out of the wallet than was accrued (addendum 8 §1.3): a plan that can never + /// fit is refused here, at planning, not at payment. + FeesDoNotFit { + gross: u64, + reserve: u64, + expected_fees: u64, + }, + /// An earlier attempt's PLANNED row (or a legacy spending row with no quote bound) has a melt + /// quote the mint reports PENDING or unknown: a payment may be settling — hold. A bound + /// spending row in the same state is [`Refusal::SpendingHeld`] instead, so that it renders the + /// one `HELD:` line every bound non-PAID observation renders (addendum 7 §2). + Settling { remittance_id: String }, + /// An earlier attempt's planned row belongs to another process whose lease has not run out, and + /// the mint does not report its quote terminal (addendum 3 §2): UNPAID means "not yet", not + /// "abandoned", so this run may not release it and may not plan on top of it. + HeldByOwner { + remittance_id: String, + owner: String, + lease_until_unix: i64, + }, + /// An earlier attempt's row is SPENDING — its owner's compare-and-set admitted the melt and + /// bound a quote — and the mint does not report that quote PAID (addendum 6 §1.2). A payment + /// prepared under the bound quote may still reach the mint after ANY local observation — the + /// mint pays an UNPAID or FAILED quote however long ago it expired (CDK 0.17.2, verdict at + /// 6fc77e1 §4) — so no run releases this row on FAILED, on expiry, on the clock, or on the + /// wallet not knowing the quote: it is held, and its receipts with it, until the mint reports + /// the quote PAID. Whoever asks, however late. A stuck row is an operator's decision (no + /// override exists in this round), never a timeout's. Also the hold for a legacy spending row + /// with no quote bound (`quote_id: None`) while its invoice's quotes are UNPAID / absent. + SpendingHeld { + remittance_id: String, + owner: String, + spending_since_unix: i64, + /// The quote the fence bound (`None` only for a row a v12 binary admitted). + quote_id: Option, + /// What the mint (or the wallet) said about that quote when this run asked. + observed: String, + /// The receipts pinned to the row: the row's gross, held from remittance while it stands. + held_sats: u64, + }, + /// The pre-spend gate refused: between journaling the plan and paying it, this process lost its + /// claim on the row (another process reconciled it), or too little lease remained to start a + /// payment safely. Nothing was spent. + OwnershipLost { + remittance_id: String, + reason: String, + }, + /// Reconciliation decided to release the in-flight row, and the conditional release then + /// changed ZERO rows: the row moved under this run between the decision and the write (its + /// owner was admitted, or another process resolved it). Nothing written; re-run to reconcile + /// against the row as it now stands (addendum 5 §1, rule 2). + RowChangedUnderMe { remittance_id: String }, + /// The store refused to journal the plan (a row already in flight, the balance moved under us, + /// an invoice already used). + PlanRefused(String), +} + +impl Refusal { + /// The two refusals that are the expected steady state for a small seller: not an error, not an + /// attempt, nothing to journal — the balance accumulates until it clears the minimum. + pub fn is_threshold(&self) -> bool { + matches!(self, Self::NothingUnremitted | Self::BelowMinimum { .. }) + } +} + +impl fmt::Display for Refusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NothingUnremitted => write!(formatter, "nothing unremitted"), + Self::BelowMinimum { + unremitted, + min_sats, + } => write!( + formatter, + "unremitted {unremitted} sats is below the destination's minimum of {min_sats} sats" + ), + Self::AboveMaximum { + unremitted, + max_sats, + } => write!( + formatter, + "unremitted {unremitted} sats exceeds the destination's maximum of {max_sats} sats" + ), + Self::ReserveDoesNotFit { gross, reserve } => write!( + formatter, + "the mint's melt fee reserve ({reserve} sats) does not fit inside the {gross} sats accrued" + ), + Self::FeesDoNotFit { + gross, + reserve, + expected_fees, + } => write!( + formatter, + "the mint's melt fee reserve ({reserve} sats) plus the expected proof fees ({expected_fees} sats) do not fit inside the {gross} sats accrued" + ), + Self::Settling { remittance_id } => write!( + formatter, + "remittance {remittance_id} is still settling at the mint" + ), + Self::HeldByOwner { + remittance_id, + owner, + lease_until_unix, + } => write!( + formatter, + "remittance {remittance_id} is planned by another live process ({owner}, lease until unix {lease_until_unix}) and its quote is not terminal; not releasing a live payer's intent" + ), + Self::SpendingHeld { + remittance_id, + owner, + spending_since_unix, + quote_id, + observed, + held_sats, + } => write!( + formatter, + "HELD: remittance {remittance_id} is SPENDING (admitted by {owner} at unix {spending_since_unix}), {}; {observed}; {held_sats} sats of receipts stay pinned to it — a spending row is released by nobody and on no clock; it settles only when the mint reports that quote PAID; an operator decision, not a timeout, resolves it", + match quote_id { + Some(quote_id) => format!("bound to melt quote {quote_id}"), + None => "with no quote bound (admitted before v13)".to_owned(), + } + ), + Self::OwnershipLost { + remittance_id, + reason, + } => write!( + formatter, + "refused before spending: remittance {remittance_id} — {reason}" + ), + Self::RowChangedUnderMe { remittance_id } => write!( + formatter, + "remittance {remittance_id} changed under this run between the release decision and the release itself; nothing written" + ), + Self::PlanRefused(reason) => write!(formatter, "plan refused: {reason}"), + } + } +} + +/// How one run of [`remit`] ended. `Err` from [`remit`] is the other ending: an effect failed before +/// or during reconciliation and nothing was paid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemitOutcome { + /// The plan was printed and nothing moved. + DryRun, + /// The melt settled and the remittance is journaled settled. + Paid { + remittance_id: String, + net_sats: u64, + melt_fee_sats: u64, + }, + /// Declined; nothing moved. + Refused(Refusal), + /// The plan was journaled, the fence admitted the melt and the payment of the bound quote then + /// failed (or was refused locally, the quote being inside its margin of expiry): the row stays + /// `spending`, bound to its quote, and the next attempt asks the mint about that quote — + /// settled if it reports PAID, otherwise HELD, with its receipts, until it does (addendum 6 + /// §1.2). No later observation releases it: a payment prepared under the quote may still + /// reach the mint, and the mint pays an UNPAID or FAILED quote regardless of its expiry. + MeltFailed { + remittance_id: String, + error: String, + }, + /// The plan was journaled and the payment was REFUSED before the fence, nothing spent — the + /// payment quote would have taken more than the accrued gross (addendum 3 §1), or expires + /// inside the spending margin. The row was still planned and this process's own, so it + /// released it: the balance is unremitted again, the attempt is journaled failed, and the + /// backoff escalates. The next attempt plans a fresh row and raises fresh quotes. + MeltRefused { + remittance_id: String, + reason: String, + }, + /// The plan was journaled and the payment quote could not be raised (mint unreachable, or it + /// quoted a different amount). Nothing spent; the planned row, this process's own, was + /// released; journaled failed; backoff escalates. + QuoteFailed { + remittance_id: String, + error: String, + }, +} + +/// What reconciliation decides about the one in-flight row, from the mint's answer about its +/// quote and the row's state and ownership (addendum 3 §2.1, addendum 5 §1 rule 2). Pure, so the +/// rule is tested as a table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reconcile { + /// The mint reports the quote PAID: settle, keep the receipts discharged, never melt again. + Settle, + /// Release the receipts back to unremitted — by the conditional transition `on`, which changes + /// zero rows if the row is no longer as this decision found it. `reason` is printed and + /// journaled. + Release { reason: String, on: ReleaseOn }, + /// Leave the row exactly as it is and refuse this run; the reason is printed and journaled. + Hold(Refusal), +} + +/// The release rule. `status` is the mint's answer about the row's **bound quote, by id**, when the +/// row is `spending` with a quote bound (addendum 5 §1, rule 2) — and about the invoice's quote(s) +/// otherwise (a `planned` row has no quote bound yet; a spending row admitted before v13 has none). +/// +/// The in-flight row is **settled** when the mint reports the quote **PAID**, whatever its state +/// or owner. +/// +/// A **`spending` row bound to a quote** — every row this binary admits — has **no other +/// transition here** (addendum 6 §1.2): on UNPAID at any age, FAILED, PENDING, UNKNOWN, or the +/// wallet not knowing the quote, it is **held**, and its receipts with it, until the mint reports +/// the quote PAID — every one of those five observations as [`Refusal::SpendingHeld`], whose +/// one-line read-out names the row, the bound quote, what the mint said and the held receipts +/// (addendum 6 §1.3; addendum 7 §2); nothing written. Nothing about it is inferred from a clock: +/// `now_unix` is not consulted for a bound spending row. The reason is the mint itself: a payment its owner prepared under the +/// bound quote may still be on its way, and the mint (CDK 0.17.2 as the verdict at 6fc77e1 §4 +/// read it) pays an UNPAID **or FAILED** quote with no expiry check — so "FAILED" and "expired" +/// are not cancellation, and a release on either could make the same gross payable twice. The +/// hold is therefore permanent until PAID or an operator's decision (none automated here); the +/// exclusion that keeps a second attempt from planning on top of the held row is +/// [`SellerStore::plan_remittance`]'s in-flight refusal, which has no time term either. +/// +/// A **`planned` row** (never admitted: nothing spent against it, and once released its owner's +/// fence changes zero rows) is **released** — always by the conditional transition that carries +/// the reason ([`ReleaseOn`]), never unconditionally — when the invoice's quote is **FAILED** or +/// **UNPAID and expired** ([`ReleaseOn::TerminalQuotePlanned`]); or when the mint reports +/// **UNPAID** / the wallet never raised a quote AND either the row is **this process's own** (a +/// process runs one attempt at a time, so its earlier attempt is over — [`ReleaseOn::OwnPlanned`]) +/// or the owner's **lease has run out** (the owner is provably not spending: its fence refuses +/// inside [`SPEND_MARGIN`] of the lease's end and, once past it, changes zero rows — +/// [`ReleaseOn::LeaseExpired`]). It is **held** when its quote is PENDING or UNKNOWN, and when it +/// is UNPAID / absent but another process's lease still stands (UNPAID means "not yet"). +/// +/// A **legacy `spending` row with no quote bound** (a v12 admission; out of this round's scope, +/// addendum 6 §1.5) keeps the v12 rule as delivered: released through +/// [`ReleaseOn::TerminalUnboundSpending`] on FAILED or UNPAID-and-expired, held otherwise. +pub fn reconcile_decision( + row: &FeeRemittance, + status: Option<&MeltQuoteStatus>, + my_owner: &str, + now_unix: i64, +) -> Reconcile { + let spending = row.state == RemittanceState::Spending; + let bound = if spending { + row.spending_quote_id.as_deref() + } else { + None + }; + // PAID settles the row whatever its state or owner. + if status.is_some_and(|status| status.state == MeltQuoteState::Paid) { + return Reconcile::Settle; + } + let observed = match status { + None => "this wallet holds no such melt quote".to_owned(), + Some(status) => format!( + "mint {} reports melt quote {} {} (expiry unix {})", + status.mint_url, status.quote_id, status.state, status.expiry_unix + ), + }; + let hold_spending = |quote_id: Option<&str>| { + Reconcile::Hold(Refusal::SpendingHeld { + remittance_id: row.remittance_id.clone(), + owner: row.owner.clone().unwrap_or_default(), + spending_since_unix: row.spending_since_unix.unwrap_or(row.created_at_unix), + quote_id: quote_id.map(str::to_owned), + observed: observed.clone(), + held_sats: row.gross_sats, + }) + }; + // A bound spending row: held on everything but PAID — UNPAID, FAILED, PENDING, UNKNOWN, or the + // wallet not knowing the quote — as ONE refusal, so every such observation renders the same + // single `HELD:` line naming the row, the quote, what the mint said and the held receipts + // (addendum 6 §1.3; addendum 7 §2). No clock, no terminality inference. + if let (true, Some(quote_id)) = (spending, bound) { + return hold_spending(Some(quote_id)); + } + // From here: a PLANNED row, or a legacy spending row with no quote bound. + // PENDING / UNKNOWN: a payment may be settling — hold, whatever the row (its own refusal, so + // the read-out says "settling", not "held": nothing is pinned to a spending mark here). + if status.is_some_and(|status| { + matches!( + status.state, + MeltQuoteState::Pending | MeltQuoteState::Unknown + ) + }) { + return Reconcile::Hold(Refusal::Settling { + remittance_id: row.remittance_id.clone(), + }); + } + let unpaid_reason = match status { + None => "the wallet never raised a melt quote for its invoice — no sats left the wallet" + .to_owned(), + Some(status) => format!( + "mint {} reports melt quote {} {} — no sats left the wallet", + status.mint_url, status.quote_id, status.state + ), + }; + let terminal_release = |reason: String| { + let on = if spending { + ReleaseOn::TerminalUnboundSpending + } else { + ReleaseOn::TerminalQuotePlanned + }; + Reconcile::Release { reason, on } + }; + match status.map(|status| status.state) { + Some(MeltQuoteState::Paid) => Reconcile::Settle, + Some(MeltQuoteState::Pending) | Some(MeltQuoteState::Unknown) => { + Reconcile::Hold(Refusal::Settling { + remittance_id: row.remittance_id.clone(), + }) + } + Some(MeltQuoteState::Failed) => terminal_release(format!( + "{unpaid_reason}; FAILED at the mint, and the row was never admitted to spend under this binary's fence — nothing was spent against it" + )), + Some(MeltQuoteState::Unpaid) + if status.is_some_and(|status| status.expired_at(now_unix)) => + { + terminal_release(format!( + "{unpaid_reason}; the quote expired at unix {}, and the row was never admitted to spend under this binary's fence — nothing was spent against it", + status.map(|status| status.expiry_unix).unwrap_or_default() + )) + } + Some(MeltQuoteState::Unpaid) | None => { + if spending { + hold_spending(None) + } else if row.owner.as_deref() == Some(my_owner) { + Reconcile::Release { + reason: format!( + "{unpaid_reason}; the row is this process's own earlier attempt, which is over" + ), + on: ReleaseOn::OwnPlanned { + owner: my_owner.to_owned(), + }, + } + } else if row.lease_expired(now_unix) { + Reconcile::Release { + reason: format!( + "{unpaid_reason}; its owner's lease ran out at unix {} (owner {})", + row.lease_until_unix.unwrap_or(row.created_at_unix), + row.owner.as_deref().unwrap_or("none recorded") + ), + on: ReleaseOn::LeaseExpired { now_unix }, + } + } else { + Reconcile::Hold(Refusal::HeldByOwner { + remittance_id: row.remittance_id.clone(), + owner: row.owner.clone().unwrap_or_default(), + lease_until_unix: row.lease_until_unix.unwrap_or(row.created_at_unix), + }) + } + } + } +} + +/// What the run learned before it ended, for the attempt journal. +#[derive(Default)] +struct AttemptTrace { + unremitted: Option, + remittance_id: Option, +} + +/// **The remit entry point.** Runs one attempt against `store` through `effects`, printing every +/// step to `out` in the seller's words, and — when the trigger pays — journals the attempt and its +/// outcome (`fee_remit_attempts`) so a failing payout is visible. Refusals at the threshold +/// ([`Refusal::is_threshold`]) are the expected steady state and are not journaled. +/// +/// `Err` is an effect failure (LNURL host, mint quote, reconciliation query) before any plan was +/// journaled, or the store failing to write; nothing was paid. A melt that fails AFTER the plan is +/// [`RemitOutcome::MeltFailed`], not `Err`, because there is a row to reconcile. +/// +/// Order of operations, and why: +/// 1. Reconcile any in-flight row first — a payment may be in flight from an interrupted run, and +/// nothing may be planned on top of it. PAID ⇒ settle. A `spending` row bound to a quote ⇒ +/// otherwise HOLD, whatever the mint says and however old the quote (addendum 6 §1.2). A +/// `planned` row: FAILED / expired ⇒ release; UNPAID / no quote ⇒ release only if the row is +/// this process's own or its owner's lease has run out, else refuse; PENDING ⇒ refuse this run +/// ([`reconcile_decision`]). +/// 2. Read the unremitted total. Zero ⇒ refuse (nothing to do), before any network. +/// 3. Resolve the destination; refuse below its minimum with the shortfall (expected for small +/// sellers, not an error). +/// 4. Probe the melt fee reserve on an invoice for the GROSS, then invoice for `gross − reserve` so +/// the fee comes out of the accrued amount — a seller never pays more than it accrued — and check +/// the second quote still fits. +/// 5. Print the plan. A dry run stops here. +/// 6. Journal the plan (pins the receipts, records this process as owner under [`REMIT_LEASE`]; +/// refuses a duplicate). Then, in this order (addendum 5 §1 rule 1): raise the PAYMENT quote +/// and check the ceiling against its amount and reserve — refused ⇒ release the row (still +/// planned, ours, nothing spent) and journal failed; pass the fence — one compare-and-set that +/// marks the row spending and BINDS that quote, with the clock read inside the store call and +/// more than [`SPEND_MARGIN`] of lease left; pay that quote BY ID, re-checking the ceiling +/// immediately before `prepare_melt`; settle. A payment ERROR after the fence leaves the row +/// `spending`, bound to its quote, for step 1 of the next run — which asks the mint about THAT +/// quote, never raises another for the row, settles on PAID and otherwise holds. +pub fn remit( + store: &SellerStore, + effects: &mut dyn RemitEffects, + trigger: RemitTrigger, + now_unix: i64, + out: &mut dyn Write, +) -> Result { + let mut trace = AttemptTrace::default(); + let result = remit_inner(store, effects, trigger, now_unix, out, &mut trace); + let attempt = trigger.journal_as().and_then(|journal_trigger| { + attempt_record(store, journal_trigger, now_unix, &trace, &result) + }); + if let Some(attempt) = attempt + && let Err(error) = store.record_remit_attempt(&attempt) + { + let _ = writeln!( + out, + "WARNING: could not journal this attempt ({error}); the outcome above stands." + ); + } + result +} + +/// The attempt row for a finished run, or `None` when the run is not an attempt (a dry run, or a +/// refusal at the threshold). +fn attempt_record( + store: &SellerStore, + trigger: RemitAttemptTrigger, + now_unix: i64, + trace: &AttemptTrace, + result: &Result, +) -> Option { + let (outcome, detail, remittance_id) = match result { + Ok(RemitOutcome::DryRun) => return None, + Ok(RemitOutcome::Refused(refusal)) if refusal.is_threshold() => return None, + Ok(RemitOutcome::Paid { + remittance_id, + net_sats, + melt_fee_sats, + }) => ( + RemitAttemptOutcome::Paid, + format!( + "paid {net_sats} sats to {PLATFORM_FEE_ADDRESS} (melt fee {melt_fee_sats} sats)" + ), + Some(remittance_id.clone()), + ), + Ok(RemitOutcome::Refused(refusal)) => ( + RemitAttemptOutcome::Refused, + refusal.to_string(), + trace.remittance_id.clone(), + ), + Ok(RemitOutcome::MeltFailed { + remittance_id, + error, + }) => ( + RemitAttemptOutcome::Failed, + format!("melt failed: {error}"), + Some(remittance_id.clone()), + ), + Ok(RemitOutcome::MeltRefused { + remittance_id, + reason, + }) => ( + RemitAttemptOutcome::Failed, + format!("refused before spending: {reason}"), + Some(remittance_id.clone()), + ), + Ok(RemitOutcome::QuoteFailed { + remittance_id, + error, + }) => ( + RemitAttemptOutcome::Failed, + format!("payment quote failed: {error}"), + Some(remittance_id.clone()), + ), + Err(error) => ( + RemitAttemptOutcome::Failed, + error.clone(), + trace.remittance_id.clone(), + ), + }; + // A run that failed before it read the ledger still journals the balance it was attempting. + let unremitted = trace.unremitted.or_else(|| { + store + .accrued_fees() + .ok() + .map(|accrued| accrued.unremitted_fee_sats) + }); + Some(RemitAttempt { + attempt_id: 0, + started_at_unix: now_unix, + trigger, + unremitted_sats: unremitted.unwrap_or(0), + outcome, + detail, + remittance_id, + }) +} + +fn remit_inner( + store: &SellerStore, + effects: &mut dyn RemitEffects, + trigger: RemitTrigger, + now_unix: i64, + out: &mut dyn Write, + trace: &mut AttemptTrace, +) -> Result { + // 0. For the operator: what the recent attempts did. The collect path skips this — its output is + // the node's log, and the journal is what the command prints. + if trigger != RemitTrigger::Collect { + print_recent_attempts(store, out)?; + } + + // 1. Reconcile an in-flight attempt before anything else — under the release rule + // (`reconcile_decision`): never a live payer's intent. + if let Some(active) = store + .in_flight_remittance() + .map_err(|error| format!("read remittances: {error}"))? + { + trace.remittance_id = Some(active.remittance_id.clone()); + let _ = writeln!( + out, + "Reconciling in-flight remittance {} (planned at unix {} by {}, lease until unix {}: {} sats to {}, gross {} sats){}", + active.remittance_id, + active.created_at_unix, + active.owner.as_deref().unwrap_or("nobody recorded"), + active + .lease_until_unix + .map(|until| until.to_string()) + .unwrap_or_else(|| "none recorded".to_owned()), + active.net_sats, + active.destination, + active.gross_sats, + match (active.state, active.spending_quote_id.as_deref()) { + (RemittanceState::Spending, Some(quote_id)) => format!( + " — SPENDING since unix {}, bound to melt quote {quote_id}: asking the mint about that quote by id", + active.spending_since_unix.unwrap_or(active.created_at_unix) + ), + (RemittanceState::Spending, None) => format!( + " — SPENDING since unix {} with no quote bound (admitted before quotes were bound): asking the mint about its invoice's quotes", + active.spending_since_unix.unwrap_or(active.created_at_unix) + ), + _ => String::new(), + } + ); + // A spending row bound to a quote is reconciled against THAT quote, by id — never against + // "the most alive quote for the invoice", which cannot see a quote nobody has raised and + // must not speak for the one the owner is paying (addendum 5 §1, rule 2). + let status = match (active.state, active.spending_quote_id.as_deref()) { + (RemittanceState::Spending, Some(quote_id)) => { + effects.melt_status_for_quote(quote_id)? + } + _ => effects.melt_status(&active.bolt11)?, + }; + let decision = reconcile_decision(&active, status.as_ref(), effects.owner(), now_unix); + effects.after_decision(&active, &decision); + match decision { + Reconcile::Settle => { + let status = status.expect("Settle is decided only on a PAID status"); + let settlement = RemitSettlement { + net_paid_sats: Some(status.amount_sats), + melt_fee_sats: None, + melt_fee_reserve_sats: Some(status.fee_reserve_sats), + melt_quote_id: Some(status.quote_id.clone()), + settled_by: SettledBy::Reconciliation, + }; + store + .settle_remittance(&active.remittance_id, &settlement, now_unix) + .map_err(|error| format!("record settled remittance: {error}"))?; + let _ = writeln!( + out, + " mint {} reports melt quote {} PAID — recorded as settled by reconciliation: {} sats reached {}; Lightning fee at most {} sats (the quote's reserve); the inclusive melt fee (Lightning + actual proof input fee) is recorded as not observed — the mint reports PAID, not what it kept", + status.mint_url, + status.quote_id, + status.amount_sats, + active.destination, + status.fee_reserve_sats + ); + } + Reconcile::Release { reason, on } => { + // The release is CONDITIONAL on the row still being as the decision found it. Zero + // rows changed ⇒ the row moved under us (its owner was admitted, or another process + // resolved it): HOLD — nothing written, this run refused, re-run to reconcile. + match store + .release_remittance(&active.remittance_id, &on, now_unix) + .map_err(|error| format!("record failed remittance: {error}"))? + { + Some(_) => { + let _ = writeln!( + out, + " {reason}; released {} sats back to unremitted (release condition: {})", + active.gross_sats, + on.describe() + ); + } + None => { + let _ = writeln!( + out, + " {reason} — but the row changed under me between that decision and the release (condition: {}): nothing written. REFUSED — nothing moved by this run; re-run to reconcile the row as it now stands.", + on.describe() + ); + return Ok(RemitOutcome::Refused(Refusal::RowChangedUnderMe { + remittance_id: active.remittance_id, + })); + } + } + } + Reconcile::Hold(refusal) => { + let _ = writeln!( + out, + " {}. REFUSED — nothing moved by this run; re-run later to reconcile.", + match &refusal { + Refusal::Settling { .. } => format!( + "mint {} reports melt quote {} {}: the payment is still settling", + status.as_ref().map(|s| s.mint_url.as_str()).unwrap_or("?"), + status.as_ref().map(|s| s.quote_id.as_str()).unwrap_or("?"), + status + .as_ref() + .map(|s| s.state.to_string()) + .unwrap_or_default() + ), + other => other.to_string(), + } + ); + return Ok(RemitOutcome::Refused(refusal)); + } + } + trace.remittance_id = None; + } + + // 2. What is owed. + let accrued = store + .accrued_fees() + .map_err(|error| format!("read receipts: {error}"))?; + let gross = accrued.unremitted_fee_sats; + trace.unremitted = Some(gross); + let _ = writeln!( + out, + "Accrued platform fee: {} sats all-time — {} sats remitted, {} sats unremitted", + accrued.total_fee_sats, accrued.remitted_fee_sats, gross + ); + if gross == 0 { + let _ = writeln!(out, "Nothing to remit. REFUSED — nothing moved."); + return Ok(RemitOutcome::Refused(Refusal::NothingUnremitted)); + } + + // 3. The destination and its bounds. + let address = + LightningAddress::parse(PLATFORM_FEE_ADDRESS).map_err(|error| error.to_string())?; + let pay = effects.pay_request(&address)?; + let min_sats = pay.min_sendable_sats(); + let max_sats = pay.max_sendable_sats(); + let _ = writeln!( + out, + "Destination: {address} (LNURL-pay; accepts {min_sats} to {max_sats} sats)" + ); + if gross < min_sats { + let _ = writeln!( + out, + "REFUSED — unremitted {gross} sats is below the destination's minimum of {min_sats} sats ({} sats short). The balance accumulates until it clears the minimum. Nothing moved.", + min_sats - gross + ); + return Ok(RemitOutcome::Refused(Refusal::BelowMinimum { + unremitted: gross, + min_sats, + })); + } + if gross > max_sats { + let _ = writeln!( + out, + "REFUSED — unremitted {gross} sats exceeds the destination's maximum of {max_sats} sats; this path remits the whole balance or nothing. Nothing moved." + ); + return Ok(RemitOutcome::Refused(Refusal::AboveMaximum { + unremitted: gross, + max_sats, + })); + } + + // 4. The melt fee comes OUT of the gross. Probe the reserve on the gross, then invoice the net. + let probe = effects.invoice(&pay, gross)?; + let probe_estimate = effects.melt_estimate(&probe.bolt11)?; + if probe_estimate.amount_sats != gross { + return Err(format!( + "mint {} quoted {} sats for a {gross}-sat invoice; refusing", + probe_estimate.mint_url, probe_estimate.amount_sats + )); + } + let reserve = probe_estimate.fee_reserve_sats; + // Addendum 11 §1 (B/N1): the ONLY refusal taken on the gross probe is the reserve alone — the + // helper below searches invoices in 1..=gross − reserve, so it needs gross − reserve > 0. The + // proof fees the SDK is expected to charge THIS wallet (input fee on the proofs it would send, + // plus a pre-melt swap fee when its proofs do not fit) are NOT a gate here: the probe's + // `expected_fees_sats` is an estimate on the GROSS-sized layout, and round 9's veto on + // `reserve + expected ≥ gross` refused remittances a smaller invoice pays (verdict 1ee5cb2 §4.2: + // 3/0/1000/[32] ⇒ probe expects 2 ≥ 3 − 0 … refused; invoice 1 pays with a 3-sat debit). Fees + // are checked by the exact search over candidate invoices (`plan_confirmable_invoice`), then + // re-checked on the quote actually raised; `FeesDoNotFit` is returned only when NO candidate + // fits. So a payment that can fit within the gross IS planned (the largest such invoice), and + // one that never can is refused here at planning, not at payment. The probe's figure is kept + // for the printed lines only. + let expected = probe_estimate.expected_fees_sats; + let expected_clause = |expected: u64| { + if expected > 0 { + format!(" plus {expected} sats of expected proof fees") + } else { + String::new() + } + }; + if reserve >= gross { + let _ = writeln!( + out, + "REFUSED — mint {} needs a melt fee reserve of {reserve} sats to pay {gross} sats, which leaves nothing for the destination. The balance accumulates. Nothing moved.", + probe_estimate.mint_url + ); + return Ok(RemitOutcome::Refused(Refusal::ReserveDoesNotFit { + gross, + reserve, + })); + } + // Addendum 9 §1.2: the invoice is the largest one `confirm` can actually pay within the gross — + // the SDK's swap target must cover invoice + reserve + the input fee it RECOMPUTES on the + // swapped proofs, and that plus the swap fee must fit the gross. Searched locally on the + // probe's reserve, swap fee and keyset ppk; re-checked below on the quote actually raised. + let net = match plan_confirmable_invoice( + gross, + reserve, + probe_estimate.expected_swap_fee_sats, + probe_estimate.input_fee_ppk, + ) { + Some(net) => net, + None => { + let _ = writeln!( + out, + "REFUSED — no invoice fits: at mint {}'s {} ppk proof fee, no amount up to {} sats ({gross} sats less the {reserve} sats melt fee reserve) can be paid for at most {gross} sats once the SDK recomputes its proof input fee on the swapped proofs{}. The balance accumulates. Nothing moved.", + probe_estimate.mint_url, + probe_estimate.input_fee_ppk, + gross - reserve, + expected_clause(expected) + ); + return Ok(RemitOutcome::Refused(Refusal::FeesDoNotFit { + gross, + reserve, + expected_fees: expected, + })); + } + }; + if net < min_sats { + let _ = writeln!( + out, + "REFUSED — after the mint's melt fee reserve ({reserve} sats){} the {gross} sats unremitted leaves {net} sats, below the destination's minimum of {min_sats} sats ({} sats short). The balance accumulates. Nothing moved.", + expected_clause(expected), + min_sats - net + ); + return Ok(RemitOutcome::Refused(if expected > 0 { + Refusal::FeesDoNotFit { + gross, + reserve, + expected_fees: expected, + } + } else { + Refusal::ReserveDoesNotFit { gross, reserve } + })); + } + let (invoice, estimate) = if reserve == 0 && expected == 0 { + (probe, probe_estimate) + } else { + let invoice = effects.invoice(&pay, net)?; + let estimate = effects.melt_estimate(&invoice.bolt11)?; + if estimate.amount_sats != net { + return Err(format!( + "mint {} quoted {} sats for a {net}-sat invoice; refusing", + estimate.mint_url, estimate.amount_sats + )); + } + (invoice, estimate) + }; + let reserve_ceiling = net.saturating_add(estimate.fee_reserve_sats); + if reserve_ceiling > gross { + let _ = writeln!( + out, + "REFUSED — mint {} quotes a {} sats fee reserve on {net} sats, so up to {reserve_ceiling} sats would leave the wallet against {gross} sats accrued. A seller never pays more than it accrued. Nothing moved.", + estimate.mint_url, estimate.fee_reserve_sats + ); + return Ok(RemitOutcome::Refused(Refusal::ReserveDoesNotFit { + gross, + reserve: estimate.fee_reserve_sats, + })); + } + // Addendum 10 §1.1 re-check on the quote actually raised (its reserve, and this wallet's SDK + // figures for THIS amount) — the SAME bound the planner searched with and the wallet's prepared- + // melt gate takes: `confirm` must succeed (target ≥ need + the input fee it RECOMPUTES) and the + // worst-case debit — invoice + reserve + ACTUAL input fee + swap fee — must fit the gross. The + // PREPARED total (invoice + reserve + estimated input fee + swap fee) is NOT a gate: it is an + // estimate that can exceed the final debit, and round 8's early return on it refused fitting + // remittances (verdict 4714623 §3.2: 19/2/1000/[32] ⇒ invoice 13, prepared total 20 > 19, actual + // debit 19 ≤ 19). + let planned_estimated_input = estimate + .expected_fees_sats + .saturating_sub(estimate.expected_swap_fee_sats); + let planned = match confirm_bound( + net, + estimate.fee_reserve_sats, + Some(planned_estimated_input), + estimate.expected_swap_fee_sats, + estimate.input_fee_ppk, + estimate.input_fee_ppk > 0, + gross, + ) { + Ok(bound) => bound, + Err(ConfirmShortfall::TargetShort { + bound, + needed_after_swap_sats, + }) => { + let _ = writeln!( + out, + "REFUSED — mint {} quotes a {} sats fee reserve on {net} sats; the wallet would swap to {} sats and the SDK's actual proof input fee on those proofs is {} sats (estimate {planned_estimated_input} sats), so the payment would need {needed_after_swap_sats} sats and the SDK would refuse after its swap. A seller never pays more than it accrued. Nothing moved.", + estimate.mint_url, + estimate.fee_reserve_sats, + bound.target_sats, + bound.actual_input_fee_sats + ); + return Ok(RemitOutcome::Refused(Refusal::FeesDoNotFit { + gross, + reserve: estimate.fee_reserve_sats, + expected_fees: estimate.expected_fees_sats, + })); + } + Err(ConfirmShortfall::OverCeiling { bound, .. }) => { + let _ = writeln!( + out, + "REFUSED — mint {} quotes a {} sats fee reserve on {net} sats; with the SDK's actual proof input fee of {} sats on the swapped proofs (estimate {planned_estimated_input} sats) and a {} sats swap fee the payment would cost up to {} sats against {gross} sats accrued. A seller never pays more than it accrued. Nothing moved.", + estimate.mint_url, + estimate.fee_reserve_sats, + bound.actual_input_fee_sats, + bound.swap_fee_sats, + bound.worst_debit_sats + ); + return Ok(RemitOutcome::Refused(Refusal::FeesDoNotFit { + gross, + reserve: estimate.fee_reserve_sats, + expected_fees: estimate.expected_fees_sats, + })); + } + Err(ConfirmShortfall::DifferentInvoice { .. }) => { + unreachable!("confirm_bound without a ceiling never compares invoices") + } + }; + let planned_actual_input = planned.actual_input_fee_sats; + let worst_debit = planned.worst_debit_sats; + + // 5. The plan, in the seller's words. + let _ = writeln!( + out, + "Plan:\n unremitted platform fee (gross): {gross} sats\n mint melt fee reserve (bounds the Lightning fee): {} sats — taken out of the gross, never on top\n invoice amount ({address} receives): {net} sats\n leaves your wallet: at most {worst_debit} sats (≤ {gross}); unused reserve returns as change", + estimate.fee_reserve_sats + ); + if estimate.expected_fees_sats > 0 { + let _ = writeln!( + out, + " expected proof fees (SDK estimate, bounded exactly at payment): {} sats = estimated proof input fee {planned_estimated_input} sats + swap fee {} sats; actual proof input fee the SDK recomputes on the swapped proofs: {planned_actual_input} sats ⇒ worst case {worst_debit} sats leaves the wallet (≤ {gross}) = invoice {net} + reserve {} (bounds the Lightning fee) + actual proof input fee {planned_actual_input} + swap fee {}; the inclusive melt fee (Lightning + actual proof input fee) is known only at payment", + estimate.expected_fees_sats, + estimate.expected_swap_fee_sats, + estimate.fee_reserve_sats, + estimate.expected_swap_fee_sats + ); + } + if let Some(note) = &estimate.expected_fees_note { + let _ = writeln!( + out, + " proof fees not estimable now ({note}); the ceiling still bounds them exactly at payment" + ); + } + let _ = writeln!( + out, + " mint: {} (melt quote {})\n invoice payment hash: {}", + estimate.mint_url, estimate.quote_id, invoice.payment_hash + ); + if !trigger.pays() { + let _ = writeln!( + out, + "DRY RUN — nothing moved. Re-run with --confirm to pay {net} sats to {address}." + ); + return Ok(RemitOutcome::DryRun); + } + + // 6. Journal (as this process, under a lease), pass the pre-spend gate, pay under the ceiling, + // settle. + let plan = RemittancePlan { + payment_hash: invoice.payment_hash.clone(), + gross_sats: gross, + net_sats: net, + melt_fee_reserve_sats: estimate.fee_reserve_sats, + destination: address.to_string(), + bolt11: invoice.bolt11.clone(), + melt_quote_id: Some(estimate.quote_id.clone()), + }; + let lease_until_unix = now_unix.saturating_add(lease_secs(REMIT_LEASE)); + let planned = match store.plan_remittance(&plan, effects.owner(), lease_until_unix, now_unix) { + Ok(planned) => planned, + Err(PlanRefused::Store(error)) => return Err(format!("journal remittance: {error}")), + Err(refused) => { + let _ = writeln!(out, "REFUSED — {refused}. Nothing moved."); + if let PlanRefused::InFlight(active) = &refused { + trace.remittance_id = Some(active.remittance_id.clone()); + } + return Ok(RemitOutcome::Refused(Refusal::PlanRefused( + refused.to_string(), + ))); + } + }; + trace.remittance_id = Some(planned.remittance_id.clone()); + let _ = writeln!( + out, + "Journaled remittance {} covering {} receipt{} (owner {}, lease until unix {lease_until_unix}); paying...", + planned.remittance_id, + planned.receipts, + if planned.receipts == 1 { "" } else { "s" }, + effects.owner() + ); + effects.after_plan(&planned); + + // The spend, in the order addendum 5 §1 rule 1 fixes, with addendum 9 §1.1's actual-arithmetic + // bound inserted BEFORE the fence (one model, addendum 10 §2 / addendum 11): + // 1. raise the PAYMENT quote Q (spends nothing) and check the ceiling against Q's amount and + // reserve ALONE — the reserve-only precheck, before any proof is selected; refused ⇒ + // `refuse_before_fence`: the row is NOT written (stays planned, unbound, ours, receipts + // pinned), the attempt is journaled failed, nothing spent; the next attempt's + // reconciliation releases it as our own earlier attempt and re-plans; + // 1a. if Q's live reserve differs from the estimate and the planned invoice would not confirm, + // re-plan ONCE onto a new invoice and a live quote for it (one conditional update on the + // still-planned row); a second mismatch is refused as in step 1; + // 1b. PREPARE the melt of Q: the wallet selects and reserves proofs in its OWN database and + // states the SDK's PREPARED proof-input fee and pre-melt swap fee. Prepare may fetch mint + // metadata/keysets (a GET); it posts nothing proof-bearing or fee-bearing — pinned CDK + // 0.17.2 melt/saga/mod.rs:286–460 writes only the local store; the swap :687–697 and the + // melt request :907–911 both live inside `confirm`. The PREPARED total is not a gate; + // 1c. run the SDK's post-swap arithmetic on the prepared figures: the ACTUAL input fee + // recomputed on the swapped split, and invoice + reserve + actual input fee + swap fee + // against the gross. Over, or the SDK would refuse after its swap ⇒ the prepared melt is + // cancelled (proofs back to Unspent, locally) and this is refused exactly like step 1: row + // stays planned and pinned, nothing spent, no bound Spending row is ever held for a fee; + // 2. the fence — ONE compare-and-set in the store advances the row planned → spending AND + // BINDS Q to it, only if it is still planned, still ours, and its lease ends more than + // SPEND_MARGIN after the clock as read INSIDE the store call, after its lock (however long + // we paused between the plan and this line, that time counts, and no pause between reading + // the clock and the write can make it stale). Zero rows changed ⇒ cancel the prepared melt, + // refuse, no spend. Once admitted, the row is released by nobody on time: only the mint's + // verdict on Q resolves it; + // 3. CONFIRM the prepared melt of Q — never a second quote for a row we hold; the swap (if + // any) and the melt request happen here and nowhere else. + let ceiling = MeltCeiling { + max_debit_sats: gross, + invoice_sats: net, + planned_quote_id: Some(estimate.quote_id.clone()), + }; + let effects_owner_for_release = effects.owner().to_owned(); + let release_own_planned = |store: &SellerStore, out: &mut dyn Write| -> Result { + // Nothing was spent and the row is ours and still planned: release it for the next attempt. + // Conditional like every release: if the row is not as we left it, hold and say so. + let released = store + .release_remittance( + &planned.remittance_id, + &ReleaseOn::OwnPlanned { + owner: effects_owner_for_release.clone(), + }, + now_unix, + ) + .map_err(|error| format!("release remittance: {error}"))?; + if released.is_none() { + let _ = writeln!( + out, + " (the row changed under me before it could be released — nothing written; the next attempt reconciles it)" + ); + } + Ok(released.is_some()) + }; + + // 1. The payment quote, and the ceiling against IT. + let quote = match effects.melt_quote(&invoice.bolt11) { + Ok(quote) => quote, + Err(error) => { + let released = release_own_planned(store, out)?; + let _ = writeln!( + out, + "payment quote failed: {error}. Nothing left the wallet{}.", + if released { + format!( + "; released {} sats back to unremitted for the next attempt", + planned.gross_sats + ) + } else { + String::new() + } + ); + return Ok(RemitOutcome::QuoteFailed { + remittance_id: planned.remittance_id, + error, + }); + } + }; + let refuse_before_fence = |reason: String, + store: &SellerStore, + out: &mut dyn Write| + -> Result { + // Addendum 10 §2: an arithmetic refusal before the fence writes NOTHING to the row. It stays + // Planned, unbound (no admission mark, no bound quote), ours, under its lease, its receipts + // pinned; the attempt journal records the failure. The next attempt's reconciliation + // releases it as our own earlier attempt (`OwnPlanned`) and re-plans on a fresh quote — + // backoff continues, one line here. (`release_own_planned` remains for a failed payment + // quote and a lost fence, which are not arithmetic refusals.) + let _ = store; + let _ = writeln!( + out, + "REFUSED before spending — {reason}; ceiling {gross} sats; nothing left the wallet; the row stays planned and the next attempt re-quotes." + ); + Ok(RemitOutcome::MeltRefused { + remittance_id: planned.remittance_id.clone(), + reason, + }) + }; + if quote.amount_sats != net { + return refuse_before_fence( + format!( + "melt refused before spending: mint {} quoted {} sats for the {net}-sat invoice; nothing left the wallet", + quote.mint_url, quote.amount_sats + ), + store, + out, + ); + } + if !ceiling.admits(quote.amount_sats, quote.fee_reserve_sats) { + return refuse_before_fence( + format!( + "melt refused before spending: mint {} quote {} would debit {} sats ({} sats invoice + {} sats fee reserve; planned invoice {net} sats) against a ceiling of {gross} sats; nothing left the wallet", + quote.mint_url, + quote.quote_id, + quote.amount_sats.saturating_add(quote.fee_reserve_sats), + quote.amount_sats, + quote.fee_reserve_sats + ), + store, + out, + ); + } + + // 1a. Addendum 10 §1.4 — the LIVE reserve differs from the estimate the invoice was planned + // on. A reserve that still lets the planned invoice confirm pays with slack; one that does + // not is re-planned ONCE, in this attempt, BEFORE anything is prepared: a new (re-planned) + // invoice — smaller or larger, whatever the search finds at the live reserve (record 40: + // 12 → 15) — and a live quote for it, the planned row re-pointed at them by one conditional update + // (still ours, still planned, still unbound — otherwise nothing is written and this is a + // pre-fence refusal). A next-attempt re-quote could not do this: it would plan from the + // probe estimate again and meet the same drift (§2.1). Gross, receipts, owner and lease do + // not move. The re-planned quote is checked by the same bound; a second mismatch is NOT + // re-planned again. Only a reserve that SHRANK is re-planned (addendum 10 §1.4's words); a + // reserve that grew past the plan is refused as before, by the prepared-melt gate below + // (record 29): growth is the mint asking for more than was planned, and a seller that + // planned on the estimate does not chase it within the same attempt. + let (quote, ceiling) = if quote.fee_reserve_sats >= estimate.fee_reserve_sats { + (quote, ceiling) + } else { + let live_reserve = quote.fee_reserve_sats; + let requires_swap = estimate.input_fee_ppk > 0; + match confirm_bound( + net, + live_reserve, + None, + estimate.expected_swap_fee_sats, + estimate.input_fee_ppk, + requires_swap, + gross, + ) { + Ok(_) => (quote, ceiling), + Err(shortfall) => { + let why_not = match &shortfall { + ConfirmShortfall::TargetShort { + bound, + needed_after_swap_sats, + } => format!( + "the wallet would swap to {} sats and the SDK's actual proof input fee on those proofs is {} sats, so the payment would need {needed_after_swap_sats} sats and the SDK would refuse after its swap", + bound.target_sats, bound.actual_input_fee_sats + ), + ConfirmShortfall::OverCeiling { bound, .. } => format!( + "invoice + reserve + actual proof input fee {} sats + swap fee {} sats = {} sats would exceed the {gross} sats accrued", + bound.actual_input_fee_sats, bound.swap_fee_sats, bound.worst_debit_sats + ), + ConfirmShortfall::DifferentInvoice { .. } => { + unreachable!("confirm_bound without a ceiling never compares invoices") + } + }; + let net2 = match plan_confirmable_invoice( + gross, + live_reserve, + estimate.expected_swap_fee_sats, + estimate.input_fee_ppk, + ) { + Some(net2) if net2 >= min_sats => net2, + _ => { + return refuse_before_fence( + format!( + "melt refused before spending: mint {} quote {} carries a {live_reserve} sats fee reserve on the {net}-sat invoice (planned on {} sats); {why_not}, and no invoice of at least {min_sats} sats fits {gross} sats at that reserve; nothing left the wallet", + quote.mint_url, quote.quote_id, estimate.fee_reserve_sats + ), + store, + out, + ); + } + }; + let invoice2 = match effects.invoice(&pay, net2) { + Ok(invoice2) => invoice2, + Err(error) => { + return refuse_before_fence( + format!( + "melt refused before spending: re-planning from invoice {net} sats to {net2} sats (live fee reserve {live_reserve} sats, planned on {} sats) — the {net2}-sat invoice could not be raised: {error}; nothing left the wallet", + estimate.fee_reserve_sats + ), + store, + out, + ); + } + }; + let quote2 = match effects.melt_quote(&invoice2.bolt11) { + Ok(quote2) => quote2, + Err(error) => { + return refuse_before_fence( + format!( + "melt refused before spending: re-planning from invoice {net} sats to {net2} sats (live fee reserve {live_reserve} sats, planned on {} sats) — the payment quote for the {net2}-sat invoice failed: {error}; nothing left the wallet", + estimate.fee_reserve_sats + ), + store, + out, + ); + } + }; + if quote2.amount_sats != net2 { + return refuse_before_fence( + format!( + "melt refused before spending: mint {} quoted {} sats for the re-planned {net2}-sat invoice; nothing left the wallet", + quote2.mint_url, quote2.amount_sats + ), + store, + out, + ); + } + // The re-planned quote must itself confirm under the bound — with ITS reserve. A + // second mismatch is a refusal, not another re-plan. + let bound2 = match confirm_bound( + net2, + quote2.fee_reserve_sats, + None, + estimate.expected_swap_fee_sats, + estimate.input_fee_ppk, + requires_swap, + gross, + ) { + Ok(bound2) => bound2, + Err(_) => { + return refuse_before_fence( + format!( + "melt refused before spending: re-planned to invoice {net2} sats on a {live_reserve} sats fee reserve, but mint {} quote {} carries a {} sats fee reserve on it and the payment would not confirm within {gross} sats; not re-planned a second time; nothing left the wallet", + quote2.mint_url, quote2.quote_id, quote2.fee_reserve_sats + ), + store, + out, + ); + } + }; + let replanned = store + .replan_remittance( + &planned.remittance_id, + effects.owner(), + &RemittanceReplan { + net_sats: net2, + payment_hash: invoice2.payment_hash.clone(), + bolt11: invoice2.bolt11.clone(), + melt_fee_reserve_sats: quote2.fee_reserve_sats, + melt_quote_id: Some(quote2.quote_id.clone()), + }, + ) + .map_err(|error| format!("re-plan remittance: {error}"))?; + if replanned.is_none() { + return refuse_before_fence( + format!( + "melt refused before spending: the planned row changed under me while re-planning from invoice {net} sats to {net2} sats (live fee reserve {live_reserve} sats) — nothing written; nothing left the wallet" + ), + store, + out, + ); + } + let _ = writeln!( + out, + "Re-planned: the payment quote's fee reserve is {live_reserve} sats (planned on {} sats); invoice {net} sats would not confirm ({why_not}), invoice {net2} sats will (at most {} sats leaves the wallet, ≤ {gross}); payment quote {} raised at mint {} for {net2} sats (fee reserve {} sats); invoice payment hash: {}", + estimate.fee_reserve_sats, + bound2.worst_debit_sats, + quote2.quote_id, + quote2.mint_url, + quote2.fee_reserve_sats, + invoice2.payment_hash + ); + let ceiling2 = MeltCeiling { + max_debit_sats: gross, + invoice_sats: net2, + planned_quote_id: Some(quote2.quote_id.clone()), + }; + (quote2, ceiling2) + } + } + }; + // From here `quote` and `ceiling` are the (possibly re-planned) figures — the invoice paid is + // `ceiling.invoice_sats`, the quote's; `planned` keeps the row as first journaled and only its + // unchanged fields (id, gross, receipts) are read below. + let margin_secs = lease_secs(SPEND_MARGIN); + let quote_inside_margin = |now_unix: i64| { + u64::try_from(now_unix.saturating_add(margin_secs)) + .is_ok_and(|bound| quote.expiry_unix <= bound) + }; + let quote_now_unix = effects.now_unix(); + if quote_inside_margin(quote_now_unix) { + return refuse_before_fence( + format!( + "melt refused before spending: mint {} quote {} expires at unix {}, within {margin_secs} s of now (unix {quote_now_unix}); a quote this close to expiry is not paid; nothing left the wallet", + quote.mint_url, quote.quote_id, quote.expiry_unix + ), + store, + out, + ); + } + let _ = writeln!( + out, + "Payment quote {} raised at mint {} for {} sats (fee reserve {} sats, expires unix {}); fits the ceiling of {gross} sats", + quote.quote_id, + quote.mint_url, + quote.amount_sats, + quote.fee_reserve_sats, + quote.expiry_unix + ); + + // 1b. Prepare, and bound the TOTAL. Either refusal here happened in the wallet's own database + // (prepare may have fetched mint metadata — a GET): no proof-bearing or fee-bearing request + // was posted, nothing left the wallet, the row is still ours and planned. + let prepared = match effects.prepare_melt("e.quote_id, &ceiling) { + Ok(prepared) => prepared, + Err(MeltFailure::RefusedBeforeSpending(reason)) => { + return refuse_before_fence(reason, store, out); + } + Err(MeltFailure::Failed(reason)) => { + return refuse_before_fence( + format!( + "wallet could not prepare the melt of quote {}: {reason}; nothing was posted to the mint and nothing left the wallet", + quote.quote_id + ), + store, + out, + ); + } + }; + let preparation = prepared.preparation.clone(); + // 1c. Confirmability (addendum 9 §1.1): the SDK's confirm recomputes the input fee on the + // proofs its swap yields and refuses AFTER the swap when they do not cover it. Run that + // arithmetic now, before the fence: a refusal here cancels the prepared melt (local) and + // takes `refuse_before_fence` — the row is not written, it stays planned with its receipts + // pinned for the next attempt's reconciliation; no fee-bearing request, no bound row. + let actual_input_fee_sats = match confirm_would_succeed(&preparation, gross) { + Ok(actual) => actual, + Err(reason) => { + if let Err(error) = effects.cancel_melt(prepared) { + let _ = writeln!( + out, + " (cancelling the prepared melt failed: {error}; no fee-bearing request was posted; its local proof reservation may remain until a supported recovery path — owed — releases it)" + ); + } + return refuse_before_fence(reason, store, out); + } + }; + let _ = writeln!( + out, + "Prepared melt of quote {}: proof input fee {} sats (estimate; actual on the swapped proofs {actual_input_fee_sats} sats), swap fee {} sats{}; total debit {} sats ({} invoice + {} reserve + fees) fits the ceiling of {gross} sats; proofs reserved in this wallet only, nothing posted yet", + preparation.quote_id, + preparation.input_fee_sats, + preparation.swap_fee_sats, + if preparation.requires_swap { + " (the wallet's proofs do not fit: a pre-melt swap will be performed)" + } else { + "" + }, + preparation.total_debit_sats, + preparation.invoice_sats, + preparation.fee_reserve_sats + ); + effects.after_quote(&planned, "e); + + // 2. The fence: clock read inside the store call, Q bound. + let mut admit_now_unix: Option = None; + let owner = effects_owner_for_release.clone(); + let admitted = { + let effects_ref: &dyn RemitEffects = &*effects; + store + .admit_remittance_spend( + &planned.remittance_id, + &owner, + "e.quote_id, + margin_secs, + &mut || { + let now = effects_ref.now_unix(); + admit_now_unix = Some(now); + now + }, + ) + .map_err(|error| format!("admit remittance {}: {error}", planned.remittance_id))? + }; + let admit_now_unix = admit_now_unix.unwrap_or(now_unix); + let admitted = match admitted { + Ok(admitted) => admitted, + Err(lost) => { + // The prepared melt first: cancel it — the SDK's best-effort local compensation. It + // posted no fee-bearing request, so a failed cancel changes nothing at the mint; a + // local proof reservation may remain (opening the wallet runs no saga recovery on this + // path; a supported recovery path is owed) — say so. + if let Err(error) = effects.cancel_melt(prepared) { + let _ = writeln!( + out, + " (cancelling the prepared melt failed: {error}; no fee-bearing request was posted; its local proof reservation may remain until a supported recovery path — owed — releases it)" + ); + } + // Ours, still planned, but too little lease left: nothing was spent, so release our own + // row (conditionally). Not ours, gone, or no longer planned: another process holds or + // resolved it — touch nothing. + let released = if matches!(lost, OwnershipLost::LeaseTooShort { .. }) { + release_own_planned(store, out)? + } else { + false + }; + let reason = lost.to_string(); + let _ = writeln!( + out, + "REFUSED before spending — {reason} (checked at unix {admit_now_unix}). Nothing moved by this run{}.", + if released { + format!( + "; released {} sats back to unremitted for the next attempt", + planned.gross_sats + ) + } else { + String::new() + } + ); + return Ok(RemitOutcome::Refused(Refusal::OwnershipLost { + remittance_id: planned.remittance_id, + reason, + })); + } + }; + let _ = writeln!( + out, + "Admitted to spend at unix {admit_now_unix}: remittance {} is now spending, bound to melt quote {} (lease until unix {lease_until_unix}); from here only the mint's verdict on that quote resolves it", + admitted.remittance_id, quote.quote_id + ); + effects.after_admit(&admitted); + + // 3. Confirm the prepared melt of Q. First the local refusal of a quote inside its margin of + // expiry, on a fresh clock — to avoid a pointless attempt, not as a safety bound: the row is + // spending and stays so, held until the mint reports Q PAID, and this process never + // re-quotes for it. The prepared melt is cancelled (best-effort local compensation; no + // fee-bearing request was posted). + let pay_now_unix = effects.now_unix(); + if quote_inside_margin(pay_now_unix) { + let error = format!( + "bound melt quote {} expires at unix {}, within {margin_secs} s of now (unix {pay_now_unix}); not paid", + quote.quote_id, quote.expiry_unix + ); + let cancel_note = match effects.cancel_melt(prepared) { + Ok(()) => String::new(), + Err(cancel_error) => format!( + " (cancelling the prepared melt failed: {cancel_error}; no fee-bearing request was posted; its local proof reservation may remain until a supported recovery path — owed — releases it)" + ), + }; + let _ = writeln!( + out, + "not paid: {error}.{cancel_note}\n remittance {} stays journaled as spending, bound to that quote; this process raises no other quote for it. The next attempt asks the mint about that quote: settled if it shows PAID, otherwise HELD with its receipts — no clock releases a spending row. Nothing else was attempted.", + planned.remittance_id + ); + return Ok(RemitOutcome::MeltFailed { + remittance_id: planned.remittance_id, + error, + }); + } + match effects.confirm_melt(prepared) { + Ok(outcome) => { + let settlement = RemitSettlement { + net_paid_sats: Some(outcome.paid_sats), + melt_fee_sats: Some(outcome.fee_sats), + melt_fee_reserve_sats: Some(outcome.fee_reserve_sats), + melt_quote_id: Some(outcome.quote_id.clone()), + settled_by: SettledBy::Melt, + }; + let settled = store + .settle_remittance(&planned.remittance_id, &settlement, now_unix) + .map_err(|error| { + format!( + "PAID {} sats (melt fee {} sats, quote {}) but could not record the settlement: {error}. \ + Remittance {} stays spending; the next attempt reconciles it with the mint before paying anything else.", + outcome.paid_sats, outcome.fee_sats, outcome.quote_id, planned.remittance_id + ) + })?; + // Actual debit (addendum 9 §2.1): invoice + the SDK's `fee_paid` (the mint's Lightning + // fee PLUS the actual proof input fee on the melt's proofs, inclusive — pinned CDK + // `melt/saga/mod.rs:139–148`) + the swap fee charged at the swap. The PREPARED input + // fee is an estimate the SDK replaced inside `fee_paid`; it is printed, not added. + let debit = outcome + .paid_sats + .saturating_add(outcome.fee_sats) + .saturating_add(outcome.swap_fee_sats); + let balance_now = match outcome.balance_after_sats { + Some(balance) => format!("{balance} sats"), + None => "unknown (the balance read after the payment failed; the payment stands)" + .to_owned(), + }; + let _ = writeln!( + out, + "PAID — remittance {} settled\n gross discharged: {} sats\n melt fee taken by the mint: {} sats (quote {} reserved {} sats; ceiling {gross} sats held at the moment of spending; this is the SDK's fee_paid = Lightning fee + actual proof input fee)\n estimated proof input fee (prepared): {} sats — replaced by the actual fee inside the melt fee above, not added again; swap fee (charged at swap): {} sats\n actual debit: {debit} sats = net + melt fee + swap fee\n net paid to {}: {} sats\n stays in your wallet (unused reserve): {} sats\n wallet balance now: {} at {}\n receipts discharged: {}", + settled.remittance_id, + settled.gross_sats, + outcome.fee_sats, + outcome.quote_id, + outcome.fee_reserve_sats, + outcome.input_fee_sats, + outcome.swap_fee_sats, + settled.destination, + outcome.paid_sats, + gross.saturating_sub(debit), + balance_now, + outcome.mint_url, + settled.receipts + ); + if debit > gross { + // Belt behind the braces: before the fence the confirmability check bounded + // invoice + reserve + ACTUAL input fee (recomputed on the swapped split) + swap fee + // under the ceiling — the prepared display is not the bound, a prepared total over + // the gross with a fitting actual debit is admitted — and the mint's Lightning fee + // is at most its reserve, so this line should never print under fixed fee metadata. If it does, + // the mint's fee metadata changed between prepare and confirm (§1.5 bound). + let _ = writeln!( + out, + "WARNING: the wallet lost {debit} sats ({} net + {} melt fee incl. actual proof input fee + {} swap fee) against {gross} sats accrued — above the ceiling the melt was admitted under. Recorded as settled; report this.", + outcome.paid_sats, outcome.fee_sats, outcome.swap_fee_sats + ); + } + Ok(RemitOutcome::Paid { + remittance_id: settled.remittance_id, + net_sats: outcome.paid_sats, + melt_fee_sats: outcome.fee_sats, + }) + } + Err(MeltFailure::RefusedBeforeSpending(reason)) => { + // Since addendum 8 the ceiling is taken at `prepare_melt`, before the fence, so a + // confirm cannot refuse on it: this arm is unreachable through the live effects and + // kept only so a future effects impl that does refuse here is handled the safe way — + // nothing left the wallet, but the row is SPENDING and bound: it is not released on a + // typed promise — reconciliation asks the mint about its bound quote, settles on PAID + // and otherwise holds; this process never re-quotes for it. + let error = format!("refused before spending: {reason}"); + let _ = writeln!( + out, + "REFUSED before spending — {reason}.\n Nothing left the wallet. remittance {} stays journaled as spending, bound to melt quote {}; the next attempt asks the mint about that quote: settled if PAID, otherwise held with its receipts. Nothing else was attempted.", + planned.remittance_id, quote.quote_id + ); + Ok(RemitOutcome::MeltFailed { + remittance_id: planned.remittance_id, + error, + }) + } + Err(MeltFailure::Failed(error)) => { + let _ = writeln!( + out, + "melt failed: {error}\n remittance {} stays journaled as spending, bound to melt quote {}: proofs may have reached the mint. The next attempt (automatic, or `maxplayer seller fees remit`) asks the mint about THAT QUOTE: settled if it is PAID, otherwise HELD with its receipts — UNPAID, FAILED, PENDING, unknown or expired, no clock releases it; an operator decision does. This process raises no other quote for the row. Nothing else was attempted.", + planned.remittance_id, quote.quote_id + ); + Ok(RemitOutcome::MeltFailed { + remittance_id: planned.remittance_id, + error, + }) + } + } +} + +/// A `Duration` as whole unix seconds, for lease arithmetic. +fn lease_secs(duration: Duration) -> i64 { + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + +fn print_recent_attempts(store: &SellerStore, out: &mut dyn Write) -> Result<(), String> { + let attempts = store + .recent_remit_attempts(RECENT_ATTEMPTS_SHOWN) + .map_err(|error| format!("read remit attempts: {error}"))?; + if attempts.is_empty() { + let _ = writeln!(out, "Recent attempts: none journaled yet"); + return Ok(()); + } + let _ = writeln!( + out, + "Recent attempts (newest first, last {}):", + RECENT_ATTEMPTS_SHOWN + ); + for attempt in &attempts { + let _ = writeln!( + out, + " unix {}: {} attempt saw {} sats unremitted — {}: {}{}", + attempt.started_at_unix, + match attempt.trigger { + RemitAttemptTrigger::Collect => "automatic (after collect)", + RemitAttemptTrigger::Retry => "automatic (retry tick)", + RemitAttemptTrigger::Command => "operator (--confirm)", + }, + attempt.unremitted_sats, + attempt.outcome.as_str().to_uppercase(), + attempt.detail, + match &attempt.remittance_id { + Some(id) => format!(" [remittance {id}]"), + None => String::new(), + } + ); + } + Ok(()) +} + +/// What the collect path gets back: the outcome and every line the attempt printed, for the node's +/// log. Never an error the caller has to handle — that is the point. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemitReport { + pub outcome: Result, + pub lines: Vec, +} + +impl RemitReport { + /// A refusal at the threshold — the expected steady state; the caller may log it briefly. + pub fn is_quiet(&self) -> bool { + matches!(&self.outcome, Ok(RemitOutcome::Refused(refusal)) if refusal.is_threshold()) + } + + /// One line saying how the attempt ended. + pub fn summary(&self) -> String { + match &self.outcome { + Ok(RemitOutcome::Paid { + remittance_id, + net_sats, + melt_fee_sats, + }) => format!( + "PAID {net_sats} sats to {PLATFORM_FEE_ADDRESS} (melt fee {melt_fee_sats} sats), remittance {remittance_id}" + ), + Ok(RemitOutcome::DryRun) => "dry run; nothing moved".to_owned(), + Ok(RemitOutcome::Refused(refusal)) if refusal.is_threshold() => format!( + "nothing moved ({refusal}); the balance accumulates until it clears the destination's minimum" + ), + Ok(RemitOutcome::Refused(refusal)) => { + format!("REFUSED, nothing moved: {refusal}; the balance stays unremitted") + } + Ok(RemitOutcome::MeltFailed { + remittance_id, + error, + }) => format!( + "melt FAILED ({error}); remittance {remittance_id} stays spending and is reconciled against the mint on the next attempt" + ), + Ok(RemitOutcome::MeltRefused { + remittance_id, + reason, + }) => format!( + "melt REFUSED before spending ({reason}); remittance {remittance_id} stays planned and unbound, nothing spent; the next attempt reconciles it and re-quotes" + ), + Ok(RemitOutcome::QuoteFailed { + remittance_id, + error, + }) => format!( + "payment quote FAILED ({error}); remittance {remittance_id} released, nothing spent, the balance stays unremitted and the next attempt re-quotes" + ), + Err(error) => format!( + "attempt FAILED ({error}); the balance stays unremitted and the node retries with backoff while it runs" + ), + } + } + + /// Whether this attempt counts as a FAILURE for pacing ([`RemitBackoff::observe`]): it meant to + /// pay and did not, for a reason that is not the steady state. `Err` (an effect failed), + /// `MeltFailed`, `MeltRefused`, `QuoteFailed`, and every refusal that is not at the threshold — + /// the balance stays owed and hammering the same host or mint every 30 s would not change that. + /// A threshold refusal, a payment and a dry run are not failures. + pub fn is_failure(&self) -> bool { + match &self.outcome { + Err(_) + | Ok(RemitOutcome::MeltFailed { .. }) + | Ok(RemitOutcome::MeltRefused { .. }) + | Ok(RemitOutcome::QuoteFailed { .. }) => true, + Ok(RemitOutcome::Refused(refusal)) => !refusal.is_threshold(), + Ok(RemitOutcome::Paid { .. }) | Ok(RemitOutcome::DryRun) => false, + } + } +} + +/// **The node's attempt** — [`remit`] under a paying node trigger ([`RemitTrigger::Collect`] or +/// [`RemitTrigger::Retry`]), with every error caught into the report. Nothing here can fail the +/// caller: on the collect path the receipt is already journaled and the job already marked paid +/// before this runs; a failure leaves the balance unremitted for the retry tick (and the next +/// collect) to try again. +pub fn remit_best_effort( + store: &SellerStore, + effects: &mut dyn RemitEffects, + trigger: RemitTrigger, + now_unix: i64, +) -> RemitReport { + debug_assert!( + matches!(trigger, RemitTrigger::Collect | RemitTrigger::Retry), + "the node's best-effort attempt runs under a node trigger, never the operator's" + ); + let mut out = Vec::new(); + let outcome = remit(store, effects, trigger, now_unix, &mut out); + let lines = String::from_utf8_lossy(&out) + .lines() + .map(str::to_owned) + .collect(); + RemitReport { outcome, lines } +} + +/// [`remit_best_effort`] over the shipped [`LiveEffects`] — what the seller node runs, on a thread +/// of its own, after a receipt is journaled `Collected::New` and on each retry tick. A failure to +/// build the https client is itself journaled as a failed attempt, so even that is visible in the +/// read-out. +pub fn remit_live_best_effort( + store: &SellerStore, + home: MaxplayerHome, + trigger: RemitTrigger, + now_unix: i64, +) -> RemitReport { + match LiveEffects::new(home) { + Ok(mut effects) => remit_best_effort(store, &mut effects, trigger, now_unix), + Err(error) => { + let error = format!("build https client for LNURL: {error}"); + let unremitted = store + .accrued_fees() + .map(|accrued| accrued.unremitted_fee_sats) + .unwrap_or(0); + let journaled = store.record_remit_attempt(&RemitAttempt { + attempt_id: 0, + started_at_unix: now_unix, + trigger: trigger.journal_as().unwrap_or(RemitAttemptTrigger::Collect), + unremitted_sats: unremitted, + outcome: RemitAttemptOutcome::Failed, + detail: error.clone(), + remittance_id: None, + }); + let mut lines = vec![error.clone()]; + if let Err(journal_error) = journaled { + lines.push(format!("could not journal the attempt: {journal_error}")); + } + RemitReport { + outcome: Err(error), + lines, + } + } + } +} + +// ---- retry pacing (stage 2a, addendum 2) ------------------------------------------------------ + +/// The retry tick's base delay: the first attempt after boot waits at least this long +/// ([`RemitBackoff::boot_delay`]), and a streak of failures doubles it from here. +pub const RETRY_BASE: Duration = Duration::from_secs(30); +/// The ceiling the doubling stops at. A node whose payout host is down keeps trying at most this +/// often, for as long as it runs. +pub const RETRY_CAP: Duration = Duration::from_secs(30 * 60); + +/// How one observed attempt moved the pacing — what the loop logs, and how loudly (addendum 2 §5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pacing { + /// The steady state: nothing owed, or not enough to clear the destination's minimum. Not a + /// failure — the streak is untouched and nothing is logged at full volume. + Idle, + /// A payment with no failure streak behind it. + Paid, + /// The first failure of a streak — the one to log in full, with its error. + FirstFailure, + /// Another failure in the same streak. `entered_cap` marks the transition into the 30-minute + /// ceiling — a line an operator wants once, not every half hour. + RepeatFailure { streak: u32, entered_cap: bool }, + /// A payment that ended a streak: how many attempts failed first, and for how long the fee sat + /// owed while they did. The line an operator wants when they ask "did it ever go out?". + Recovered { + failed_attempts: u32, + owed_for_secs: i64, + }, +} + +/// The retry tick's backoff: **base 30 s, doubling on consecutive failures, capped at 30 minutes, +/// with full jitter**; reset to base by a successful remittance and by nothing else. +/// +/// One instance per node, shared by the loop's tick and the collect path's thread, so a success on +/// either path resets it and a failure on either escalates it: both back off against the same LNURL +/// host and the same mint. +/// +/// **Why full jitter, and why nobody may "simplify" it away:** every seller's node backs off against +/// the same payout host and the same mint. If they all slept the computed delay, an outage would end +/// with every node in the fleet retrying in the same second — the correlated burst that turns a +/// recovered host back into a failed one. So the delay actually slept is a uniform random value in +/// `[0, computed_delay]`, not the delay plus a small wobble ([`Self::next_delay`]). The first +/// attempt after boot is the one exception to "from zero" (addendum 3 RULING 1): it waits the full +/// base and THEN a jitter in `[0, base]` — `[30 s, 60 s]` — so a fleet restarting together neither +/// attempts at once nor attempts at startup ([`Self::boot_delay`]). Zero is never a legal first +/// delay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemitBackoff { + base: Duration, + cap: Duration, + /// Consecutive failures observed since the last success. + streak: u32, + /// When the current streak began (unix seconds), for the recovery line. + streak_since_unix: Option, +} + +impl Default for RemitBackoff { + fn default() -> Self { + Self::new() + } +} + +impl RemitBackoff { + /// The shipped bounds: [`RETRY_BASE`] doubling to [`RETRY_CAP`]. + pub fn new() -> Self { + Self::with_bounds(RETRY_BASE, RETRY_CAP) + } + + /// Explicit bounds — for tests that must not sleep 30 minutes. `cap` below `base` is clamped + /// to `base`. + pub fn with_bounds(base: Duration, cap: Duration) -> Self { + Self { + base, + cap: cap.max(base), + streak: 0, + streak_since_unix: None, + } + } + + /// Consecutive failures so far (0 = healthy). + pub fn streak(&self) -> u32 { + self.streak + } + + /// The base delay: the floor under the first retry after boot (RULING 1), whatever re-arms it. + pub fn base(&self) -> Duration { + self.base + } + + /// The delay the current streak computes to, BEFORE jitter: `base × 2^streak`, capped. + pub fn computed_delay(&self) -> Duration { + let mut delay = self.base; + for _ in 0..self.streak { + if delay >= self.cap { + break; + } + delay = delay.saturating_mul(2); + } + delay.min(self.cap) + } + + /// Whether the doubling has reached the cap. + pub fn at_cap(&self) -> bool { + self.computed_delay() >= self.cap + } + + /// The delay to actually sleep before the next attempt: full jitter over + /// [`Self::computed_delay`], drawn from the OS RNG. Never above the computed delay, never below + /// zero. If the RNG is unavailable (it should never be), sleeps the full computed delay — later + /// is the safe direction. + pub fn next_delay(&self) -> Duration { + jittered(self.computed_delay(), os_entropy()) + } + + /// The delay before the FIRST attempt after boot (addendum 3 RULING 1): one full base delay, + /// plus an additive jitter in `[0, base]` — `[base, 2 × base]`, never less than the base, never + /// zero. See [`boot_delay_for`] for the pure form. + pub fn boot_delay(&self) -> Duration { + boot_delay_for(self.base, os_entropy()) + } + + /// Fold one finished attempt into the pacing and say what changed. Failures + /// ([`RemitReport::is_failure`]) lengthen the streak; a payment resets it to base; the steady + /// state ([`Pacing::Idle`]) leaves it exactly as it was — a balance under the threshold neither + /// escalates nor resets. + pub fn observe(&mut self, report: &RemitReport, now_unix: i64) -> Pacing { + if report.is_failure() { + let was_at_cap = self.at_cap(); + self.streak = self.streak.saturating_add(1); + if self.streak == 1 { + self.streak_since_unix = Some(now_unix); + return Pacing::FirstFailure; + } + return Pacing::RepeatFailure { + streak: self.streak, + entered_cap: !was_at_cap && self.at_cap(), + }; + } + match &report.outcome { + Ok(RemitOutcome::Paid { .. }) => { + let failed_attempts = self.streak; + let owed_for_secs = self + .streak_since_unix + .map(|since| now_unix.saturating_sub(since).max(0)) + .unwrap_or(0); + self.streak = 0; + self.streak_since_unix = None; + if failed_attempts == 0 { + Pacing::Paid + } else { + Pacing::Recovered { + failed_attempts, + owed_for_secs, + } + } + } + _ => Pacing::Idle, + } + } +} + +/// Eight bytes from the OS RNG as a `u64`. If the RNG is unavailable (it should never be), the +/// maximum — which every caller maps to the LONGEST delay: later is the safe direction. +fn os_entropy() -> u64 { + let mut bytes = [0u8; 8]; + match getrandom::fill(&mut bytes) { + Ok(()) => u64::from_le_bytes(bytes), + Err(_) => u64::MAX, + } +} + +/// The boot delay's pure form: `base + jittered(base, entropy)`, so `[base, 2 × base]` — `entropy +/// = 0` gives exactly the base, never less. Saturates rather than overflowing. +pub fn boot_delay_for(base: Duration, entropy: u64) -> Duration { + base.saturating_add(jittered(base, entropy)) +} + +/// Full jitter: a uniform point in `[0, computed]` chosen by `entropy` (`0` ⇒ zero, `u64::MAX` ⇒ +/// the whole computed delay). Pure, so the bound is tested without a clock or an RNG. +pub fn jittered(computed: Duration, entropy: u64) -> Duration { + // Integer arithmetic, scaled in two parts so nothing overflows even at `Duration::MAX`: + // `secs × entropy` and `subsec_nanos × entropy` each fit u128 (u64 × u64), and the remainder + // of the seconds part becomes nanoseconds. + let scale = u128::from(u64::MAX); + let entropy = u128::from(entropy); + let secs_scaled = u128::from(computed.as_secs()) * entropy; + let whole_secs = secs_scaled / scale; + let carry_nanos = (secs_scaled % scale) * 1_000_000_000 / scale; + let subsec_nanos = u128::from(computed.subsec_nanos()) * entropy / scale; + let nanos = carry_nanos + subsec_nanos; + let secs = Duration::from_secs(u64::try_from(whole_secs).unwrap_or(u64::MAX)); + secs.checked_add(Duration::from_nanos( + u64::try_from(nanos).unwrap_or(u64::MAX), + )) + .unwrap_or(computed) + .min(computed) +} + +/// **Single-flight for the node's two paths** (addendum 2 §3): one remittance attempt in flight per +/// process, ever. The collect thread and the loop's tick can reach the entry point at the same time; +/// whichever cannot take the permit **skips and returns** — it does not queue, block or fail. This is +/// a liveness device, not the correctness argument: the store's one-`planned`-row rule is what makes +/// a double payment impossible, including against `maxplayer seller fees remit --confirm` in another +/// process, which this guard cannot see. +#[derive(Debug, Clone, Default)] +pub struct RemitFlight(Arc); + +/// Held by the one attempt in flight; the slot frees when it drops (including on a panic). +#[derive(Debug)] +pub struct RemitPermit(Arc); + +impl RemitFlight { + pub fn new() -> Self { + Self::default() + } + + /// Take the slot if it is free. `None` means an attempt is already in flight: skip. + pub fn try_acquire(&self) -> Option { + self.0 + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| RemitPermit(Arc::clone(&self.0))) + } + + /// Whether an attempt holds the slot right now. + pub fn in_flight(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + +impl Drop for RemitPermit { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +// The SDK fee arithmetic (`fee_for`, `binary_split`, `post_swap_figures`, `confirm_bound`) lives in +// `wallet_ops` since addendum 10 §1.1, so the wallet's prepared-melt gate, this module's planner and +// its pre-fence check are one function; re-exported here for this module and its tests. +pub(crate) use crate::wallet_ops::{ConfirmShortfall, binary_split, confirm_bound}; + +/// **Fee-aware planning (addendum 9 §1.2): the largest invoice `confirm` can actually pay within +/// `gross`.** Searches DOWN from `gross − reserve` for the first invoice `n` for which, with +/// `need = n + reserve`, the post-swap arithmetic holds — target ≥ need + actual input fee — AND +/// need + actual input fee + `swap_fee_sats` ≤ `gross`. `reserve` and `swap_fee_sats` are the +/// probe's (the mint's reserve policy and the wallet's swap fee at the gross); the quote raised for +/// the chosen invoice is checked again with its own figures. `None`: no invoice fits — refuse at +/// planning. A fee-free mint (`input_fee_ppk == 0`, no swap) yields `gross − reserve`, as before. +/// Verdict da0ee92 §4.4: gross 20, reserve 2, 1000 ppk, one 32-sat proof ⇒ 13 (need 15, target 19 = +/// [16, 2, 1], actual 3, worst 19 ≤ 20), not 14 (need 16, target 17 = [16, 1], actual 2, 17 < 18). +pub(crate) fn plan_confirmable_invoice( + gross: u64, + reserve: u64, + swap_fee_sats: u64, + input_fee_ppk: u64, +) -> Option { + let ceiling = gross.checked_sub(reserve)?; + (1..=ceiling).rev().find(|&invoice| { + confirm_bound( + invoice, + reserve, + None, + swap_fee_sats, + input_fee_ppk, + true, + gross, + ) + .is_ok() + }) +} + +/// **Will `confirm` succeed, under the fee metadata the preparation saw?** Addendum 9 §1.1, from +/// pinned CDK 0.17.2 `MeltSaga::request_melt_with_options` (`melt/saga/mod.rs:647–760`): on a +/// swap layout the wallet swaps to a target of invoice + reserve + the PREPARED input fee (`:678`), +/// receives exactly that target's binary split (`swap/saga/mod.rs:285–301`), RECOMPUTES the input +/// fee on that split (`:704`) and refuses — after the swap has been paid — when the target does not +/// cover invoice + reserve + that actual fee (`:706–712`). The prepared `input_fee` is an estimate +/// on the split of invoice + reserve BEFORE the fee is added (`:383–387`), so the two can differ. +/// This is that arithmetic, run BEFORE the fence and before any fee-bearing effect: the target's +/// split × the keyset's `input_fee_ppk`, ceil — deterministic for a power-of-two keyset. On an +/// exact-fit layout (no swap) the melt sends the selected proofs and the recomputed fee is the +/// prepared one (same proofs, same metadata). +/// +/// Also requires the worst-case debit under the ACTUAL fee — invoice + reserve + actual input fee +/// + swap fee — to fit `gross`. Returns the actual input fee; `Err` is the one printed line. +/// +/// Bound, disclosed not solved (§1.5): fee metadata can change between prepare and confirm and the +/// SDK takes no caller maximum; a change there is what the bound-Spending hold after the fence +/// covers. +pub(crate) fn confirm_would_succeed( + preparation: &MeltPreparation, + gross: u64, +) -> Result { + match confirm_bound( + preparation.invoice_sats, + preparation.fee_reserve_sats, + Some(preparation.input_fee_sats), + preparation.swap_fee_sats, + preparation.input_fee_ppk, + preparation.requires_swap, + gross, + ) { + Ok(bound) => Ok(bound.actual_input_fee_sats), + Err(ConfirmShortfall::TargetShort { + bound, + needed_after_swap_sats, + }) => Err(format!( + "melt refused before spending: the wallet would swap to {} sats ({:?}) for quote {} and the mint's actual proof input fee on those proofs is {} sats (prepared estimate {} sats at {} ppk), so {} sats invoice + {} sats fee reserve + {} sats would need {needed_after_swap_sats} sats and the SDK would refuse AFTER paying the {} sats swap fee; the prepared melt was cancelled before any fee-bearing request", + bound.target_sats, + binary_split(bound.target_sats), + preparation.quote_id, + bound.actual_input_fee_sats, + preparation.input_fee_sats, + preparation.input_fee_ppk, + preparation.invoice_sats, + preparation.fee_reserve_sats, + bound.actual_input_fee_sats, + preparation.swap_fee_sats + )), + Err(ConfirmShortfall::OverCeiling { bound, .. }) => Err(format!( + "melt refused before spending: quote {} would debit up to {} sats under the mint's actual proof input fee ({} sats invoice + {} sats fee reserve + {} sats actual proof input fee + {} sats swap fee) against a ceiling of {gross} sats; the prepared melt was cancelled before any fee-bearing request", + preparation.quote_id, + bound.worst_debit_sats, + preparation.invoice_sats, + preparation.fee_reserve_sats, + bound.actual_input_fee_sats, + preparation.swap_fee_sats + )), + Err(ConfirmShortfall::DifferentInvoice { .. }) => { + unreachable!("confirm_bound without a ceiling never compares invoices") + } + } +} + +/// Scripted effects for tests, shared with `seller_node::run`'s collect-path tests. +#[cfg(test)] +pub(crate) mod test_support { + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering}; + use std::sync::{Arc, Condvar, Mutex}; + + use super::{MeltFailure, PreparedMelt, Reconcile, RemitEffects, host_now_unix}; + use crate::lnurl_pay::{LightningAddress, PayRequest, ResolvedInvoice, Url}; + use crate::seller_node::store::FeeRemittance; + use crate::wallet_ops::{ + ConfirmShortfall, MeltCeiling, MeltEstimate, MeltOutcome, MeltPreparation, MeltQuoteState, + MeltQuoteStatus, WalletOpsError, + }; + + /// A rendezvous a test uses to PAUSE one attempt at a chosen point (after the plan is journaled, + /// or inside the melt) while another attempt runs against the same store — the deterministic + /// interleaving addendum 3 §2.2 asks for. The paused side calls [`Self::arrive_and_wait`]; the + /// test waits for [`Self::wait_arrived`], does what it wants, then [`Self::release`]s. + pub(crate) struct Gate { + arrived: AtomicBool, + released: Mutex, + cv: Condvar, + } + + impl Gate { + pub(crate) fn new() -> Arc { + Arc::new(Self { + arrived: AtomicBool::new(false), + released: Mutex::new(false), + cv: Condvar::new(), + }) + } + + pub(crate) fn arrive_and_wait(&self) { + self.arrived.store(true, Ordering::SeqCst); + let mut released = self.released.lock().unwrap_or_else(|e| e.into_inner()); + while !*released { + released = self.cv.wait(released).unwrap_or_else(|e| e.into_inner()); + } + } + + pub(crate) fn arrived(&self) -> bool { + self.arrived.load(Ordering::SeqCst) + } + + /// Spin (bounded) until the paused side has arrived. + pub(crate) fn wait_arrived(&self, bound: std::time::Duration) { + let deadline = std::time::Instant::now() + bound; + while !self.arrived() { + assert!( + std::time::Instant::now() < deadline, + "the paused attempt never reached the gate" + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + } + + pub(crate) fn release(&self) { + let mut released = self.released.lock().unwrap_or_else(|e| e.into_inner()); + *released = true; + self.cv.notify_all(); + } + } + + /// One melt quote at the fake mint, as BOTH sides of a test see it. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct FakeQuote { + pub(crate) bolt11: String, + pub(crate) state: MeltQuoteState, + pub(crate) amount_sats: u64, + pub(crate) fee_reserve_sats: u64, + pub(crate) expiry_unix: u64, + } + + /// The fake mint's quote registry, SHARED between the Fakes of one test (one `Arc`, addendum 5 + /// §2): a quote raised by one side is visible to the other; a test moves a quote's state or + /// expiry in place and both sides read the change; a payment marks its quote PAID for everyone. + /// A Fake without a registry answers status queries from its scripted `status` instead. + pub(crate) type QuoteRegistry = Arc>>; + + /// A melt the fake wallet has PREPARED: the quote as loaded, the proofs taken out of the pool + /// and held for it. Confirm spends them; cancel returns them. + #[derive(Debug, Clone)] + pub(crate) struct FakePrepared { + pub(crate) quote_id: String, + pub(crate) quote: FakeQuote, + pub(crate) selected: Vec, + pub(crate) input_fee_sats: u64, + pub(crate) swap_fee_sats: u64, + pub(crate) requires_swap: bool, + } + + /// What the fake wallet would use to pay `need` sats and what the SDK would charge for it, in + /// the shape of pinned CDK 0.17.2 `MeltSaga::prepare` (`melt/saga/mod.rs:286–460`). + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct FakeLayout { + /// The proofs taken out of the pool (empty for a Fake without a pool). + pub(crate) picked: Vec, + /// `PreparedMelt::input_fee`. + pub(crate) input_fee_sats: u64, + /// `PreparedMelt::swap_fee`. + pub(crate) swap_fee_sats: u64, + pub(crate) requires_swap: bool, + } + + pub(crate) use super::binary_split; + pub(crate) use crate::wallet_ops::fee_for; + + /// One pre-melt swap the fake wallet performed inside `confirm` (CDK `melt/saga/mod.rs: + /// 678–697` → `swap_no_reserve`): what it sent, what the mint kept as swap fee, what came back + /// as the melt's proofs (the binary split of the target) and as change. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct FakeSwap { + pub(crate) quote_id: String, + pub(crate) sent: Vec, + pub(crate) target_sats: u64, + pub(crate) swap_fee_sats: u64, + pub(crate) received: Vec, + pub(crate) change: Vec, + } + + /// The SDK's two prepare branches, on a snapshot of the pool (nothing removed): + /// - `input_fee_ppk == 0`: the exact-fit branch (`:312–375`) — exact denominations for `need`, + /// no swap, no fees (with a positive ppk `select_proofs(…, include_fees = true)` targets + /// `need` + the selection's own fee, so the exact-fit test `proofs_total == need` at `:321` + /// does not hold and the swap branch is taken — modelled here as "ppk > 0 ⇒ swap branch"); + /// - otherwise the swap branch (`:377–459`): `estimated_output_count` = the number of proofs + /// in the binary split of `need` (`:383`, = popcount for a power-of-two keyset), + /// `input_fee` = fee on that count (`:384–387`), selection target `need + input_fee` + /// (`:389`), largest-first proofs until they cover the target plus their own input fee + /// (`select_proofs(…, true)`, `:391–397`), `swap_fee` = fee on the proofs picked (`:403`). + /// + /// A Fake without a pool has unbounded funds: one notional proof is swapped. + pub(crate) fn layout( + input_fee_ppk: u64, + available: Option<&[u64]>, + need: u64, + ) -> Result { + if input_fee_ppk == 0 { + let picked = match available { + None => Vec::new(), + Some(available) => { + let mut scratch = available.to_vec(); + select_exact(&mut scratch, need).ok_or_else(|| { + format!("no exact proofs for {need} sats among {available:?}") + })? + } + }; + return Ok(FakeLayout { + picked, + input_fee_sats: 0, + swap_fee_sats: 0, + requires_swap: false, + }); + } + let input_fee_sats = fee_for(input_fee_ppk, need.count_ones() as usize); + let selection = need + input_fee_sats; + let picked = match available { + None => Vec::new(), + Some(available) => { + let mut sorted = available.to_vec(); + sorted.sort_unstable_by(|a, b| b.cmp(a)); + let mut picked = Vec::new(); + let mut sum = 0u64; + for denomination in sorted { + picked.push(denomination); + sum += denomination; + if sum >= selection + fee_for(input_fee_ppk, picked.len()) { + break; + } + } + if sum < selection + fee_for(input_fee_ppk, picked.len()) { + return Err(format!( + "proofs {available:?} do not cover {selection} sats (amount + reserve + {input_fee_sats} sats estimated input fee) plus their own input fee" + )); + } + picked + } + }; + let swap_fee_sats = fee_for(input_fee_ppk, picked.len().max(1)); + Ok(FakeLayout { + picked, + input_fee_sats, + swap_fee_sats, + requires_swap: true, + }) + } + + /// Remove exactly `picked` from `available` (each denomination once). + fn take(available: &mut Vec, picked: &[u64]) { + for denomination in picked { + let index = available + .iter() + .position(|candidate| candidate == denomination) + .expect("picked from available"); + available.swap_remove(index); + } + } + + pub(crate) fn quote_registry() -> QuoteRegistry { + Arc::new(Mutex::new(BTreeMap::new())) + } + + /// The fake WALLET's proofs — denominations in sats — SHARED between the Fakes of one test + /// (one `Arc`, addendum 6 §2.1): every payment selects exact denominations summing to + /// `amount + fee reserve` (as CDK's proof selection does) and removes them, so two payments + /// from one wallet spend DISJOINT proofs and a test can assert that a second payment was + /// refused by the STORE, not for want of funds. A Fake without proofs has unbounded funds. + pub(crate) type FakeProofs = Arc>>; + + pub(crate) fn fake_proofs(denominations: &[u64]) -> FakeProofs { + Arc::new(Mutex::new(denominations.to_vec())) + } + + /// Exact-denomination selection, largest first: the proofs (removed from `available`) that sum + /// to exactly `need`, or `None` — nothing removed — when no such subset exists among the + /// largest-first picks. + fn select_exact(available: &mut Vec, need: u64) -> Option> { + let mut sorted = available.clone(); + sorted.sort_unstable_by(|a, b| b.cmp(a)); + let mut picked = Vec::new(); + let mut remaining = need; + for denomination in sorted { + if denomination <= remaining { + picked.push(denomination); + remaining -= denomination; + if remaining == 0 { + break; + } + } + } + if remaining != 0 { + return None; + } + for denomination in &picked { + let index = available + .iter() + .position(|candidate| candidate == denomination) + .expect("picked from available"); + available.swap_remove(index); + } + Some(picked) + } + + /// Scripted effects. `reserve_for(amount)` is the mint's fee reserve policy at ESTIMATE time; + /// `live_reserve_for`, when set, is the reserve the PAYMENT quote carries (the two can differ — + /// addendum 3 §1); `melt_results` are consumed in order by payments; `status` answers the + /// reconciliation queries when no `registry` is set; `pay_request_error` makes the LNURL host + /// unreachable. Every call is logged so a test can assert what was — and was not — touched; + /// `melt_counter`, when set, counts ACTUAL debits across Fakes on different threads (a payment + /// refused at the ceiling or by the mint is not a debit and is logged in `ceiling_refusals` / + /// `pay_refusals` instead). `owner` is the process this Fake speaks as; `invoice_tag` makes one + /// Fake's invoices distinct from another's, as two real LNURL calls would be. + /// + /// Quote ids: the estimate for `bolt11` is `quote-{bolt11}`, the payment quote is + /// `paid-quote-{bolt11}` — two quotes, two ids, as the wallet raises them. With a `registry` + /// every quote raised is recorded there (UNPAID, expiring at `quote_expiry_unix`) and every + /// status query reads it. [`Self::prepare_melt`] + [`Self::confirm_melt`] are shaped like the + /// checksum-pinned CDK 0.17.2 path the verdict at 6fc77e1 read (§4 B3), in two halves around + /// `melt_gate`: the WALLET half (ceiling, funds, and `prepare_melt`'s `expiry > now` check on + /// the wallet's clock) is `prepare_melt`; the MINT half, which accepts an UNPAID **or FAILED** + /// quote with NO expiry check and rejects PENDING / PAID / UNKNOWN, is `confirm_melt`, which + /// pauses at `melt_gate` first. A fake mint stricter than the dependency is what let the + /// round-4 defect through; this one is not. + pub(crate) struct Fake { + pub(crate) owner: String, + pub(crate) invoice_tag: String, + pub(crate) min_msat: u64, + pub(crate) max_msat: u64, + pub(crate) reserve_for: Box u64 + Send>, + pub(crate) live_reserve_for: Option u64 + Send>>, + pub(crate) melt_results: Vec>, + pub(crate) status: Result, String>, + pub(crate) registry: Option, + /// The wallet's proofs, shared with the other Fake of a two-owner test; `None` = unbounded. + pub(crate) proofs: Option, + /// The mint's keyset `input_fee_ppk` (NUT-02). `0` = a fee-free mint (every test written + /// before addendum 8); positive ⇒ every payment takes the SDK's swap branch and pays a + /// proof-input fee and a swap fee on top of amount + reserve (see [`layout`]). + pub(crate) input_fee_ppk: u64, + /// When set, the proof input fee `prepare_melt` REPORTS on its selection is this figure + /// instead of the layout's — the fee-metadata drift model (round 9 plan §5 §1.4(b)): the + /// SDK computes the prepared fee from the selected proofs' keyset, the swap lands on the + /// active keyset, and the two can disagree; the actual fee `confirm` recomputes on the + /// swapped proofs is unaffected. Only the PREPARED figure moves. + pub(crate) prepared_input_override: Option, + /// The exact proofs each debit spent, in order (empty inner vec when `proofs` is `None`). + pub(crate) proofs_spent: Vec>, + /// When set, the observational balance read after a confirmed melt fails (addendum 9 §2.3): + /// `MeltOutcome::balance_after_sats` is `None`; the payment itself is unaffected. + pub(crate) balance_read_fails: bool, + /// Every pre-melt swap performed inside `confirm_melt`, in order — a swap is a mint effect + /// that charges its fee whether or not the melt after it succeeds (addendum 9 §1.3). + pub(crate) swaps: Vec, + /// Melts prepared (proofs selected and held) and not yet confirmed or cancelled, by token. + pub(crate) prepared: BTreeMap, + pub(crate) next_token: u64, + /// Prepared melts the caller cancelled (quote ids), in order — proofs went back to the pool. + pub(crate) cancels: Vec, + /// Expiry stamped on every quote this Fake raises (registry or not). Far future by default. + pub(crate) quote_expiry_unix: u64, + pub(crate) pay_request_error: Option, + pub(crate) pay_requests: usize, + pub(crate) invoices: Vec, + pub(crate) estimates: Vec, + /// Payment quotes raised (bolt11s), in order. + pub(crate) quotes: Vec, + /// Actual payments (bolt11s), in order — the debits. + pub(crate) melts: Vec, + pub(crate) ceiling_refusals: Vec, + /// Payments refused after the ceiling and before any debit: by the WALLET (the quote had + /// expired at `prepare_melt`, or the proofs did not cover amount + reserve) or by the MINT + /// (the quote was PENDING, PAID or UNKNOWN — never for expiry, never for FAILED). + pub(crate) pay_refusals: Vec, + pub(crate) status_calls: Vec, + /// Reconciliation queries BY QUOTE ID (a spending row's bound quote). + pub(crate) quote_status_calls: Vec, + pub(crate) melt_counter: Option>, + pub(crate) plan_gate: Option>, + /// Pause point AFTER the payment quote is raised and has passed the ceiling, BEFORE the fence + /// (addendum 5 §2, `AfterQuote`): the row is still `planned` while the paused side waits. + pub(crate) quote_gate: Option>, + /// Pause point AFTER the compare-and-set admitted the melt and BEFORE the payment (addendum + /// 4 §1): the row is `spending`, bound to its quote, while the paused side waits here. + pub(crate) admit_gate: Option>, + /// Pause point inside the payment itself: AFTER the wallet's last local check (ceiling, + /// funds, `prepare_melt`'s expiry check) and BEFORE the request reaches the mint — the + /// suspension the verdict at 6fc77e1 traced (§4 B3, `AfterPrepare`). A quote that expires + /// while the payer waits here is still paid by the mint when the payer resumes. + pub(crate) melt_gate: Option>, + /// Pause point AFTER reconciliation decided and BEFORE it writes (addendum 5 §2, + /// `AfterDecision`): a test moves the row under a decided release here. + pub(crate) decision_gate: Option>, + pub(crate) planned_seen: Vec, + pub(crate) admitted_seen: Vec, + pub(crate) decisions_seen: Vec, + /// The injectable clock [`RemitEffects::now_unix`] reads at the fence and before paying. + /// Shared between the Fakes of one test (`Arc`) so that "the clock advanced while A was + /// paused" is a fact A reads FRESH — inside the store's lock — and the store compares in + /// SQL. Unset (`i64::MIN`) the Fake reads the host clock, like the live effects. + pub(crate) clock: Arc, + } + + impl Fake { + pub(crate) fn new(reserve_for: impl Fn(u64) -> u64 + Send + 'static) -> Self { + Self { + owner: "fake-owner".to_owned(), + invoice_tag: String::new(), + min_msat: 1000, + max_msat: 1_000_000_000, + reserve_for: Box::new(reserve_for), + live_reserve_for: None, + prepared_input_override: None, + melt_results: Vec::new(), + status: Ok(None), + registry: None, + proofs: None, + input_fee_ppk: 0, + proofs_spent: Vec::new(), + balance_read_fails: false, + swaps: Vec::new(), + prepared: BTreeMap::new(), + next_token: 1, + cancels: Vec::new(), + quote_expiry_unix: u64::MAX, + pay_request_error: None, + pay_requests: 0, + invoices: Vec::new(), + estimates: Vec::new(), + quotes: Vec::new(), + melts: Vec::new(), + ceiling_refusals: Vec::new(), + pay_refusals: Vec::new(), + status_calls: Vec::new(), + quote_status_calls: Vec::new(), + melt_counter: None, + plan_gate: None, + quote_gate: None, + admit_gate: None, + melt_gate: None, + decision_gate: None, + planned_seen: Vec::new(), + admitted_seen: Vec::new(), + decisions_seen: Vec::new(), + clock: Arc::new(AtomicI64::new(i64::MIN)), + } + } + + /// Set the clock this Fake (and every Fake sharing its `clock`) reads at the fence. + pub(crate) fn set_clock(&self, now_unix: i64) { + self.clock.store(now_unix, Ordering::SeqCst); + } + + pub(crate) fn bolt11_for(amount_sats: u64, sequence: usize) -> String { + format!("lnbc-fake-{amount_sats}-{sequence}") + } + + pub(crate) fn hash_for(amount_sats: u64, sequence: usize) -> String { + format!("hash-{amount_sats}-{sequence}") + } + + /// The payment quote's id for an invoice, as this Fake raises it. + pub(crate) fn pay_quote_id(bolt11: &str) -> String { + format!("paid-quote-{bolt11}") + } + + fn amount_in(bolt11: &str) -> u64 { + bolt11 + .split('-') + .nth(2) + .and_then(|raw| raw.parse().ok()) + .expect("fake bolt11 carries its amount") + } + + fn live_reserve(&self, amount_sats: u64) -> u64 { + match &self.live_reserve_for { + Some(live) => live(amount_sats), + None => (self.reserve_for)(amount_sats), + } + } + + fn register(&self, quote_id: &str, quote: FakeQuote) { + if let Some(registry) = &self.registry { + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(quote_id.to_owned(), quote); + } + } + + /// The pool's value in sats — the wallet measured, not the melt counter. `None` for a + /// Fake with unbounded funds. + pub(crate) fn pool_value(&self) -> Option { + self.proofs.as_ref().map(|proofs| { + proofs + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .sum() + }) + } + + /// The fees the SDK would charge this wallet on top of `need` right now (nothing removed): + /// the estimate's `expected_fees_sats`, or `0` and the reason. + fn expected_fees(&self, need: u64) -> (u64, u64, Option) { + let snapshot: Option> = self + .proofs + .as_ref() + .map(|proofs| proofs.lock().unwrap_or_else(|e| e.into_inner()).clone()); + match layout(self.input_fee_ppk, snapshot.as_deref(), need) { + Ok(layout) => ( + layout.input_fee_sats + layout.swap_fee_sats, + layout.swap_fee_sats, + None, + ), + Err(reason) => (0, 0, Some(reason)), + } + } + + /// Reserved proofs go back to the shared wallet when a payment is refused after selection. + fn return_proofs(&self, selected: &[u64]) { + if let Some(proofs) = &self.proofs { + proofs + .lock() + .unwrap_or_else(|e| e.into_inner()) + .extend_from_slice(selected); + } + } + + fn registered(&self, quote_id: &str) -> Option { + self.registry.as_ref().and_then(|registry| { + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(quote_id) + .cloned() + }) + } + + fn status_of(quote_id: &str, quote: &FakeQuote) -> MeltQuoteStatus { + MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: quote_id.to_owned(), + state: quote.state, + amount_sats: quote.amount_sats, + fee_reserve_sats: quote.fee_reserve_sats, + expiry_unix: quote.expiry_unix, + } + } + + /// How "alive" a quote is, for the by-invoice query (the shipped helper's ranking): a paid + /// quote outranks a pending one, which outranks a live unpaid one; expired and failed last. + fn liveness(quote: &FakeQuote, now_unix: i64) -> u8 { + match quote.state { + MeltQuoteState::Paid => 0, + MeltQuoteState::Pending => 1, + MeltQuoteState::Unpaid + if !u64::try_from(now_unix).is_ok_and(|now| now > quote.expiry_unix) => + { + 2 + } + MeltQuoteState::Unknown => 3, + MeltQuoteState::Unpaid => 4, + MeltQuoteState::Failed => 5, + } + } + } + + impl RemitEffects for Fake { + fn owner(&self) -> &str { + &self.owner + } + + fn after_plan(&mut self, planned: &FeeRemittance) { + self.planned_seen.push(planned.clone()); + // A Fake nobody set a clock on reads the plan's own timestamp — the run's entry time — + // so the single-process tests that pass a literal `now` keep their arithmetic. A test + // that moves time sets the clock (before the run, or while the run is paused here). + if self.clock.load(Ordering::SeqCst) == i64::MIN { + self.set_clock(planned.created_at_unix); + } + if let Some(gate) = &self.plan_gate { + gate.arrive_and_wait(); + } + } + + fn after_quote(&mut self, _planned: &FeeRemittance, _quote: &MeltEstimate) { + if let Some(gate) = &self.quote_gate { + gate.arrive_and_wait(); + } + } + + fn after_admit(&mut self, admitted: &FeeRemittance) { + self.admitted_seen.push(admitted.clone()); + if let Some(gate) = &self.admit_gate { + gate.arrive_and_wait(); + } + } + + fn after_decision(&mut self, _row: &FeeRemittance, decision: &Reconcile) { + self.decisions_seen.push(decision.clone()); + if let Some(gate) = &self.decision_gate { + gate.arrive_and_wait(); + } + } + + fn now_unix(&self) -> i64 { + match self.clock.load(Ordering::SeqCst) { + // Never set and no plan seen yet (the fence always follows a plan, so this is + // unreachable on the paying path): the host clock, like the live effects. + i64::MIN => host_now_unix(), + set => set, + } + } + + fn pay_request(&mut self, address: &LightningAddress) -> Result { + assert_eq!(address.to_string(), "maxplayer@agi.cash"); + self.pay_requests += 1; + if let Some(error) = &self.pay_request_error { + return Err(error.clone()); + } + Ok(PayRequest { + callback: Url::parse("https://agi.cash/cb").unwrap(), + min_sendable_msat: self.min_msat, + max_sendable_msat: self.max_msat, + }) + } + + fn invoice( + &mut self, + pay: &PayRequest, + amount_sats: u64, + ) -> Result { + pay.invoice_url(amount_sats) + .map_err(|error| error.to_string())?; + self.invoices.push(amount_sats); + let sequence = self.invoices.len(); + Ok(ResolvedInvoice { + bolt11: format!( + "{}{}", + Self::bolt11_for(amount_sats, sequence), + self.invoice_tag + ), + payment_hash: format!( + "{}{}", + Self::hash_for(amount_sats, sequence), + self.invoice_tag + ), + amount_sats, + amount_msat: amount_sats * 1000, + }) + } + + fn melt_estimate(&mut self, bolt11: &str) -> Result { + self.estimates.push(bolt11.to_owned()); + let amount_sats = Self::amount_in(bolt11); + let fee_reserve_sats = (self.reserve_for)(amount_sats); + let quote_id = format!("quote-{bolt11}"); + self.register( + "e_id, + FakeQuote { + bolt11: bolt11.to_owned(), + state: MeltQuoteState::Unpaid, + amount_sats, + fee_reserve_sats, + expiry_unix: self.quote_expiry_unix, + }, + ); + let (expected_fees_sats, expected_swap_fee_sats, expected_fees_note) = + self.expected_fees(amount_sats.saturating_add(fee_reserve_sats)); + Ok(MeltEstimate { + mint_url: "https://mint.example".to_owned(), + quote_id, + amount_sats, + fee_reserve_sats, + expiry_unix: self.quote_expiry_unix, + expected_fees_sats, + expected_swap_fee_sats, + input_fee_ppk: self.input_fee_ppk, + expected_fees_note, + }) + } + + /// The PAYMENT quote, as the shipped wallet raises it: a fresh quote for the invoice whose + /// reserve is `live_reserve_for` (or the estimate's policy when unset). Spends nothing. + fn melt_quote(&mut self, bolt11: &str) -> Result { + self.quotes.push(bolt11.to_owned()); + let amount_sats = Self::amount_in(bolt11); + let fee_reserve_sats = self.live_reserve(amount_sats); + let quote_id = Self::pay_quote_id(bolt11); + self.register( + "e_id, + FakeQuote { + bolt11: bolt11.to_owned(), + state: MeltQuoteState::Unpaid, + amount_sats, + fee_reserve_sats, + expiry_unix: self.quote_expiry_unix, + }, + ); + let (expected_fees_sats, expected_swap_fee_sats, expected_fees_note) = + self.expected_fees(amount_sats.saturating_add(fee_reserve_sats)); + Ok(MeltEstimate { + mint_url: "https://mint.example".to_owned(), + quote_id, + amount_sats, + fee_reserve_sats, + expiry_unix: self.quote_expiry_unix, + expected_fees_sats, + expected_swap_fee_sats, + input_fee_ppk: self.input_fee_ppk, + expected_fees_note, + }) + } + + /// The WALLET half of the payment, BY QUOTE ID, in the shape of the shipped path + /// (`wallet_ops::prepare_melt_payment_blocking` over CDK 0.17.2, as the verdict at 6fc77e1 + /// §4 read `prepare_melt`) — nothing has left the wallet; a refusal here is not a debit: + /// the quote must be one this wallet raised; its STORED amount and reserve are re-checked + /// against the ceiling; the proofs must cover amount + reserve (exact denominations are + /// selected and HELD, as CDK reserves them); `prepare_melt` refuses a quote whose `expiry` + /// has passed on the WALLET's clock, read now; then the prepared melt's TOTAL is bounded + /// (addendum 8 §1.1). The prepared melt waits in `self.prepared` for the verdict. + fn prepare_melt( + &mut self, + quote_id: &str, + ceiling: &MeltCeiling, + ) -> Result { + let quote = match self.registered(quote_id) { + Some(quote) => quote, + None => { + // No registry: the quote is the one this Fake raised for the invoice its id + // names, with the reserve the payment quote carries. + let bolt11 = quote_id + .strip_prefix("paid-quote-") + .unwrap_or_else(|| { + panic!("the payer must pay the PAYMENT quote it raised, not {quote_id}") + }) + .to_owned(); + let amount_sats = Self::amount_in(&bolt11); + FakeQuote { + fee_reserve_sats: self.live_reserve(amount_sats), + bolt11, + state: MeltQuoteState::Unpaid, + amount_sats, + expiry_unix: self.quote_expiry_unix, + } + } + }; + if !ceiling.admits(quote.amount_sats, quote.fee_reserve_sats) { + let reason = format!( + "melt refused before spending: mint https://mint.example quote {quote_id} would debit {} sats ({} sats invoice + {} sats fee reserve; planned invoice {} sats) against a ceiling of {} sats; nothing left the wallet", + quote.amount_sats.saturating_add(quote.fee_reserve_sats), + quote.amount_sats, + quote.fee_reserve_sats, + ceiling.invoice_sats, + ceiling.max_debit_sats + ); + self.ceiling_refusals.push(reason.clone()); + return Err(MeltFailure::RefusedBeforeSpending(reason)); + } + let need = quote.amount_sats.saturating_add(quote.fee_reserve_sats); + // Selection and reservation, under the pool's lock (a shared pool is read and taken + // from atomically, as the wallet's own store is). + let layout = { + let mut pool = self + .proofs + .as_ref() + .map(|proofs| proofs.lock().unwrap_or_else(|e| e.into_inner())); + match layout( + self.input_fee_ppk, + pool.as_deref().map(|available| available.as_slice()), + need, + ) { + Ok(layout) => { + if let Some(pool) = pool.as_mut() { + take(pool, &layout.picked); + } + layout + } + Err(reason) => { + drop(pool); + let reason = + format!("wallet refuses to prepare melt quote {quote_id}: {reason}"); + self.pay_refusals.push(reason.clone()); + return Err(MeltFailure::Failed(reason)); + } + } + }; + let FakeLayout { + picked: selected, + input_fee_sats, + swap_fee_sats, + requires_swap, + } = layout; + // Fee-metadata drift knob: the PREPARED figure the SDK reports may differ from the + // layout's; the actual fee on the swapped proofs (recomputed by the bound) does not. + let input_fee_sats = self.prepared_input_override.unwrap_or(input_fee_sats); + let prepare_now = self.now_unix(); + if u64::try_from(prepare_now).is_ok_and(|now| now > quote.expiry_unix) { + // CDK wallet `initialize_melt`: `expiry > unix_time()` at prepare — the wallet's + // clock, the LAST expiry check on the path; nothing after it looks at expiry. + self.return_proofs(&selected); + let reason = format!( + "wallet refuses to prepare melt quote {quote_id}: it expired at unix {} (now {prepare_now})", + quote.expiry_unix + ); + self.pay_refusals.push(reason.clone()); + return Err(MeltFailure::Failed(reason)); + } + // The one gate on the prepared figures (addendum 10 §1.1) — the SDK's figures through + // the same `confirm_bound` the shipped path takes, same refusal wording. + let total_debit_sats = match ceiling.admits_confirmable( + quote.amount_sats, + quote.fee_reserve_sats, + Some(input_fee_sats), + swap_fee_sats, + self.input_fee_ppk, + requires_swap, + ) { + Ok(bound) => bound.worst_debit_sats, + Err(shortfall) => { + self.return_proofs(&selected); + let refusal = match shortfall { + ConfirmShortfall::DifferentInvoice { + invoice_sats, + planned_invoice_sats, + } => WalletOpsError::MeltExceedsCeiling { + mint_url: "https://mint.example".to_owned(), + quote_id: quote_id.to_owned(), + invoice_sats, + fee_reserve_sats: quote.fee_reserve_sats, + planned_invoice_sats, + max_debit_sats: ceiling.max_debit_sats, + }, + ConfirmShortfall::TargetShort { bound, .. } => { + WalletOpsError::MeltWouldNotConfirm { + mint_url: "https://mint.example".to_owned(), + quote_id: quote_id.to_owned(), + invoice_sats: quote.amount_sats, + fee_reserve_sats: quote.fee_reserve_sats, + input_fee_sats, + actual_input_fee_sats: bound.actual_input_fee_sats, + target_sats: bound.target_sats, + swap_fee_sats, + input_fee_ppk: self.input_fee_ppk, + } + } + ConfirmShortfall::OverCeiling { + bound, + max_debit_sats, + } => WalletOpsError::MeltTotalExceedsCeiling { + mint_url: "https://mint.example".to_owned(), + quote_id: quote_id.to_owned(), + invoice_sats: quote.amount_sats, + fee_reserve_sats: quote.fee_reserve_sats, + input_fee_sats: bound.actual_input_fee_sats, + swap_fee_sats, + total_sats: bound.worst_debit_sats, + max_debit_sats, + }, + }; + let reason = refusal.to_string(); + self.ceiling_refusals.push(reason.clone()); + return Err(MeltFailure::RefusedBeforeSpending(reason)); + } + }; + let token = self.next_token; + self.next_token += 1; + let preparation = MeltPreparation { + mint_url: "https://mint.example".to_owned(), + quote_id: quote_id.to_owned(), + invoice_sats: quote.amount_sats, + fee_reserve_sats: quote.fee_reserve_sats, + input_fee_sats, + swap_fee_sats, + requires_swap, + input_fee_ppk: self.input_fee_ppk, + total_debit_sats, + expiry_unix: quote.expiry_unix, + }; + self.prepared.insert( + token, + FakePrepared { + quote_id: quote_id.to_owned(), + quote, + selected, + input_fee_sats, + swap_fee_sats, + requires_swap, + }, + ); + Ok(PreparedMelt::fake(preparation, token)) + } + + /// `confirm`, in the shape of pinned CDK 0.17.2 `MeltSaga::request_melt_with_options` + /// (`melt/saga/mod.rs:647–760`), then the MINT half (CDK mint `setup_melt`). + /// + /// Swap branch first (`requires_swap`, the positive-ppk layout): the selected proofs are + /// POSTED to the mint's swap (`:678–697` → `swap_no_reserve`, `swap/saga/mod.rs:223–227`) + /// for a target of invoice + reserve + the PREPARED input fee (`:678`); the mint keeps the + /// swap fee — recomputed on the proofs actually sent (`swap/saga/mod.rs:108–145`), which + /// under fixed fee metadata equals the prepared `swap_fee` — and hands back the target in + /// its binary split (`swap/saga/mod.rs:285–301`) plus the rest as change + /// (`swap/mod.rs:146–175`, `include_fees = false`). **The swap fee is charged at this point + /// whether or not the melt after it succeeds.** Then the wallet RECOMPUTES the input fee on + /// the proofs it now holds for the melt (`:704`) and refuses with `InsufficientFunds` + /// (`:706–712`) when they do not cover invoice + reserve + that ACTUAL input fee: no melt + /// is posted, the swapped proofs stay in the wallet, the swap fee is gone. Only then does + /// the payer pause at `melt_gate` — after its last local check, before the melt request + /// reaches the mint — and the mint accepts the request when the quote is UNPAID **or + /// FAILED** — with NO expiry check, however long ago the quote expired — and rejects it + /// when PENDING, PAID or UNKNOWN (the melt's proofs return to the wallet). Accepted ⇒ the + /// debit is counted, the quote is PAID for everyone reading the registry, and the mint + /// returns as change what the proofs exceeded invoice + its Lightning fee + the actual + /// input fee by. `fee_sats` is CDK `FinalizedMelt::fee_paid` (`melt/saga/mod.rs:139–148`): + /// proofs sent − invoice − change returned = Lightning fee + ACTUAL input fee, inclusive; it + /// does not contain the swap fee. The scripted `(paid, fee)` is the mint's (invoice, + /// Lightning fee) — the Lightning fee alone, never above the reserve. + /// + /// Exact-fit branch (a fee-free mint, every test written before addendum 8): no swap, the + /// established conservative model stands — the exact set for amount + reserve is spent + /// whole and the unused reserve is NOT returned (overstating the loss, never understating + /// it), so the two-process tests' proof-set assertions hold. + fn confirm_melt(&mut self, prepared: PreparedMelt) -> Result { + let token = prepared + .fake_token() + .expect("the Fake confirms only melts it prepared"); + let FakePrepared { + quote_id, + quote, + selected, + input_fee_sats, + swap_fee_sats: prepared_swap_fee_sats, + requires_swap, + } = self + .prepared + .remove(&token) + .expect("a prepared melt is confirmed or cancelled once"); + let quote_id = quote_id.as_str(); + let ppk = self.input_fee_ppk; + // What the melt request will carry, and what the swap (if any) charged. + let (melt_proofs, melt_total_sats, actual_input_fee_sats, swap_fee_sats) = + if requires_swap { + let target_sats = quote.amount_sats + quote.fee_reserve_sats + input_fee_sats; + // `swap_fee` recomputed on the proofs actually sent; `selected` is empty only for + // a pool-less Fake, whose one notional proof pays the prepared figure. + let swap_fee_sats = if selected.is_empty() { + prepared_swap_fee_sats + } else { + fee_for(ppk, selected.len()) + }; + let sent_sats = selected.iter().sum::(); + if !selected.is_empty() && sent_sats < target_sats + swap_fee_sats { + // CDK `swap/mod.rs:146–157`: `checked_sub` refuses BEFORE the swap POST. + // Unreachable from `layout` (its selection covers target + own fee); kept + // so the fake never fabricates value. + self.return_proofs(&selected); + let reason = format!( + "wallet refuses to swap for melt quote {quote_id}: {sent_sats} sats of proofs do not cover the {target_sats} sats target plus {swap_fee_sats} sats swap fee" + ); + self.pay_refusals.push(reason.clone()); + return Err(MeltFailure::Failed(reason)); + } + let received = binary_split(target_sats); + let change = if selected.is_empty() { + Vec::new() + } else { + binary_split(sent_sats - target_sats - swap_fee_sats) + }; + // The swap is POSTED: the selected proofs are spent at the mint, the swap fee is + // gone, the target and the change are the wallet's new proofs. + self.swaps.push(FakeSwap { + quote_id: quote_id.to_owned(), + sent: selected.clone(), + target_sats, + swap_fee_sats, + received: received.clone(), + change: change.clone(), + }); + self.proofs_spent.push(selected); + self.return_proofs(&change); + // `:704–712`: the ACTUAL input fee on the proofs the melt will send. + let actual_input_fee_sats = fee_for(ppk, received.len()); + let needed_sats = + quote.amount_sats + quote.fee_reserve_sats + actual_input_fee_sats; + if target_sats < needed_sats { + // `InsufficientFunds` after the swap: compensations revert proof STATES + // (the swapped proofs stay spendable in the wallet); nothing reaches the + // melt endpoint; the swap fee is not refunded. + self.return_proofs(&received); + let reason = format!( + "wallet refuses to melt quote {quote_id} after its swap: {target_sats} sats of proofs ({received:?}) do not cover {} sats invoice + {} sats fee reserve + {actual_input_fee_sats} sats actual proof input fee (prepared estimate {input_fee_sats} sats); the {swap_fee_sats} sats swap fee was charged", + quote.amount_sats, quote.fee_reserve_sats + ); + self.pay_refusals.push(reason.clone()); + return Err(MeltFailure::Failed(reason)); + } + (received, target_sats, actual_input_fee_sats, swap_fee_sats) + } else { + let total = selected.iter().sum::(); + (selected, total, 0, 0) + }; + if let Some(gate) = &self.melt_gate { + gate.arrive_and_wait(); + } + // The quote's state as the mint holds it NOW (the registry), not as the wallet loaded + // it before the pause: another process may have moved it meanwhile. + let state_at_mint = self + .registered(quote_id) + .map(|current| current.state) + .unwrap_or(quote.state); + if !matches!( + state_at_mint, + MeltQuoteState::Unpaid | MeltQuoteState::Failed + ) { + self.return_proofs(&melt_proofs); + let reason = format!( + "mint https://mint.example refuses to pay melt quote {quote_id}: it is {state_at_mint}" + ); + self.pay_refusals.push(reason.clone()); + return Err(MeltFailure::Failed(reason)); + } + self.melts.push(quote.bolt11.clone()); + if let Some(counter) = &self.melt_counter { + counter.fetch_add(1, Ordering::SeqCst); + } + let (paid, lightning_fee) = self.melt_results.remove(0).map_err(MeltFailure::Failed)?; + // Swap branch: the mint consumes the melt's proofs, pays the invoice, keeps its + // Lightning fee and the actual input fee, and returns the rest as change (CDK + // `melt/saga/mod.rs:136–148`: `fee_paid` = proofs − invoice − change). Exact-fit + // branch: no change is modelled (see above); `fee_paid` is the scripted Lightning fee, + // the input fee being 0 on a fee-free mint. + let fee_paid_sats = if requires_swap { + let change_sats = melt_total_sats + .saturating_sub(paid) + .saturating_sub(lightning_fee) + .saturating_sub(actual_input_fee_sats); + if self.proofs.is_some() && change_sats > 0 { + self.return_proofs(&binary_split(change_sats)); + } + melt_total_sats + .saturating_sub(paid) + .saturating_sub(change_sats) + } else { + self.proofs_spent.push(melt_proofs); + lightning_fee + }; + if let Some(registry) = &self.registry + && let Some(entry) = registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_mut(quote_id) + { + entry.state = MeltQuoteState::Paid; + } + Ok(MeltOutcome { + mint_url: "https://mint.example".to_owned(), + paid_sats: paid, + fee_sats: fee_paid_sats, + balance_sats: 1_000, + balance_after_sats: if self.balance_read_fails { + None + } else { + Some(1_000) + }, + quote_id: quote_id.to_owned(), + fee_reserve_sats: quote.fee_reserve_sats, + input_fee_sats, + swap_fee_sats, + }) + } + + /// Release a prepared melt: its held proofs go back to the pool; nothing was posted. + fn cancel_melt(&mut self, prepared: PreparedMelt) -> Result<(), MeltFailure> { + let token = prepared + .fake_token() + .expect("the Fake cancels only melts it prepared"); + let FakePrepared { + quote_id, selected, .. + } = self + .prepared + .remove(&token) + .expect("a prepared melt is confirmed or cancelled once"); + self.return_proofs(&selected); + self.cancels.push(quote_id); + Ok(()) + } + + /// By invoice: the most alive of the quotes raised for it (registry), else the script. + fn melt_status(&mut self, bolt11: &str) -> Result, String> { + self.status_calls.push(bolt11.to_owned()); + let Some(registry) = &self.registry else { + return self.status.clone(); + }; + let now = self.now_unix(); + let registry = registry.lock().unwrap_or_else(|e| e.into_inner()); + Ok(registry + .iter() + .filter(|(_, quote)| quote.bolt11 == bolt11) + .min_by_key(|(_, quote)| Self::liveness(quote, now)) + .map(|(quote_id, quote)| Self::status_of(quote_id, quote))) + } + + /// By id: exactly that quote (registry), else the script. + fn melt_status_for_quote( + &mut self, + quote_id: &str, + ) -> Result, String> { + self.quote_status_calls.push(quote_id.to_owned()); + if self.registry.is_none() { + return self.status.clone(); + } + Ok(self + .registered(quote_id) + .map(|quote| Self::status_of(quote_id, "e))) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier}; + + use super::test_support::{ + Fake, FakeLayout, Gate, QuoteRegistry, fake_proofs, fee_for, layout, quote_registry, + }; + use super::*; + use crate::seller_node::STATE_DB_FILE; + use crate::seller_node::store::{ReceiptFees, RemittanceState}; + use crate::wallet_ops::post_swap_figures; + + static NEXT: AtomicU64 = AtomicU64::new(0); + + /// Addendum 10 §2: a pre-fence arithmetic refusal is ONE line and writes nothing to the row — + /// it stays Planned, unbound, with its receipts pinned and its planned quote on it; nothing of + /// the old stanza ("A seller never pays…", "released … back to unremitted") follows. + fn assert_refused_before_fence_row_stays_planned( + out: &str, + row: &FeeRemittance, + gross: u64, + planned_quote_id: &str, + ) { + let refusals: Vec<&str> = out + .lines() + .filter(|line| line.starts_with("REFUSED before spending — ")) + .collect(); + assert_eq!(refusals.len(), 1, "exactly one refusal line:\n{out}"); + assert!( + refusals[0].ends_with(&format!( + "; ceiling {gross} sats; nothing left the wallet; the row stays planned and the next attempt re-quotes." + )), + "{}", + refusals[0] + ); + assert_eq!( + out.matches("REFUSED before spending").count(), + 1, + "the refusal is stated once:\n{out}" + ); + assert!( + !out.contains("A seller never pays more than it accrued") + && !out.contains("back to unremitted"), + "nothing of the old stanza follows the one line:\n{out}" + ); + assert_eq!( + row.state, + RemittanceState::Planned, + "the row is not written" + ); + assert_eq!(row.spending_since_unix, None, "never admitted"); + assert_eq!(row.spending_quote_id, None, "no quote bound"); + assert_eq!(row.gross_sats, gross); + assert_eq!(row.receipts, 2, "receipts stay pinned to the planned row"); + assert_eq!(row.melt_quote_id.as_deref(), Some(planned_quote_id)); + } + + fn temp_home(label: &str) -> PathBuf { + let id = NEXT.fetch_add(1, Ordering::SeqCst); + let root = std::env::temp_dir().join(format!( + "maxplayer-fee-remit-{label}-{}-{id}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("mk root"); + root + } + + fn store_with_fees(label: &str, fees: &[u64]) -> (SellerStore, PathBuf) { + let root = temp_home(label); + let store = SellerStore::open(root.join(STATE_DB_FILE)).expect("open store"); + for (index, fee) in fees.iter().enumerate() { + store + .collect_receipt( + &format!("receipt-{index}"), + &format!("job-{index}"), + fee * 10, + ReceiptFees { + mint_fee_sats: 1, + fee_bps: 1000, + fee_sats: *fee, + }, + index as i64 + 1, + ) + .expect("collect"); + } + (store, root) + } + + fn run_remit( + store: &SellerStore, + fake: &mut Fake, + trigger: RemitTrigger, + now: i64, + ) -> (RemitOutcome, String) { + // The fence reads the clock fresh; a single-process test's clock is the time it runs at. + fake.set_clock(now); + let mut out = Vec::new(); + let outcome = remit(store, fake, trigger, now, &mut out).expect("remit runs"); + (outcome, String::from_utf8(out).expect("utf8")) + } + + fn is_paid(outcome: &RemitOutcome) -> bool { + matches!(outcome, RemitOutcome::Paid { .. }) + } + + // §3.2: no flag ⇒ dry run. It resolves, quotes, prints every figure, and MOVES NOTHING: no melt, + // no journal row, no attempt row, the unremitted balance untouched. + #[test] + fn dry_run_prints_the_plan_and_moves_nothing() { + let (store, root) = store_with_fees("dry-run", &[10, 5]); + let mut fake = Fake::new(|_| 2); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::DryRun, 100); + assert_eq!(outcome, RemitOutcome::DryRun, "{out}"); + for needle in [ + "Recent attempts: none journaled yet", + "Accrued platform fee: 15 sats all-time — 0 sats remitted, 15 sats unremitted", + "Destination: maxplayer@agi.cash (LNURL-pay; accepts 1 to 1000000 sats)", + "unremitted platform fee (gross): 15 sats", + "mint melt fee reserve (bounds the Lightning fee): 2 sats — taken out of the gross, never on top", + "invoice amount (maxplayer@agi.cash receives): 13 sats", + "leaves your wallet: at most 15 sats (≤ 15)", + "mint: https://mint.example (melt quote quote-lnbc-fake-13-2)", + "invoice payment hash: hash-13-2", + "DRY RUN — nothing moved. Re-run with --confirm to pay 13 sats to maxplayer@agi.cash.", + ] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + assert!(fake.melts.is_empty(), "a dry run never melts"); + assert_eq!( + fake.invoices, + vec![15, 13], + "probe on the gross, then invoice the net" + ); + assert_eq!(fake.estimates.len(), 2); + assert!( + store.remittances().expect("rows").is_empty(), + "a dry run journals nothing" + ); + assert!( + store + .recent_remit_attempts(10) + .expect("attempts") + .is_empty(), + "a dry run is not an attempt" + ); + assert_eq!(store.accrued_fees().expect("read").unremitted_fee_sats, 15); + let _ = std::fs::remove_dir_all(&root); + } + + // §3.2 + §3.3 + §3.4: `--confirm` pays ONCE, the fee comes out of the gross, the settlement is + // journaled with gross / melt fee / net / destination literal / payment hash / quote id, the + // receipts are discharged, the attempt is journaled PAID — and a second `--confirm` pays nothing. + #[test] + fn confirm_pays_once_takes_the_fee_out_of_the_gross_and_is_idempotent() { + let (store, root) = store_with_fees("confirm", &[10, 5]); + let mut fake = Fake::new(|_| 2); + fake.melt_results = vec![Ok((13, 1))]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + assert_eq!( + outcome, + RemitOutcome::Paid { + remittance_id: "hash-13-2".to_owned(), + net_sats: 13, + melt_fee_sats: 1, + }, + "{out}" + ); + assert_eq!( + fake.melts, + vec!["lnbc-fake-13-2".to_owned()], + "exactly one melt, of the NET invoice" + ); + for needle in [ + "Journaled remittance hash-13-2 covering 2 receipts (owner fake-owner, lease until unix 400); paying...", + "PAID — remittance hash-13-2 settled", + "gross discharged: 15 sats", + "melt fee taken by the mint: 1 sats", + "net paid to maxplayer@agi.cash: 13 sats", + "stays in your wallet (unused reserve): 1 sats", + "wallet balance now: 1000 sats at https://mint.example", + "receipts discharged: 2", + ] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + assert!(!out.contains("WARNING"), "{out}"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + let row = &rows[0]; + assert_eq!(row.state, RemittanceState::Settled); + assert_eq!(row.remittance_id, "hash-13-2"); + assert_eq!(row.payment_hash, "hash-13-2"); + assert_eq!(row.bolt11, "lnbc-fake-13-2"); + assert_eq!( + (row.gross_sats, row.melt_fee_sats, row.net_sats), + (15, Some(1), 13) + ); + assert_eq!( + row.destination, "maxplayer@agi.cash", + "the literal paid is journaled" + ); + assert_eq!( + row.melt_quote_id, + Some("paid-quote-lnbc-fake-13-2".to_owned()) + ); + assert_eq!((row.created_at_unix, row.settled_at_unix), (100, Some(100))); + assert_eq!(row.receipts, 2); + let accrued = store.accrued_fees().expect("read"); + assert_eq!(accrued.unremitted_fee_sats, 0); + assert_eq!(accrued.remitted_fee_sats, 15); + assert!( + accrued + .by_job + .iter() + .all(|r| r.remittance_id.as_deref() == Some("hash-13-2")) + ); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 1); + assert_eq!( + ( + attempts[0].trigger, + attempts[0].outcome, + attempts[0].unremitted_sats, + attempts[0].remittance_id.as_deref(), + attempts[0].started_at_unix, + ), + ( + RemitAttemptTrigger::Command, + RemitAttemptOutcome::Paid, + 15, + Some("hash-13-2"), + 100 + ) + ); + assert_eq!( + attempts[0].detail, + "paid 13 sats to maxplayer@agi.cash (melt fee 1 sats)" + ); + + // Idempotent: a second --confirm finds nothing unremitted, touches no network, pays nothing, + // and — a threshold refusal — journals no attempt. + let pay_requests_before = fake.pay_requests; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 101); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::NothingUnremitted), + "{out}" + ); + assert!( + out.contains("Nothing to remit. REFUSED — nothing moved."), + "{out}" + ); + assert!( + out.contains("unix 100: operator (--confirm) attempt saw 15 sats unremitted — PAID: paid 13 sats to maxplayer@agi.cash (melt fee 1 sats) [remittance hash-13-2]"), + "the command prints the journaled attempts:\n{out}" + ); + assert_eq!( + fake.pay_requests, pay_requests_before, + "no LNURL round trip" + ); + assert_eq!(fake.melts.len(), 1, "still exactly one melt, ever"); + assert_eq!(store.remittances().expect("rows").len(), 1); + assert_eq!(store.recent_remit_attempts(10).expect("attempts").len(), 1); + + // A new receipt after the settlement is the only thing the next remittance covers. + store + .collect_receipt( + "receipt-late", + "job-late", + 200, + ReceiptFees { + mint_fee_sats: 2, + fee_bps: 1000, + fee_sats: 20, + }, + 102, + ) + .expect("collect late"); + fake.melt_results = vec![Ok((18, 2))]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 103); + assert!(is_paid(&outcome), "{out}"); + assert!(out.contains("gross discharged: 20 sats"), "{out}"); + assert_eq!(fake.melts.len(), 2); + assert_eq!(store.accrued_fees().expect("read").remitted_fee_sats, 35); + let _ = std::fs::remove_dir_all(&root); + } + + // The mint's melt fee is zero (some mints charge none): one invoice, one quote, the whole gross + // goes to the destination. + #[test] + fn a_zero_fee_reserve_invoices_the_gross_once() { + let (store, root) = store_with_fees("zero-reserve", &[10]); + let mut fake = Fake::new(|_| 0); + fake.melt_results = vec![Ok((10, 0))]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + assert!(is_paid(&outcome), "{out}"); + assert_eq!( + fake.invoices, + vec![10], + "no second invoice when the reserve is zero" + ); + assert_eq!(fake.melts, vec!["lnbc-fake-10-1".to_owned()]); + assert!( + out.contains("net paid to maxplayer@agi.cash: 10 sats"), + "{out}" + ); + assert!( + out.contains("stays in your wallet (unused reserve): 0 sats"), + "{out}" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // §3.2 / addendum rule 4: below the resolved minSendable ⇒ refuse with the shortfall printed, no + // invoice requested, nothing journaled — not a remittance row, not an attempt row. This is the + // steady state for small sellers, not an error, from every trigger. + #[test] + fn below_the_resolved_minimum_refuses_with_the_shortfall_and_journals_nothing() { + // Two 10-sat jobs at 10% owe 1 sat each ⇒ gross 2; the destination wants 5000 msat = 5 sats. + let (store, root) = store_with_fees("below-min", &[1, 1]); + let mut fake = Fake::new(|_| 0); + fake.min_msat = 5000; + for trigger in [ + RemitTrigger::DryRun, + RemitTrigger::Command, + RemitTrigger::Collect, + ] { + let (outcome, out) = run_remit(&store, &mut fake, trigger, 100); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::BelowMinimum { + unremitted: 2, + min_sats: 5, + }), + "{out}" + ); + assert!( + out.contains("REFUSED — unremitted 2 sats is below the destination's minimum of 5 sats (3 sats short)."), + "{out}" + ); + assert!( + out.contains("accumulates until it clears the minimum"), + "{out}" + ); + } + assert!( + fake.invoices.is_empty(), + "no invoice is requested below the minimum" + ); + assert!(fake.melts.is_empty()); + assert!(store.remittances().expect("rows").is_empty()); + assert!( + store + .recent_remit_attempts(10) + .expect("attempts") + .is_empty(), + "the threshold is the steady state, not an attempt" + ); + assert_eq!(store.accrued_fees().expect("read").unremitted_fee_sats, 2); + + // A minimum that is not a whole sat rounds UP: 1500 msat ⇒ 2 sats; gross 2 clears it, gross 1 does not. + fake.min_msat = 1500; + fake.melt_results = vec![Ok((2, 0))]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 101); + assert!(is_paid(&outcome), "{out}"); + let (store1, root1) = store_with_fees("below-min-1", &[1]); + let (outcome, out) = run_remit(&store1, &mut fake, RemitTrigger::Command, 102); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::BelowMinimum { + unremitted: 1, + min_sats: 2, + }), + "{out}" + ); + assert!( + out.contains("below the destination's minimum of 2 sats (1 sats short)"), + "{out}" + ); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&root1); + } + + // §3.3: the fee reserve is taken OUT of the gross — and when what is left is below the minimum, + // or nothing at all, the attempt refuses rather than paying more than the seller accrued. These + // refusals ARE journaled: an operator should see a mint whose fees eat the fee. + #[test] + fn a_fee_reserve_that_leaves_too_little_or_nothing_is_refused_and_journaled() { + // Gross 3, reserve 2 ⇒ net 1, below a 2-sat minimum. + let (store, root) = store_with_fees("reserve-below-min", &[3]); + let mut fake = Fake::new(|_| 2); + fake.min_msat = 2000; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::ReserveDoesNotFit { + gross: 3, + reserve: 2, + }), + "{out}" + ); + assert!( + out.contains("REFUSED — after the mint's melt fee reserve (2 sats) the 3 sats unremitted leaves 1 sats, below the destination's minimum of 2 sats (1 sats short)."), + "{out}" + ); + assert_eq!(fake.invoices, vec![3], "only the probe was requested"); + assert!(fake.melts.is_empty()); + assert!(store.remittances().expect("rows").is_empty()); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Refused); + assert_eq!(attempts[0].unremitted_sats, 3); + assert_eq!( + attempts[0].detail, + "the mint's melt fee reserve (2 sats) does not fit inside the 3 sats accrued" + ); + assert_eq!(attempts[0].remittance_id, None); + + // Gross 2, reserve 2 ⇒ nothing left. + let (store2, root2) = store_with_fees("reserve-eats-all", &[2]); + let mut fake = Fake::new(|_| 2); + let (outcome, out) = run_remit(&store2, &mut fake, RemitTrigger::Command, 100); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::ReserveDoesNotFit { + gross: 2, + reserve: 2, + }), + "{out}" + ); + assert!( + out.contains("needs a melt fee reserve of 2 sats to pay 2 sats, which leaves nothing for the destination"), + "{out}" + ); + assert!(fake.melts.is_empty()); + + // A reserve that GROWS on the smaller invoice (non-monotone mint) so net + reserve > gross + // is refused: the seller would pay more than it accrued. + let (store3, root3) = store_with_fees("reserve-non-monotone", &[15]); + let mut fake = Fake::new(|amount| if amount == 15 { 2 } else { 3 }); + let (outcome, out) = run_remit(&store3, &mut fake, RemitTrigger::Command, 100); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::ReserveDoesNotFit { + gross: 15, + reserve: 3, + }), + "{out}" + ); + assert!( + out.contains("quotes a 3 sats fee reserve on 13 sats, so up to 16 sats would leave the wallet against 15 sats accrued"), + "{out}" + ); + assert_eq!(fake.invoices, vec![15, 13]); + assert!(fake.melts.is_empty()); + assert!(store3.remittances().expect("rows").is_empty()); + for root in [root, root2, root3] { + let _ = std::fs::remove_dir_all(&root); + } + } + + // Above the resolved maxSendable: refuse (whole balance or nothing), name the bound. + #[test] + fn above_the_resolved_maximum_is_refused() { + let (store, root) = store_with_fees("above-max", &[50]); + let mut fake = Fake::new(|_| 0); + fake.max_msat = 20_000; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::AboveMaximum { + unremitted: 50, + max_sats: 20, + }), + "{out}" + ); + assert!( + out.contains("exceeds the destination's maximum of 20 sats"), + "{out}" + ); + assert!(fake.invoices.is_empty() && fake.melts.is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + // §3.4: an interrupted remittance is RECOVERABLE, not repeatable. The melt errors after the plan + // was journaled and the fence admitted it: the row stays SPENDING (addendum 4 §1) and the + // attempt is journaled FAILED naming it. The next run asks the mint — PAID ⇒ settled with no + // second melt; the receipts stay discharged. + #[test] + fn interrupted_after_the_plan_is_reconciled_as_paid_without_a_second_melt() { + let (store, root) = store_with_fees("interrupted-paid", &[10]); + let mut fake = Fake::new(|_| 1); + fake.melt_results = vec![Err("connection reset during confirm".to_owned())]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Collect, 100); + assert_eq!( + outcome, + RemitOutcome::MeltFailed { + remittance_id: "hash-9-2".to_owned(), + error: "connection reset during confirm".to_owned(), + }, + "{out}" + ); + assert!( + out.contains("melt failed: connection reset during confirm"), + "{out}" + ); + assert!( + out.contains("Admitted to spend at unix 100: remittance hash-9-2 is now spending, bound to melt quote paid-quote-lnbc-fake-9-2"), + "{out}" + ); + assert!( + out.contains( + "remittance hash-9-2 stays journaled as spending, bound to melt quote paid-quote-lnbc-fake-9-2: proofs may have reached the mint" + ), + "{out}" + ); + assert!( + !out.contains("Recent attempts"), + "the collect path does not print the journal into the node log:\n{out}" + ); + let in_flight = store + .in_flight_remittance() + .expect("query") + .expect("a spending row"); + assert_eq!(in_flight.state, RemittanceState::Spending); + assert_eq!(in_flight.spending_since_unix, Some(100)); + assert_eq!(in_flight.bolt11, "lnbc-fake-9-2"); + assert_eq!(fake.admitted_seen.len(), 1); + assert_eq!(fake.admitted_seen[0].state, RemittanceState::Spending); + assert_eq!(store.accrued_fees().expect("read").in_flight_fee_sats, 10); + assert_eq!(fake.melts.len(), 1); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 1); + assert_eq!( + ( + attempts[0].trigger, + attempts[0].outcome, + attempts[0].unremitted_sats, + attempts[0].remittance_id.as_deref() + ), + ( + RemitAttemptTrigger::Collect, + RemitAttemptOutcome::Failed, + 10, + Some("hash-9-2") + ) + ); + assert_eq!( + attempts[0].detail, + "melt failed: connection reset during confirm" + ); + + // Next run, the mint says PAID: settle, keep the receipts discharged, then find nothing left. + fake.status = Ok(Some(MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: "paid-quote-lnbc-fake-9-2".to_owned(), + state: MeltQuoteState::Paid, + amount_sats: 9, + fee_reserve_sats: 1, + expiry_unix: u64::MAX, + })); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 101); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::NothingUnremitted), + "{out}" + ); + assert!( + out.contains("unix 100: automatic (after collect) attempt saw 10 sats unremitted — FAILED: melt failed: connection reset during confirm [remittance hash-9-2]"), + "{out}" + ); + assert!( + out.contains("Reconciling in-flight remittance hash-9-2 (planned at unix 100 by fake-owner, lease until unix 400: 9 sats to maxplayer@agi.cash, gross 10 sats)"), + "{out}" + ); + // Addendum 10 §3: the reserve bounds the LIGHTNING fee, not the inclusive melt fee (which + // includes the actual proof input fee and was not observed on a quote paid by another run). + assert!( + out.contains("reports melt quote paid-quote-lnbc-fake-9-2 PAID — recorded as settled by reconciliation: 9 sats reached maxplayer@agi.cash; Lightning fee at most 1 sats (the quote's reserve); the inclusive melt fee (Lightning + actual proof input fee) is recorded as not observed — the mint reports PAID, not what it kept"), + "{out}" + ); + assert!( + !out.contains("melt fee at most"), + "the reserve is not presented as a bound on the inclusive melt fee:\n{out}" + ); + assert!(out.contains("Nothing to remit."), "{out}"); + // A spending row is reconciled by its BOUND quote, by id — never by the invoice. + assert_eq!( + fake.quote_status_calls, + vec!["paid-quote-lnbc-fake-9-2".to_owned()] + ); + assert!(fake.status_calls.is_empty()); + assert_eq!(fake.melts.len(), 1, "reconciliation never melts"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!(rows[0].melt_fee_sats, None, "unobserved, not invented"); + assert_eq!( + rows[0].settled_by, + Some(RemittanceState::Settled) + .map(|_| crate::seller_node::store::SettledBy::Reconciliation), + "the row says it was settled by reconciliation" + ); + assert_eq!( + rows[0].melt_fee_reserve_sats, + Some(1), + "the paying quote's reserve — the fee's ceiling — is recorded" + ); + assert_eq!(rows[0].net_sats, 9); + assert_eq!( + rows[0].melt_quote_id, + Some("paid-quote-lnbc-fake-9-2".to_owned()) + ); + assert_eq!(rows[0].settled_at_unix, Some(101)); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.unremitted_fee_sats, + accrued.in_flight_fee_sats + ), + (10, 0, 0) + ); + let _ = std::fs::remove_dir_all(&root); + } + + // The other reconciliation outcomes for a SPENDING row (the melt was admitted, then errored): + // PENDING ⇒ refuse this run, keep the row, melt nothing; UNPAID with a LIVE quote ⇒ HOLD too — + // even our own row, even long after the lease: the melt that errored may have reached the mint + // (addendum 4 §1.2), so only the mint's verdict resolves a spending row; UNPAID with the quote + // EXPIRED, however long ago, or FAILED, or no quote known ⇒ HOLD too (addendum 6 §1.2: the + // mint pays an expired UNPAID or a FAILED quote, so neither is cancellation); the ONE exit is + // the mint reporting the bound quote PAID, which settles the row by reconciliation. + #[test] + fn an_unpaid_or_pending_interrupted_attempt_is_reconciled_without_paying_twice() { + let (store, root) = store_with_fees("interrupted-unpaid", &[10]); + let mut fake = Fake::new(|_| 1); + fake.melt_results = vec![Err("insufficient funds for melt".to_owned())]; + let (outcome, _) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + assert!(matches!(outcome, RemitOutcome::MeltFailed { .. })); + + // PENDING: refuse, keep the planned row, no melt, no new invoice; journaled as a refusal + // naming the row. + fake.status = Ok(Some(MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: "q-pending".to_owned(), + state: MeltQuoteState::Pending, + amount_sats: 9, + fee_reserve_sats: 1, + expiry_unix: u64::MAX, + })); + let invoices_before = fake.invoices.len(); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Collect, 101); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: "hash-9-2".to_owned(), + owner: "fake-owner".to_owned(), + spending_since_unix: 100, + quote_id: Some("paid-quote-lnbc-fake-9-2".to_owned()), + observed: format!( + "mint https://mint.example reports melt quote q-pending PENDING (expiry unix {})", + u64::MAX + ), + held_sats: 10, + }), + "PENDING on a bound spending row is the same HELD refusal as every other non-PAID answer (addendum 7 §2): {out}" + ); + assert!( + out.contains( + "HELD: remittance hash-9-2 is SPENDING (admitted by fake-owner at unix 100), bound to melt quote paid-quote-lnbc-fake-9-2; mint https://mint.example reports melt quote q-pending PENDING" + ) && out.contains("10 sats of receipts stay pinned to it") + && out.contains("REFUSED — nothing moved by this run"), + "{out}" + ); + assert_eq!( + out.lines() + .filter(|line| line.starts_with(" HELD: remittance hash-9-2")) + .count(), + 1, + "exactly one HELD line: {out}" + ); + assert_eq!( + store + .in_flight_remittance() + .expect("query") + .expect("the row stays in flight") + .state, + RemittanceState::Spending + ); + assert_eq!(fake.melts.len(), 1); + assert_eq!( + fake.invoices.len(), + invoices_before, + "no new invoice while pending" + ); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2, "the failed melt and the pending refusal"); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Refused); + assert_eq!(attempts[0].remittance_id.as_deref(), Some("hash-9-2")); + assert!( + attempts[0].detail.starts_with( + "HELD: remittance hash-9-2 is SPENDING (admitted by fake-owner at unix 100), bound to melt quote paid-quote-lnbc-fake-9-2; mint https://mint.example reports melt quote q-pending PENDING" + ), + "the journal carries the same HELD line the operator saw: {}", + attempts[0].detail + ); + + // UNPAID with a LIVE quote (expires at unix 2000): HOLD — our own row, and the lease (until + // 400) is long gone at 1000, and neither matters: the row is SPENDING. Nothing released, no + // new invoice, journaled as a refusal naming the row. The status is the BOUND quote's. + fake.status = Ok(Some(MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: "paid-quote-lnbc-fake-9-2".to_owned(), + state: MeltQuoteState::Unpaid, + amount_sats: 9, + fee_reserve_sats: 1, + expiry_unix: 2000, + })); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 1000); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: "hash-9-2".to_owned(), + owner: "fake-owner".to_owned(), + spending_since_unix: 100, + quote_id: Some("paid-quote-lnbc-fake-9-2".to_owned()), + observed: "mint https://mint.example reports melt quote paid-quote-lnbc-fake-9-2 UNPAID (expiry unix 2000)".to_owned(), + held_sats: 10, + }), + "{out}" + ); + assert!( + out.contains("HELD: remittance hash-9-2 is SPENDING (admitted by fake-owner at unix 100), bound to melt quote paid-quote-lnbc-fake-9-2; mint https://mint.example reports melt quote paid-quote-lnbc-fake-9-2 UNPAID (expiry unix 2000); 10 sats of receipts stay pinned to it — a spending row is released by nobody and on no clock; it settles only when the mint reports that quote PAID; an operator decision, not a timeout, resolves it. REFUSED — nothing moved by this run"), + "{out}" + ); + assert_eq!(fake.melts.len(), 1); + assert_eq!( + fake.invoices.len(), + invoices_before, + "no new invoice on a held row" + ); + assert_eq!( + store + .in_flight_remittance() + .expect("query") + .expect("held") + .state, + RemittanceState::Spending + ); + assert_eq!(store.accrued_fees().expect("read").in_flight_fee_sats, 10); + + // UNPAID and the bound quote has EXPIRED at the mint (2000): still HOLD, at 2001, past the + // margin at 2061, and ten thousand seconds on (addendum 6 §1.2) — the mint pays an expired + // UNPAID quote, so no clock makes the receipts payable again. No fresh plan, no dry run of + // a new invoice, the binding kept, the melt count unchanged. + for (trigger, now) in [ + (RemitTrigger::DryRun, 2001), + (RemitTrigger::DryRun, 2061), + (RemitTrigger::Command, 12_000), + ] { + let (outcome, out) = run_remit(&store, &mut fake, trigger, now); + assert!( + matches!(outcome, RemitOutcome::Refused(Refusal::SpendingHeld { .. })), + "at {now}: {out}" + ); + assert!( + out.contains("HELD: remittance hash-9-2 is SPENDING (admitted by fake-owner at unix 100), bound to melt quote paid-quote-lnbc-fake-9-2; mint https://mint.example reports melt quote paid-quote-lnbc-fake-9-2 UNPAID (expiry unix 2000); 10 sats of receipts stay pinned to it"), + "at {now}: {out}" + ); + assert!( + !out.contains("DRY RUN"), + "no fresh plan on a held row: {out}" + ); + assert_eq!( + out.lines() + .filter(|line| line.starts_with(" HELD: remittance")) + .count(), + 1, + "exactly one HELD line per run (addendum 6 §1.3): {out}" + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1, "at {now}"); + assert_eq!(rows[0].state, RemittanceState::Spending, "at {now}"); + assert_eq!( + rows[0].spending_quote_id.as_deref(), + Some("paid-quote-lnbc-fake-9-2") + ); + assert_eq!(fake.melts.len(), 1, "at {now}"); + assert_eq!(fake.invoices.len(), invoices_before, "at {now}"); + assert_eq!(store.accrued_fees().expect("read").in_flight_fee_sats, 10); + } + // The one exit: the mint reports the bound quote PAID — settled by reconciliation, the + // receipts discharged, no second melt. (The paid figures come from the quote; the actual + // fee is recorded as not observed.) + fake.status = Ok(Some(MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: "paid-quote-lnbc-fake-9-2".to_owned(), + state: MeltQuoteState::Paid, + amount_sats: 9, + fee_reserve_sats: 1, + expiry_unix: 2000, + })); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 12_001); + assert!( + out.contains("reports melt quote paid-quote-lnbc-fake-9-2 PAID — recorded as settled by reconciliation"), + "{out}" + ); + assert!( + matches!(outcome, RemitOutcome::Refused(Refusal::NothingUnremitted)), + "settled, then nothing left to remit: {outcome:?}\n{out}" + ); + assert_eq!(fake.melts.len(), 1, "no second melt, ever"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!(rows[0].settled_by, Some(SettledBy::Reconciliation)); + assert_eq!(rows[0].melt_fee_sats, None, "not observed, not invented"); + assert_eq!(store.accrued_fees().expect("read").remitted_fee_sats, 10); + + // No quote at all for a SPENDING row's invoice (the wallet has no record — the live wallet + // always holds at least the estimate quote, so this is an anomaly, not a normal failure): + // HOLD, fail-closed — "no quote" is not the mint saying terminal. FAILED holds too: the + // mint pays a FAILED quote (CDK 0.17.2), so FAILED is not cancellation either. + let (store2, root2) = store_with_fees("interrupted-noquote", &[10]); + let mut fake2 = Fake::new(|_| 1); + fake2.melt_results = vec![Err("mint unreachable".to_owned())]; + assert!(matches!( + run_remit(&store2, &mut fake2, RemitTrigger::Command, 100).0, + RemitOutcome::MeltFailed { .. } + )); + fake2.status = Ok(None); + let (outcome, out) = run_remit(&store2, &mut fake2, RemitTrigger::DryRun, 101); + assert!( + matches!(outcome, RemitOutcome::Refused(Refusal::SpendingHeld { .. })), + "{out}" + ); + assert!( + out.contains( + "this wallet holds no such melt quote; 10 sats of receipts stay pinned to it" + ), + "{out}" + ); + assert_eq!( + store2.remittances().expect("rows")[0].state, + RemittanceState::Spending + ); + fake2.status = Ok(Some(status( + MeltQuoteState::Failed, + "paid-quote-lnbc-fake-9-2", + ))); + let (outcome, out) = run_remit(&store2, &mut fake2, RemitTrigger::DryRun, 102); + assert!( + matches!(outcome, RemitOutcome::Refused(Refusal::SpendingHeld { .. })), + "{out}" + ); + assert!( + out.contains("reports melt quote paid-quote-lnbc-fake-9-2 FAILED (expiry unix"), + "{out}" + ); + assert!(!out.contains("released 10 sats"), "{out}"); + assert_eq!( + store2.remittances().expect("rows")[0].state, + RemittanceState::Spending + ); + assert_eq!(store2.accrued_fees().expect("read").in_flight_fee_sats, 10); + assert_eq!(fake2.melts.len(), 1); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&root2); + } + + // A status query that itself fails is an error that changes nothing: the row stays planned, no + // melt is attempted — and the attempt is journaled FAILED against the in-flight row. + #[test] + fn a_reconciliation_that_cannot_reach_the_mint_leaves_the_row_planned() { + let (store, root) = store_with_fees("reconcile-error", &[10]); + let mut fake = Fake::new(|_| 1); + fake.melt_results = vec![Err("boom".to_owned())]; + assert!(matches!( + run_remit(&store, &mut fake, RemitTrigger::Command, 100).0, + RemitOutcome::MeltFailed { .. } + )); + fake.status = Err("mint unreachable".to_owned()); + let mut out = Vec::new(); + let error = remit(&store, &mut fake, RemitTrigger::Command, 101, &mut out) + .expect_err("status failure surfaces"); + assert_eq!(error, "mint unreachable"); + assert!(store.in_flight_remittance().expect("query").is_some()); + assert_eq!(fake.melts.len(), 1); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Failed); + assert_eq!(attempts[0].detail, "mint unreachable"); + assert_eq!(attempts[0].remittance_id.as_deref(), Some("hash-9-2")); + assert_eq!( + attempts[0].unremitted_sats, 0, + "the balance is in flight, not unremitted" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum gate 2b, the effects half: the LNURL host is unreachable. `remit_best_effort` returns + // a report — never an error the collect path has to handle — the balance is intact, and the + // failure is journaled so the read-out shows it. (The collect half — receipt journaled, job + // marked paid — is `seller_node::run`'s test on the real collect write.) + #[test] + fn best_effort_catches_a_failed_attempt_and_leaves_the_balance_intact() { + let (store, root) = store_with_fees("best-effort-fails", &[10]); + let mut fake = Fake::new(|_| 1); + fake.pay_request_error = Some("agi.cash: connection refused".to_owned()); + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 100); + assert_eq!( + report.outcome, + Err("agi.cash: connection refused".to_owned()) + ); + assert!(!report.is_quiet()); + assert_eq!( + report.summary(), + "attempt FAILED (agi.cash: connection refused); the balance stays unremitted and the node retries with backoff while it runs" + ); + assert!( + report.is_failure(), + "an unreachable host is a pacing failure" + ); + assert!( + report.lines.iter().any(|line| line + == "Accrued platform fee: 10 sats all-time — 0 sats remitted, 10 sats unremitted"), + "{:?}", + report.lines + ); + assert!(fake.invoices.is_empty() && fake.melts.is_empty()); + assert_eq!(store.accrued_fees().expect("read").unremitted_fee_sats, 10); + assert!(store.remittances().expect("rows").is_empty()); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].trigger, RemitAttemptTrigger::Collect); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Failed); + assert_eq!(attempts[0].unremitted_sats, 10); + assert_eq!(attempts[0].detail, "agi.cash: connection refused"); + + // The next attempt (a collect here; the retry tick is the other trigger) succeeds: the whole + // balance, old and new, is paid once. + store + .collect_receipt( + "receipt-next", + "job-next", + 50, + ReceiptFees { + mint_fee_sats: 1, + fee_bps: 1000, + fee_sats: 5, + }, + 101, + ) + .expect("collect"); + let mut fake = Fake::new(|_| 1); + fake.melt_results = vec![Ok((14, 1))]; + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 102); + assert_eq!( + report.outcome, + Ok(RemitOutcome::Paid { + remittance_id: "hash-14-2".to_owned(), + net_sats: 14, + melt_fee_sats: 1, + }) + ); + assert_eq!(store.accrued_fees().expect("read").unremitted_fee_sats, 0); + assert_eq!(store.accrued_fees().expect("read").remitted_fee_sats, 15); + + // And a collect that lands below the threshold is quiet: no attempt journaled. + let attempts_before = store.recent_remit_attempts(10).expect("attempts").len(); + store + .collect_receipt( + "receipt-tiny", + "job-tiny", + 5, + ReceiptFees { + mint_fee_sats: 1, + fee_bps: 1000, + fee_sats: 0, + }, + 103, + ) + .expect("collect"); + let mut fake = Fake::new(|_| 1); + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 104); + assert_eq!( + report.outcome, + Ok(RemitOutcome::Refused(Refusal::NothingUnremitted)) + ); + assert!(report.is_quiet()); + assert_eq!(fake.pay_requests, 0, "no network below the threshold"); + assert_eq!( + store.recent_remit_attempts(10).expect("attempts").len(), + attempts_before + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum gate 2c: two attempts race against the same balance — two threads, two connections + // to the same store, released together — and EXACTLY ONE remittance is recorded and exactly one + // melt happens. The loser is refused, never paid a second time, by whichever boundary it + // reaches first (addendum 11 §2, verdict 1ee5cb2 §5.2): the store's plan (both saw no row — + // `PlanRefused` in flight / total moved), reconciliation of the winner's PLANNED row (another + // live owner's lease stands — `HeldByOwner`), reconciliation of the winner's SPENDING row bound + // to its quote (`SpendingHeld` — the product's REQUIRED result after the fence, which round 9's + // oracle panicked on), or the winner already settled (`NothingUnremitted`). Every arm asserts + // the row, owner, quote and pinned-receipt figures it names; the two ordered tests below pin + // the after-fence and after-settled orderings deterministically. + #[test] + fn two_racing_attempts_against_the_same_balance_record_exactly_one_remittance() { + for round in 0..8 { + let (store, root) = store_with_fees(&format!("race-{round}"), &[10, 5]); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let barrier = Arc::new(Barrier::new(2)); + let mut handles = Vec::new(); + for thread in 0..2 { + let db = db.clone(); + let melts = Arc::clone(&melts); + let barrier = Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + // A connection of its own, as two collect handlers on two threads would have. + let store = SellerStore::open(&db).expect("open"); + let mut fake = Fake::new(|_| 2); + // Two processes are two owners (`process_owner` is per process). With ONE shared + // owner string a loser that met the winner's PLANNED row would release it as + // "its own earlier attempt" (`ReleaseOn::OwnPlanned`) — a shape no deployment + // has (addendum 11 §2). + fake.owner = format!("proc-t{thread}"); + // Two real LNURL calls never hand out the same invoice: distinct hashes, so + // the loser cannot be masked by a DuplicateInvoice on the winner's hash. + fake.invoice_tag = format!("-t{thread}"); + fake.melt_results = vec![Ok((13, 1))]; + fake.melt_counter = Some(melts); + fake.set_clock(100 + thread); + barrier.wait(); + let mut out = Vec::new(); + let outcome = remit( + &store, + &mut fake, + RemitTrigger::Collect, + 100 + thread, + &mut out, + ); + ( + outcome, + String::from_utf8_lossy(&out).into_owned(), + fake.invoices.clone(), + fake.melts.len(), + ) + })); + } + let results: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().expect("thread")) + .collect(); + + assert_eq!( + melts.load(Ordering::SeqCst), + 1, + "exactly one melt: {results:?}" + ); + let winners: Vec = results + .iter() + .enumerate() + .filter(|(_, (outcome, ..))| matches!(outcome, Ok(RemitOutcome::Paid { .. }))) + .map(|(thread, _)| thread) + .collect(); + assert_eq!(winners.len(), 1, "exactly one attempt paid: {results:?}"); + let winner_thread = winners[0]; + let winner_owner = format!("proc-t{winner_thread}"); + let winner_row_id = format!("hash-13-2-t{winner_thread}"); + let winner_quote = format!("paid-quote-lnbc-fake-13-2-t{winner_thread}"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1, "exactly one remittance row: {rows:?}"); + let winner = &rows[0]; + assert_eq!(winner.state, RemittanceState::Settled); + assert_eq!(winner.remittance_id, winner_row_id); + assert_eq!(winner.owner.as_deref(), Some(winner_owner.as_str())); + assert_eq!( + winner.spending_quote_id.as_deref(), + Some(winner_quote.as_str()) + ); + assert_eq!((winner.gross_sats, winner.net_sats), (15, 13)); + assert_eq!(winner.receipts, 2); + for (thread, (outcome, out, invoices, own_melts)) in results.iter().enumerate() { + match outcome { + Ok(RemitOutcome::Paid { + remittance_id, + net_sats, + melt_fee_sats, + }) => { + assert_eq!(remittance_id, &winner_row_id); + assert_eq!((*net_sats, *melt_fee_sats), (13, 1)); + assert_eq!(*own_melts, 1); + } + Ok(RemitOutcome::Refused(Refusal::PlanRefused(reason))) => { + assert!( + reason.contains("still in flight") + || reason.contains("nothing to remit") + || reason.contains("unremitted total moved"), + "the loser is refused by the store's plan: {reason}\n{out}" + ); + assert_eq!(*own_melts, 0, "{out}"); + } + Ok(RemitOutcome::Refused(Refusal::NothingUnremitted)) => { + assert!(invoices.is_empty(), "nothing to plan on: {out}"); + assert_eq!(*own_melts, 0, "{out}"); + } + Ok(RemitOutcome::Refused(Refusal::HeldByOwner { + remittance_id, + owner, + lease_until_unix, + })) => { + // The loser met the winner's row PLANNED (journaled, not yet fenced): the + // winner is another live owner whose lease stands, so the loser holds. + assert_ne!(thread, winner_thread); + assert_eq!(remittance_id, &winner_row_id, "{out}"); + assert_eq!(owner, &winner_owner, "{out}"); + assert_eq!(*lease_until_unix, 400 + winner_thread as i64, "{out}"); + assert!( + out.contains(&format!( + "remittance {winner_row_id} is planned by another live process ({winner_owner}, lease until unix {lease_until_unix}) and its quote is not terminal; not releasing a live payer's intent" + )), + "{out}" + ); + assert!(invoices.is_empty(), "held before planning: {out}"); + assert_eq!(*own_melts, 0, "{out}"); + } + Ok(RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id, + owner, + spending_since_unix, + quote_id, + observed, + held_sats, + })) => { + // The loser started reconciliation after the winner's fence: the winner's + // row is SPENDING, bound to the winner's payment quote — which the LOSER's + // wallet never raised — and the whole gross stays pinned to it. + assert_ne!(thread, winner_thread); + assert_eq!(remittance_id, &winner_row_id, "{out}"); + assert_eq!(owner, &winner_owner, "{out}"); + assert_eq!( + Some(*spending_since_unix), + winner.spending_since_unix, + "{out}" + ); + assert_eq!(*spending_since_unix, 100 + winner_thread as i64); + assert_eq!(quote_id.as_deref(), Some(winner_quote.as_str()), "{out}"); + assert_eq!(observed, "this wallet holds no such melt quote", "{out}"); + assert_eq!(*held_sats, 15, "the winner's gross, pinned: {out}"); + assert!( + out.contains(&format!( + "HELD: remittance {winner_row_id} is SPENDING (admitted by {winner_owner} at unix {spending_since_unix}), bound to melt quote {winner_quote}; this wallet holds no such melt quote; 15 sats of receipts stay pinned to it — a spending row is released by nobody and on no clock; it settles only when the mint reports that quote PAID; an operator decision, not a timeout, resolves it. REFUSED — nothing moved by this run" + )), + "{out}" + ); + assert!(invoices.is_empty(), "held before planning: {out}"); + assert_eq!(*own_melts, 0, "{out}"); + } + other => panic!("unexpected outcome {other:?}\n{out}"), + } + } + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.unremitted_fee_sats, + accrued.in_flight_fee_sats + ), + (15, 0, 0) + ); + let _ = std::fs::remove_dir_all(&root); + } + } + + // Addendum 11 §2, ordered (i): the racing test's after-fence ordering, deterministic. A (proc-a) + // pauses right after its fence admitted the melt and bound its quote — its row is SPENDING — + // and B (proc-b, its own wallet, which never raised A's quote) runs `--confirm`: B reconciles + // A's bound spending row, its wallet holds no such quote ⇒ `SpendingHeld` naming A's row, A's + // owner, A's bound quote and the 15 pinned sats, one HELD line; B plans nothing (no invoice), + // pays nothing, changes nothing. A resumes and pays once. One melt, one Paid, one Settled row, + // accounting (15, 0, 0); B's refused confirm journaled against A's row. + #[test] + fn a_racer_that_starts_after_the_winners_fence_is_held_on_the_winners_bound_quote_and_the_winner_pays_once() + { + let (store, root) = store_with_fees("race-after-fence", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let mut a = Fake::new(|_| 2); + a.owner = "proc-a".to_owned(); + a.invoice_tag = "-a".to_owned(); + a.melt_results = vec![Ok((13, 1))]; + let (a_result, b_results) = run_paused( + &db, + a, + 100, + PauseAt::Admit, + super::test_support::Gate::new(), + Arc::clone(&melts), + |store_b| { + let x = store_b + .in_flight_remittance() + .expect("query") + .expect("A's row"); + assert_eq!(x.state, RemittanceState::Spending); + assert_eq!(x.remittance_id, "hash-13-2-a"); + assert_eq!(x.spending_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + let mut b = Fake::new(|_| 2); + b.owner = "proc-b".to_owned(); + b.invoice_tag = "-b".to_owned(); + b.melt_results = vec![Ok((13, 1))]; + b.melt_counter = Some(Arc::clone(&melts)); + let (outcome, out) = run_remit(store_b, &mut b, RemitTrigger::Command, 101); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: "hash-13-2-a".to_owned(), + owner: "proc-a".to_owned(), + spending_since_unix: 100, + quote_id: Some(X_PAYMENT_QUOTE.to_owned()), + observed: "this wallet holds no such melt quote".to_owned(), + held_sats: 15, + }), + "{out}" + ); + assert!( + out.contains("HELD: remittance hash-13-2-a is SPENDING (admitted by proc-a at unix 100), bound to melt quote paid-quote-lnbc-fake-13-2-a; this wallet holds no such melt quote; 15 sats of receipts stay pinned to it — a spending row is released by nobody and on no clock; it settles only when the mint reports that quote PAID; an operator decision, not a timeout, resolves it. REFUSED — nothing moved by this run"), + "{out}" + ); + assert_eq!( + b.quote_status_calls, + vec![X_PAYMENT_QUOTE.to_owned()], + "B asked its wallet about A's bound quote, once" + ); + assert!(b.invoices.is_empty(), "B planned nothing: {out}"); + assert!( + b.quotes.is_empty() && b.melts.is_empty(), + "B paid nothing: {out}" + ); + assert_eq!( + store_b + .in_flight_remittance() + .expect("query") + .expect("still A's row") + .state, + RemittanceState::Spending, + "B changed nothing" + ); + vec![(outcome, out)] + }, + ); + let (a_outcome, a_out) = a_result; + assert_eq!( + a_outcome, + Ok(RemitOutcome::Paid { + remittance_id: "hash-13-2-a".to_owned(), + net_sats: 13, + melt_fee_sats: 1, + }), + "A pays X once it resumes:\n{a_out}" + ); + assert_eq!(melts.load(Ordering::SeqCst), 1, "exactly one actual debit"); + assert_eq!(b_results.len(), 1); + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1, "one row, A's: {rows:?}"); + assert_eq!(rows[0].remittance_id, "hash-13-2-a"); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!((rows[0].gross_sats, rows[0].net_sats), (15, 13)); + assert_eq!(rows[0].receipts, 2); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (15, 0, 0) + ); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!( + attempts.len(), + 2, + "A's payment and B's refused --confirm: {attempts:?}" + ); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Paid); + assert_eq!(attempts[1].trigger, RemitAttemptTrigger::Command); + assert_eq!(attempts[1].outcome, RemitAttemptOutcome::Refused); + assert_eq!(attempts[1].remittance_id.as_deref(), Some("hash-13-2-a")); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum 11 §2, ordered (ii): the racing test's after-settled ordering. A pays and settles + // in full; then B (another owner, its own wallet) runs `--confirm`: no row is in flight, the + // unremitted balance is 0 ⇒ `NothingUnremitted` — B raises no invoice, no quote, no melt; + // still one melt, one Settled row, accounting (15, 0, 0). + #[test] + fn a_racer_that_starts_after_the_winner_settled_finds_nothing_unremitted_and_pays_nothing() { + let (store, root) = store_with_fees("race-after-settled", &[10, 5]); + let mut a = Fake::new(|_| 2); + a.owner = "proc-a".to_owned(); + a.invoice_tag = "-a".to_owned(); + a.melt_results = vec![Ok((13, 1))]; + let (a_outcome, a_out) = run_remit(&store, &mut a, RemitTrigger::Collect, 100); + assert_eq!( + a_outcome, + RemitOutcome::Paid { + remittance_id: "hash-13-2-a".to_owned(), + net_sats: 13, + melt_fee_sats: 1, + }, + "{a_out}" + ); + assert_eq!(a.melts.len(), 1); + let mut b = Fake::new(|_| 2); + b.owner = "proc-b".to_owned(); + b.invoice_tag = "-b".to_owned(); + b.melt_results = vec![Ok((13, 1))]; + let (b_outcome, b_out) = run_remit(&store, &mut b, RemitTrigger::Command, 101); + assert_eq!( + b_outcome, + RemitOutcome::Refused(Refusal::NothingUnremitted), + "{b_out}" + ); + assert!( + b_out.contains("Nothing to remit. REFUSED — nothing moved."), + "{b_out}" + ); + assert!( + b.quote_status_calls.is_empty() && b.status_calls.is_empty(), + "no row in flight, nothing to reconcile: {b_out}" + ); + assert!( + b.invoices.is_empty() && b.quotes.is_empty() && b.melts.is_empty(), + "{b_out}" + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1, "one row, A's: {rows:?}"); + assert_eq!(rows[0].remittance_id, "hash-13-2-a"); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!((rows[0].gross_sats, rows[0].net_sats), (15, 13)); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (15, 0, 0) + ); + let _ = std::fs::remove_dir_all(&root); + } + + // ---- addendum 8 §1: the ceiling bounds the ENTIRE wallet debit (verdict B4) ---------------- + + // The fake's fee model, checked against the verdict's own arithmetic (§4 B4): a 1000-ppk keyset, + // one 32-sat proof, a quote of 13 with a 2-sat reserve against 15 accrued. need = 15 = 8+4+2+1 + // ⇒ four output proofs ⇒ input fee 4; selection 19 ⇒ the 32 is swapped ⇒ swap fee 1; total + // 13 + 2 + 4 + 1 = 20 > 15. The two-figure check admits it; the four-figure one refuses it, and + // the refusal leaves the pool untouched. + #[test] + fn the_fake_wallet_models_the_sdks_proof_input_and_swap_fees_and_the_total_bound_refuses_them() + { + assert_eq!(fee_for(1000, 4), 4); + assert_eq!(fee_for(500, 3), 2, "ceil(1500 / 1000)"); + assert_eq!(fee_for(0, 7), 0); + assert_eq!( + layout(1000, Some(&[32]), 15).expect("layout"), + FakeLayout { + picked: vec![32], + input_fee_sats: 4, + swap_fee_sats: 1, + requires_swap: true, + } + ); + assert_eq!( + layout(0, Some(&[8, 4, 2, 1]), 15).expect("layout"), + FakeLayout { + picked: vec![8, 4, 2, 1], + input_fee_sats: 0, + swap_fee_sats: 0, + requires_swap: false, + }, + "a fee-free mint keeps the exact-fit branch every earlier test relies on" + ); + assert!(layout(1000, Some(&[16]), 15).is_err(), "16 < 15 + 4 + 1"); + + let mut fake = Fake::new(|_| 2); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let ceiling = MeltCeiling { + max_debit_sats: 15, + invoice_sats: 13, + planned_quote_id: Some("quote-lnbc-fake-13-1".to_owned()), + }; + assert!(ceiling.admits(13, 2), "the quote's own figures fit exactly"); + let refused = fake + .prepare_melt("paid-quote-lnbc-fake-13-1", &ceiling) + .expect_err("the total does not fit"); + match refused { + MeltFailure::RefusedBeforeSpending(reason) => assert!( + reason.contains("would debit 19 sats in total (13 sats invoice + 2 sats fee reserve + 3 sats proof input fee + 1 sats swap fee) against a ceiling of 15 sats; the prepared melt was cancelled and its proofs released; nothing was posted to the mint"), + "{reason}" + ), + other => panic!("expected a typed refusal, got {other:?}"), + } + assert_eq!(fake.pool_value(), Some(32), "the pool is exactly as it was"); + assert_eq!(fake.ceiling_refusals.len(), 1); + assert!(fake.melts.is_empty()); + assert!(fake.prepared.is_empty(), "nothing is left prepared"); + + // The estimate carries the same fees, reserving nothing. + let estimate = fake.melt_estimate("lnbc-fake-13-1").expect("estimate"); + assert_eq!(estimate.expected_fees_sats, 5, "4 input + 1 swap"); + assert_eq!(estimate.expected_fees_note, None); + assert_eq!(fake.pool_value(), Some(32)); + + // CONFIRM model (addendum 9 §1.3), against CDK's arithmetic, not the fake's preparation. + // (a) Prepared figures fit, post-swap arithmetic does not: invoice 12, reserve 0 ⇒ need 12 + // = 8+4 ⇒ prepared input 2, swap 1, prepared total 15 ≤ 20. confirm WOULD swap the 32 to + // target 14 = [8, 4, 2] (three proofs ⇒ ACTUAL input 3) and need 12 + 0 + 3 = 15 > 14 ⇒ + // CDK refuses AFTER the swap, swap fee gone. Since addendum 10 §1.1 the wallet's prepare + // gate (`confirm_bound`, the same arithmetic) refuses this BEFORE anything is posted: no + // swap, no melt, the pool exactly as it was, proofs released. + assert_eq!(binary_split(14), vec![8, 4, 2]); + assert_eq!(binary_split(19), vec![16, 2, 1]); + assert_eq!(binary_split(0), Vec::::new()); + // §1.2 planning: the advisor's example and its neighbours. + assert_eq!( + plan_confirmable_invoice(20, 2, 1, 1000), + Some(13), + "gross 20, reserve 2: 13 pays at 19; 14 would swap to 17 < 16 + 2" + ); + assert_eq!(plan_confirmable_invoice(20, 3, 1, 1000), Some(12)); + assert_eq!(plan_confirmable_invoice(3, 1, 1, 1000), None, "never fits"); + assert_eq!( + plan_confirmable_invoice(20, 2, 0, 0), + Some(18), + "fee-free: gross − reserve" + ); + assert_eq!( + plan_confirmable_invoice(2, 2, 0, 0), + None, + "reserve eats the gross" + ); + assert_eq!(post_swap_figures(15, Some(4), 1000), (19, 3)); + assert_eq!(post_swap_figures(16, None, 1000), (17, 2)); + let mut fake = Fake::new(|_| 0); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let ceiling = MeltCeiling { + max_debit_sats: 20, + invoice_sats: 12, + planned_quote_id: Some("quote-lnbc-fake-12-1".to_owned()), + }; + let refused = fake + .prepare_melt("paid-quote-lnbc-fake-12-1", &ceiling) + .expect_err( + "the prepared total 12 + 0 + 2 + 1 = 15 ≤ 20 is NOT the gate; confirmability is", + ); + match refused { + MeltFailure::RefusedBeforeSpending(reason) => assert!( + reason.contains("would swap to 14 sats ([8, 4, 2])") + && reason.contains("actual proof input fee on those proofs is 3 sats (prepared estimate 2 sats at 1000 ppk)") + && reason.contains("would need 15 sats") + && reason.contains("the SDK would refuse AFTER paying the 1 sats swap fee"), + "{reason}" + ), + other => panic!("expected the prepare gate's typed refusal, got {other:?}"), + } + assert!(fake.swaps.is_empty(), "no swap was posted"); + assert!(fake.melts.is_empty(), "no melt was posted"); + assert!(fake.pay_refusals.is_empty(), "the SDK never got to refuse"); + assert_eq!(fake.ceiling_refusals.len(), 1, "the one gate refused it"); + assert!(fake.prepared.is_empty(), "nothing is left prepared"); + assert_eq!(fake.pool_value(), Some(32), "the pool is exactly as it was"); + assert!(fake.proofs_spent.is_empty(), "nothing was spent"); + // And §1.1 says so BEFORE the fence, from the same figures. + let refused = + confirm_would_succeed(&fake_preparation(12, 0, 2, 1, 1000), 20).expect_err("predicted"); + assert!( + refused.contains("swap to 14 sats ([8, 4, 2])") + && refused.contains("actual proof input fee on those proofs is 3 sats") + && refused.contains("would need 15 sats"), + "{refused}" + ); + + // (b) A genuinely confirmable payment: invoice 13, reserve 2 ⇒ need 15 = 8+4+2+1 ⇒ prepared + // input 4, swap 1, total 20 ≤ 20. confirm swaps to target 19 = [16, 2, 1] ⇒ ACTUAL input 3, + // needs 13 + 2 + 3 = 18 ≤ 19 ⇒ melt posted; the mint takes a 1-sat Lightning fee and returns + // 19 − 13 − 1 − 3 = 2 as change. `fee_paid` = 19 − 13 − 2 = 4 = Lightning 1 + actual input 3 + // (inclusive, swap fee excluded). Wallet: 32 → change [8, 4] from the swap + [2] from the + // melt = 14; delta 18 = 13 + 1 + 3 + 1 ≤ gross 20. + let mut fake = Fake::new(|_| 2); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + fake.melt_results = vec![Ok((13, 1))]; + let ceiling = MeltCeiling { + max_debit_sats: 20, + invoice_sats: 13, + planned_quote_id: Some("quote-lnbc-fake-13-1".to_owned()), + }; + let prepared = fake + .prepare_melt("paid-quote-lnbc-fake-13-1", &ceiling) + .expect("13 + 2 + 4 + 1 = 20 ≤ 20"); + assert_eq!( + confirm_would_succeed(&prepared.preparation, 20), + Ok(3), + "actual input fee on [16, 2, 1]; 13 + 2 + 3 + 1 = 19 ≤ 20" + ); + let outcome = fake.confirm_melt(prepared).expect("confirmable"); + assert_eq!(outcome.paid_sats, 13); + assert_eq!( + outcome.fee_sats, 4, + "fee_paid = Lightning 1 + actual input 3" + ); + assert_eq!( + outcome.input_fee_sats, 4, + "the PREPARED estimate, as the SDK reports it" + ); + assert_eq!(outcome.swap_fee_sats, 1); + assert_eq!(fake.swaps.len(), 1); + assert_eq!(fake.swaps[0].received, vec![16, 2, 1]); + assert_eq!(fake.swaps[0].change, vec![8, 4]); + assert_eq!(fake.melts.len(), 1, "one melt"); + let after = fake.pool_value().expect("pool"); + assert_eq!(after, 14, "32 − 13 − 1 − 3 − 1"); + let delta = 32 - after; + assert_eq!( + delta, + 13 + 1 + 3 + 1, + "the whole-wallet delta is the four terms" + ); + assert!(delta <= 20, "≤ gross"); + } + + /// A `MeltPreparation` with the four fee figures and the keyset ppk, for the arithmetic tests. + fn fake_preparation( + invoice_sats: u64, + fee_reserve_sats: u64, + input_fee_sats: u64, + swap_fee_sats: u64, + input_fee_ppk: u64, + ) -> MeltPreparation { + MeltPreparation { + mint_url: "https://mint.example".to_owned(), + quote_id: "paid-quote-fixture".to_owned(), + invoice_sats, + fee_reserve_sats, + input_fee_sats, + swap_fee_sats, + requires_swap: input_fee_ppk > 0, + input_fee_ppk, + total_debit_sats: invoice_sats + fee_reserve_sats + input_fee_sats + swap_fee_sats, + expiry_unix: u64::MAX, + } + } + + // Regression (i), addendum 9 §1.4: fee-bearing mint (1000 ppk), surplus funds in a layout that + // does not fit (one 32-sat proof), reserve 3 at estimate AND payment; the PREPARED input fee + // differs from the ACTUAL one and the payment still fits ⇒ pays ONCE, swap counted once, melt + // counted once, and the WALLET — measured, not counted — loses at most the gross. Gross 20 (two + // 10-sat fees). Planning (§1.2) searches down from 17: 12 is the first invoice whose post-swap + // arithmetic holds (need 15 = 8+4+2+1 ⇒ prepared input 4 ⇒ target 19 = [16, 2, 1] ⇒ actual 3; + // 19 ≥ 18; 15 + 3 + 1 = 19 ≤ 20). Payment: prepared 12 + 3 + 4 + 1 = 20 ≤ 20 admitted; confirm + // swaps the 32 to 19 (swap fee 1, change [8, 4]), recomputes 3, needs 18 ≤ 19, melts; the mint + // takes a 1-sat Lightning fee and returns 19 − 12 − 1 − 3 = 3 as change. `fee_paid` = 19 − 12 − + // 3 = 4 = Lightning 1 + actual input 3 (inclusive, CDK `melt/saga/mod.rs:139–148`). Wallet 32 → + // 12 + 3 = 15: delta 17 = 12 + 1 + 3 + 1 ≤ 20. + #[test] + fn a_fee_bearing_payment_whose_prepared_input_fee_differs_from_the_actual_pays_once_and_the_wallet_loses_at_most_the_gross() + { + let (store, root) = store_with_fees("fee-bearing-fits", &[10, 10]); + let mut fake = Fake::new(|_| 3); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + fake.registry = Some(quote_registry()); + fake.melt_results = vec![Ok((12, 1))]; + let before = fake.pool_value().expect("pool"); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + assert!(is_paid(&outcome), "{out}"); + assert!( + out.contains("invoice amount (maxplayer@agi.cash receives): 12 sats"), + "{out}" + ); + assert!( + out.contains("expected proof fees (SDK estimate, bounded exactly at payment): 5 sats = estimated proof input fee 4 sats + swap fee 1 sats; actual proof input fee the SDK recomputes on the swapped proofs: 3 sats ⇒ worst case 19 sats leaves the wallet (≤ 20) = invoice 12 + reserve 3 (bounds the Lightning fee) + actual proof input fee 3 + swap fee 1; the inclusive melt fee (Lightning + actual proof input fee) is known only at payment"), + "{out}" + ); + assert_eq!( + fake.melts, + vec!["lnbc-fake-12-2".to_owned()], + "exactly one melt" + ); + assert_eq!(fake.swaps.len(), 1, "exactly one swap"); + assert_eq!(fake.swaps[0].sent, vec![32]); + assert_eq!(fake.swaps[0].target_sats, 19); + assert_eq!(fake.swaps[0].swap_fee_sats, 1); + assert_eq!(fake.swaps[0].received, vec![16, 2, 1]); + assert_eq!(fake.swaps[0].change, vec![8, 4]); + assert!( + out.contains("Prepared melt of quote paid-quote-lnbc-fake-12-2: proof input fee 4 sats (estimate; actual on the swapped proofs 3 sats), swap fee 1 sats (the wallet's proofs do not fit: a pre-melt swap will be performed); total debit 19 sats (12 invoice + 3 reserve + fees) fits the ceiling of 20 sats; proofs reserved in this wallet only, nothing posted yet"), + "{out}" + ); + // §2 G/F2: the SDK's inclusive `fee_paid` (4 = Lightning 1 + actual input 3) is counted + // ONCE; the prepared estimate (4) is printed, not added — debit 12 + 4 + 1 = 17, not 21. + for needle in [ + "melt fee taken by the mint: 4 sats (quote paid-quote-lnbc-fake-12-2 reserved 3 sats; ceiling 20 sats held at the moment of spending; this is the SDK's fee_paid = Lightning fee + actual proof input fee)", + "estimated proof input fee (prepared): 4 sats — replaced by the actual fee inside the melt fee above, not added again; swap fee (charged at swap): 1 sats", + "actual debit: 17 sats = net + melt fee + swap fee", + "stays in your wallet (unused reserve): 3 sats", + "wallet balance now: 1000 sats at https://mint.example", + ] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + assert!( + !out.contains("WARNING"), + "no false overspend warning from double-counting the input fee:\n{out}" + ); + let after = fake.pool_value().expect("pool"); + assert_eq!( + (before, after), + (32, 15), + "measured: change [8, 4] from the swap + [2, 1] from the melt" + ); + let delta = before - after; + assert_eq!( + delta, + 12 + 1 + 3 + 1, + "invoice + Lightning fee + ACTUAL input fee + swap fee (not the prepared 4)" + ); + assert!( + delta <= 20, + "the wallet lost {delta} sats against 20 accrued" + ); + assert_eq!(fake.proofs_spent, vec![vec![32]], "the 32 went to the swap"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!((rows[0].gross_sats, rows[0].net_sats), (20, 12)); + assert_eq!( + rows[0].melt_fee_sats, + Some(4), + "`fee_paid`, inclusive: Lightning 1 + actual input 3" + ); + assert_eq!(rows[0].melt_fee_reserve_sats, Some(3)); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (20, 0, 0) + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum 9 §2.4: the same fee-bearing success (prepared input 4 ≠ actual 3) when the + // observational balance read after the payment FAILS: no false overspend warning, the residual + // balance printed as "unknown" — never a computed figure — and the settlement unaffected. + #[test] + fn a_failed_balance_read_after_a_fee_bearing_payment_prints_unknown_and_no_false_warning() { + let (store, root) = store_with_fees("fee-bearing-balance-unknown", &[10, 10]); + let mut fake = Fake::new(|_| 3); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + fake.registry = Some(quote_registry()); + fake.melt_results = vec![Ok((12, 1))]; + fake.balance_read_fails = true; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + assert!(is_paid(&outcome), "{out}"); + assert_eq!(fake.melts.len(), 1); + assert_eq!(fake.swaps.len(), 1); + assert!( + out.contains("wallet balance now: unknown (the balance read after the payment failed; the payment stands) at https://mint.example"), + "{out}" + ); + assert!( + out.contains("actual debit: 17 sats = net + melt fee + swap fee") + && out.contains("stays in your wallet (unused reserve): 3 sats"), + "{out}" + ); + assert!(!out.contains("WARNING"), "{out}"); + assert!( + !out.contains("wallet balance now: 9 sats") + && !out.contains("wallet balance now: 15 sats"), + "no computed residual is printed:\n{out}" + ); + assert_eq!( + fake.pool_value(), + Some(15), + "the payment itself is unaffected" + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!(rows[0].melt_fee_sats, Some(4)); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum 10 §1.4 (a): the reserve SHRINKS between estimate and payment (3 → 0) and the planned + // invoice would no longer confirm — planned on reserve 3: invoice 12 (need 15 ⇒ target 19, + // actual 3); at reserve 0: need 12 = 8+4 ⇒ target 14 = [8, 4, 2] ⇒ actual 3 ⇒ needs 15 > 14. + // The SAME attempt re-plans ONCE, before anything is prepared: the planner on the live reserve + // gives 15 (need 15 ⇒ prepared 4 ⇒ target 19 ⇒ actual 3; 19 ≥ 18; 15 + 0 + 3 + 1 = 19 ≤ 20), a + // 15-sat invoice and its live quote are raised, the Planned row is re-pointed at them (same + // id, same gross, same two receipts), one "Re-planned:" line prints, and the unchanged sequence + // pays it once: swap 32 → 19 (fee 1, change [8, 4]), melt 19, Lightning fee 0, change 19 − 15 − + // 0 − 3 = 1 ⇒ `fee_paid` = 19 − 15 − 1 = 3; pool 32 → 12 + 1 = 13, delta 19 ≤ 20. A + // next-attempt re-quote could not have done this: it would plan from the probe estimate again. + #[test] + fn a_reserve_that_shrinks_between_estimate_and_payment_is_re_planned_once_and_pays_invoice_15() + { + let (store, root) = store_with_fees("fee-bearing-replan", &[10, 10]); + let mut fake = Fake::new(|_| 3); + fake.live_reserve_for = Some(Box::new(|_| 0)); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let registry = quote_registry(); + fake.registry = Some(Arc::clone(®istry)); + fake.melt_results = vec![Ok((15, 0))]; + let before = fake.pool_value().expect("pool"); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + match &outcome { + RemitOutcome::Paid { + remittance_id, + net_sats, + melt_fee_sats, + } => { + assert_eq!(remittance_id, "hash-12-2", "the row keeps its id"); + assert_eq!((*net_sats, *melt_fee_sats), (15, 3)); + } + other => panic!("expected Paid, got {other:?}\n{out}"), + } + // Planned on the estimate (12), re-planned on the live reserve (15): ONE re-plan line, no + // refusal, no warning. + assert!( + out.contains("invoice amount (maxplayer@agi.cash receives): 12 sats"), + "{out}" + ); + assert_eq!( + out.lines() + .filter(|line| line.starts_with("Re-planned: ")) + .count(), + 1, + "exactly one re-plan line:\n{out}" + ); + assert!( + out.contains("Re-planned: the payment quote's fee reserve is 0 sats (planned on 3 sats); invoice 12 sats would not confirm (the wallet would swap to 14 sats and the SDK's actual proof input fee on those proofs is 3 sats, so the payment would need 15 sats and the SDK would refuse after its swap), invoice 15 sats will (at most 19 sats leaves the wallet, ≤ 20); payment quote paid-quote-lnbc-fake-15-3 raised at mint https://mint.example for 15 sats (fee reserve 0 sats); invoice payment hash: hash-15-3"), + "{out}" + ); + assert!(!out.contains("REFUSED"), "{out}"); + assert!(!out.contains("WARNING"), "{out}"); + assert!( + out.contains("Prepared melt of quote paid-quote-lnbc-fake-15-3: proof input fee 4 sats (estimate; actual on the swapped proofs 3 sats), swap fee 1 sats (the wallet's proofs do not fit: a pre-melt swap will be performed); total debit 19 sats (15 invoice + 0 reserve + fees) fits the ceiling of 20 sats; proofs reserved in this wallet only, nothing posted yet"), + "{out}" + ); + for needle in [ + "bound to melt quote paid-quote-lnbc-fake-15-3", + "melt fee taken by the mint: 3 sats (quote paid-quote-lnbc-fake-15-3 reserved 0 sats;", + "actual debit: 19 sats = net + melt fee + swap fee", + "net paid to maxplayer@agi.cash: 15 sats", + "receipts discharged: 2", + ] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + // Paid ONCE: one swap, one melt — of the RE-PLANNED invoice; the 12-sat quote was never + // prepared or paid. + assert_eq!(fake.melts, vec!["lnbc-fake-15-3".to_owned()]); + assert_eq!(fake.swaps.len(), 1); + assert_eq!(fake.swaps[0].sent, vec![32]); + assert_eq!(fake.swaps[0].target_sats, 19); + assert_eq!(fake.swaps[0].swap_fee_sats, 1); + assert_eq!(fake.swaps[0].received, vec![16, 2, 1]); + assert_eq!(fake.swaps[0].change, vec![8, 4]); + assert!( + fake.cancels.is_empty(), + "nothing was prepared before the re-plan" + ); + assert!(fake.ceiling_refusals.is_empty()); + assert!(fake.pay_refusals.is_empty()); + let after = fake.pool_value().expect("pool"); + assert_eq!((before, after), (32, 13), "change [8, 4] + [1]"); + assert_eq!( + before - after, + 15 + 3 + 1, + "delta 19 ≤ 20: invoice + actual input fee + swap fee, Lightning fee 0" + ); + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!( + quotes + .get("paid-quote-lnbc-fake-15-3") + .map(|quote| quote.state), + Some(MeltQuoteState::Paid) + ); + assert_eq!( + quotes + .get("paid-quote-lnbc-fake-12-2") + .map(|quote| quote.state), + Some(MeltQuoteState::Unpaid), + "the first payment quote was raised and never paid" + ); + drop(quotes); + // One row, re-pointed then settled: same id and gross, the re-planned invoice's figures, + // the second quote bound and settled, both receipts discharged. + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].remittance_id, "hash-12-2"); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!((rows[0].gross_sats, rows[0].net_sats), (20, 15)); + assert_eq!(rows[0].payment_hash, "hash-15-3"); + assert_eq!(rows[0].bolt11, "lnbc-fake-15-3"); + assert_eq!(rows[0].melt_fee_sats, Some(3)); + assert_eq!(rows[0].melt_fee_reserve_sats, Some(0)); + assert_eq!( + rows[0].melt_quote_id.as_deref(), + Some("paid-quote-lnbc-fake-15-3") + ); + assert_eq!( + rows[0].spending_quote_id.as_deref(), + Some("paid-quote-lnbc-fake-15-3") + ); + assert_eq!(rows[0].receipts, 2); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (20, 0, 0) + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum 10 §1.3 (verdict 4714623 §3.2's witness): gross 19 (fees 10 + 9), reserve 2 at + // estimate and payment, 1000 ppk, one 32-sat proof. The planner gives 13: need 15 = 8+4+2+1 ⇒ + // prepared 4 ⇒ target 19 = [16, 2, 1] ⇒ actual 3; 19 ≥ 18; 15 + 3 + 1 = 19 ≤ 19. The PREPARED + // total is 13 + 2 + 4 + 1 = 20 > 19 — round 8's early return on it refused this remittance; + // since addendum 10 §1.1 the one gate is the actual-confirmability bound and it PAYS, once: + // swap 32 → 19 (fee 1, change [8, 4]), melt 19, the mint keeps the whole 2-sat reserve as its + // Lightning fee (worst case) and the actual input 3, change 19 − 13 − 2 − 3 = 1 ⇒ `fee_paid` = + // 19 − 13 − 1 = 5 = Lightning 2 + actual input 3; pool 32 → 12 + 1 = 13, delta 19 = the gross + // exactly, never over. (The round-9 plan wrote "fee_paid 4, delta 19" — those two figures are + // inconsistent; with Lightning 1 the pair is 4 / 18, with Lightning 2 it is 5 / 19. The tight + // case is asserted here.) + #[test] + fn a_fee_bearing_payment_at_a_19_sat_gross_the_prepared_estimate_would_refuse_pays_invoice_13_once() + { + let (store, root) = store_with_fees("fee-bearing-19", &[10, 9]); + let mut fake = Fake::new(|_| 2); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let registry = quote_registry(); + fake.registry = Some(Arc::clone(®istry)); + fake.melt_results = vec![Ok((13, 2))]; + let before = fake.pool_value().expect("pool"); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + match &outcome { + RemitOutcome::Paid { + remittance_id, + net_sats, + melt_fee_sats, + } => { + assert_eq!(remittance_id, "hash-13-2"); + assert_eq!((*net_sats, *melt_fee_sats), (13, 5)); + } + other => panic!("expected Paid, got {other:?}\n{out}"), + } + assert!( + out.contains("invoice amount (maxplayer@agi.cash receives): 13 sats"), + "{out}" + ); + assert!( + out.contains("expected proof fees (SDK estimate, bounded exactly at payment): 5 sats = estimated proof input fee 4 sats + swap fee 1 sats; actual proof input fee the SDK recomputes on the swapped proofs: 3 sats ⇒ worst case 19 sats leaves the wallet (≤ 19) = invoice 13 + reserve 2 (bounds the Lightning fee) + actual proof input fee 3 + swap fee 1; the inclusive melt fee (Lightning + actual proof input fee) is known only at payment"), + "{out}" + ); + assert!( + out.contains("Prepared melt of quote paid-quote-lnbc-fake-13-2: proof input fee 4 sats (estimate; actual on the swapped proofs 3 sats), swap fee 1 sats (the wallet's proofs do not fit: a pre-melt swap will be performed); total debit 19 sats (13 invoice + 2 reserve + fees) fits the ceiling of 19 sats; proofs reserved in this wallet only, nothing posted yet"), + "the prepared total (20) is printed nowhere as a gate; the bound's 19 is:\n{out}" + ); + for needle in [ + "melt fee taken by the mint: 5 sats (quote paid-quote-lnbc-fake-13-2 reserved 2 sats; ceiling 19 sats held at the moment of spending; this is the SDK's fee_paid = Lightning fee + actual proof input fee)", + "estimated proof input fee (prepared): 4 sats — replaced by the actual fee inside the melt fee above, not added again; swap fee (charged at swap): 1 sats", + "actual debit: 19 sats = net + melt fee + swap fee", + "net paid to maxplayer@agi.cash: 13 sats", + "receipts discharged: 2", + ] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + assert!(!out.contains("REFUSED"), "{out}"); + assert!( + !out.contains("Re-planned"), + "reserve unchanged: no re-plan\n{out}" + ); + assert!( + !out.contains("WARNING"), + "delta equals the gross exactly; that is not an overspend:\n{out}" + ); + assert_eq!( + fake.melts, + vec!["lnbc-fake-13-2".to_owned()], + "exactly one melt" + ); + assert_eq!(fake.swaps.len(), 1, "exactly one swap"); + assert_eq!(fake.swaps[0].sent, vec![32]); + assert_eq!(fake.swaps[0].target_sats, 19); + assert_eq!(fake.swaps[0].swap_fee_sats, 1); + assert_eq!(fake.swaps[0].received, vec![16, 2, 1]); + assert_eq!(fake.swaps[0].change, vec![8, 4]); + assert!(fake.cancels.is_empty()); + assert!(fake.ceiling_refusals.is_empty(), "the bound admitted it"); + assert!(fake.pay_refusals.is_empty()); + assert_eq!(fake.proofs_spent, vec![vec![32]], "the 32 went to the swap"); + let after = fake.pool_value().expect("pool"); + assert_eq!((before, after), (32, 13), "change [8, 4] + [1]"); + let delta = before - after; + assert_eq!( + delta, + 13 + 2 + 3 + 1, + "invoice + Lightning + ACTUAL input + swap" + ); + assert!( + delta <= 19, + "the wallet lost {delta} sats against 19 accrued" + ); + assert_eq!( + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get("paid-quote-lnbc-fake-13-2") + .map(|quote| quote.state), + Some(MeltQuoteState::Paid) + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!((rows[0].gross_sats, rows[0].net_sats), (19, 13)); + assert_eq!( + rows[0].melt_fee_sats, + Some(5), + "inclusive: Lightning 2 + actual input 3" + ); + assert_eq!(rows[0].melt_fee_reserve_sats, Some(2)); + assert_eq!( + rows[0].spending_quote_id.as_deref(), + Some("paid-quote-lnbc-fake-13-2") + ); + assert_eq!(rows[0].receipts, 2); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (19, 0, 0) + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Record 45 — addendum 11 §1 (verdict 1ee5cb2 §4.2 B/N1): gross 3 (fees 2 + 1), reserve 0, + // 1000 ppk, one 32-sat proof. The probe on 3 expects popcount(4) = 1 + swap 1 = 2 sats; round + // 9's gross-probe veto `reserve + expected ≥ gross` (0 + 2 ≥ 3 is false — but with reserve 1 + // it fired, and on 2/0 it fires) sat BEFORE the exact search and refused what a smaller invoice + // pays. Now the search decides: invoice 1 ⇒ need 1 = [1], prepared 1, target 2 = [2], actual 1, + // 2 ≥ 1 + 0 + 1; worst 1 + 0 + 1 + 1 = 3 ≤ 3 ⇒ PAYS, once: swap 32 → 2 (fee 1, change + // [16, 8, 4, 1] = 29), melt [2] for invoice 1, Lightning 0, actual input 1 ⇒ `fee_paid` = 1, + // no melt change; pool 32 → 29, delta 3 = the gross exactly, never over; row Settled (3, 1). + #[test] + fn a_3_sat_gross_with_no_reserve_at_1000_ppk_the_gross_probe_would_veto_pays_invoice_1_once() { + let (store, root) = store_with_fees("gross-3-witness", &[2, 1]); + let mut fake = Fake::new(|_| 0); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let registry = quote_registry(); + fake.registry = Some(Arc::clone(®istry)); + fake.melt_results = vec![Ok((1, 0))]; + let before = fake.pool_value().expect("pool"); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + match &outcome { + RemitOutcome::Paid { + remittance_id, + net_sats, + melt_fee_sats, + } => { + assert_eq!(remittance_id, "hash-1-2"); + assert_eq!((*net_sats, *melt_fee_sats), (1, 1)); + } + other => panic!("expected Paid, got {other:?}\n{out}"), + } + assert!(!out.contains("REFUSED"), "{out}"); + assert!( + out.contains("invoice amount (maxplayer@agi.cash receives): 1 sats"), + "{out}" + ); + for needle in [ + "actual debit: 3 sats = net + melt fee + swap fee", + "net paid to maxplayer@agi.cash: 1 sats", + "receipts discharged: 2", + ] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + assert!( + !out.contains("WARNING"), + "delta equals the gross exactly; that is not an overspend:\n{out}" + ); + assert_eq!( + fake.invoices, + vec![3, 1], + "the gross probe, then the planned net" + ); + assert_eq!( + fake.melts, + vec!["lnbc-fake-1-2".to_owned()], + "exactly one melt" + ); + assert_eq!(fake.swaps.len(), 1, "exactly one swap"); + assert_eq!(fake.swaps[0].sent, vec![32]); + assert_eq!(fake.swaps[0].target_sats, 2); + assert_eq!(fake.swaps[0].swap_fee_sats, 1); + assert_eq!(fake.swaps[0].received, vec![2]); + assert_eq!(fake.swaps[0].change, vec![16, 8, 4, 1]); + assert!(fake.cancels.is_empty()); + assert!(fake.ceiling_refusals.is_empty(), "the bound admitted it"); + assert!(fake.pay_refusals.is_empty()); + let after = fake.pool_value().expect("pool"); + assert_eq!( + (before, after), + (32, 29), + "change [16, 8, 4, 1], no melt change" + ); + let delta = before - after; + assert_eq!( + delta, 3, + "invoice 1 + Lightning 0 + ACTUAL input 1 + swap 1 = 3" + ); + assert!(delta <= 3, "the wallet lost {delta} sats against 3 accrued"); + assert_eq!( + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get("paid-quote-lnbc-fake-1-2") + .map(|quote| quote.state), + Some(MeltQuoteState::Paid) + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!((rows[0].gross_sats, rows[0].net_sats), (3, 1)); + assert_eq!( + rows[0].melt_fee_sats, + Some(1), + "inclusive: Lightning 0 + actual input 1" + ); + assert_eq!(rows[0].melt_fee_reserve_sats, Some(0)); + assert_eq!(rows[0].receipts, 2); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (3, 0, 0) + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Record 46 — addendum 11 §1, second witness with a non-zero reserve: gross 8 (fees 5 + 3), + // reserve 5 at estimate and payment, 1000 ppk, one 32-sat proof. Ceiling 8 − 5 = 3; the search: + // invoice 3 ⇒ need 8 = [8], prepared 1, target 9 = [8, 1], actual 2, worst 3 + 5 + 2 + 1 = 11; + // invoice 2 ⇒ need 7 = [4, 2, 1], prepared 3, target 10 = [8, 2], actual 2, worst 10; invoice 1 + // ⇒ need 6 = [4, 2], prepared 2, target 8 = [8], actual 1, 8 ≥ 6 + 1, worst 1 + 5 + 1 + 1 = 8 ≤ + // 8 ⇒ PAYS invoice 1, once: swap 32 → 8 (fee 1, change [16, 4, 2, 1] = 23), melt [8], the mint + // keeps the whole 5-sat reserve as its Lightning fee (worst case) plus actual input 1 ⇒ + // `fee_paid` = 6, melt change 8 − 1 − 5 − 1 = 1; pool 32 → 24, delta 8 = the gross exactly. + #[test] + fn an_8_sat_gross_with_a_5_sat_reserve_at_1000_ppk_pays_invoice_1_once_within_the_gross() { + let (store, root) = store_with_fees("gross-8-witness", &[5, 3]); + let mut fake = Fake::new(|_| 5); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let registry = quote_registry(); + fake.registry = Some(Arc::clone(®istry)); + fake.melt_results = vec![Ok((1, 5))]; + let before = fake.pool_value().expect("pool"); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + match &outcome { + RemitOutcome::Paid { + remittance_id, + net_sats, + melt_fee_sats, + } => { + assert_eq!(remittance_id, "hash-1-2"); + assert_eq!((*net_sats, *melt_fee_sats), (1, 6)); + } + other => panic!("expected Paid, got {other:?}\n{out}"), + } + assert!(!out.contains("REFUSED"), "{out}"); + assert!( + out.contains("invoice amount (maxplayer@agi.cash receives): 1 sats"), + "{out}" + ); + for needle in [ + "actual debit: 8 sats = net + melt fee + swap fee", + "net paid to maxplayer@agi.cash: 1 sats", + "receipts discharged: 2", + ] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + assert!(!out.contains("WARNING"), "{out}"); + assert_eq!( + fake.invoices, + vec![8, 1], + "the gross probe, then the planned net" + ); + assert_eq!( + fake.melts, + vec!["lnbc-fake-1-2".to_owned()], + "exactly one melt" + ); + assert_eq!(fake.swaps.len(), 1, "exactly one swap"); + assert_eq!(fake.swaps[0].sent, vec![32]); + assert_eq!(fake.swaps[0].target_sats, 8); + assert_eq!(fake.swaps[0].swap_fee_sats, 1); + assert_eq!(fake.swaps[0].received, vec![8]); + assert_eq!(fake.swaps[0].change, vec![16, 4, 2, 1]); + assert!(fake.cancels.is_empty()); + assert!(fake.ceiling_refusals.is_empty(), "the bound admitted it"); + assert!(fake.pay_refusals.is_empty()); + let after = fake.pool_value().expect("pool"); + assert_eq!( + (before, after), + (32, 24), + "change [16, 4, 2, 1] + melt change [1]" + ); + let delta = before - after; + assert_eq!( + delta, + 1 + 5 + 1 + 1, + "invoice + Lightning + ACTUAL input + swap" + ); + assert!(delta <= 8, "the wallet lost {delta} sats against 8 accrued"); + assert_eq!( + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get("paid-quote-lnbc-fake-1-2") + .map(|quote| quote.state), + Some(MeltQuoteState::Paid) + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!((rows[0].gross_sats, rows[0].net_sats), (8, 1)); + assert_eq!( + rows[0].melt_fee_sats, + Some(6), + "inclusive: Lightning 5 + actual input 1" + ); + assert_eq!(rows[0].melt_fee_reserve_sats, Some(5)); + assert_eq!(rows[0].receipts, 2); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (8, 0, 0) + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Regression (ii), addendum 9 §1.4 / addendum 10 §1.1: the prepared figures FIT but the + // post-swap arithmetic does not. Same seller, mint and layout, reserve 3 at estimate AND at + // payment (so addendum 10 §1.4's re-plan has nothing to do — its schedule, the reserve + // shrinking 3 → 0, now legitimately re-plans and pays: see `a_reserve_that_shrinks_…` below), + // but the SDK's PREPARED input fee on the selection is 0 (fee-metadata drift: the selected + // proofs' keyset reports no fee; the swap lands on the active 1000-ppk keyset), so need = 15 = + // 8+4+2+1 with a prepared total of 15 + 0 + 1 = 16 ≤ 20 — the prepared total ADMITS it. But + // confirm would swap to 15 = [8, 4, 2, 1] ⇒ actual input 4 ⇒ needs 19 > 15: pinned CDK refuses + // AFTER paying the swap (`melt/saga/mod.rs:704–712`) — and on this path that would land after + // the fence, leaving a bound Spending row held. The §1.1 bound runs that arithmetic BEFORE the + // fence: refused, the prepared melt cancelled (local), no swap, no melt, wallet delta exactly 0, + // no request posted (the quote is still UNPAID), the row NOT written (stays Planned, unbound, + // receipts pinned; the next attempt's reconciliation releases it — addendum 10 §2), one REFUSED + // line naming the target, the actual fee and the estimate. Until round 8 the + // fake let the old schedule "succeed" (verdict da0ee92 §4.4). + #[test] + fn a_fee_bearing_schedule_whose_prepared_figures_fit_but_post_swap_arithmetic_does_not_is_refused_before_the_fence() + { + let (store, root) = store_with_fees("fee-bearing-post-swap", &[10, 10]); + let mut fake = Fake::new(|_| 3); + fake.prepared_input_override = Some(0); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let registry = quote_registry(); + fake.registry = Some(Arc::clone(®istry)); + fake.melt_results = vec![Ok((12, 3))]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + match &outcome { + RemitOutcome::MeltRefused { + remittance_id, + reason, + } => { + assert_eq!(remittance_id, "hash-12-2"); + assert_eq!( + reason, + "melt refused before spending: the wallet would swap to 15 sats ([8, 4, 2, 1]) for mint https://mint.example quote paid-quote-lnbc-fake-12-2 and the mint's actual proof input fee on those proofs is 4 sats (prepared estimate 0 sats at 1000 ppk), so 12 sats invoice + 3 sats fee reserve + 4 sats would need 19 sats and the SDK would refuse AFTER paying the 1 sats swap fee; the prepared melt was cancelled before any fee-bearing request" + ); + } + other => panic!("expected MeltRefused, got {other:?}\n{out}"), + } + assert!(fake.swaps.is_empty(), "no swap was posted"); + assert!(fake.melts.is_empty(), "no melt was posted"); + assert!( + fake.cancels.is_empty(), + "since addendum 10 §1.1 the wallet's own gate refuses this preparation (cancelling it inside prepare); nothing prepared reached the caller to cancel" + ); + assert!(fake.prepared.is_empty()); + assert_eq!( + fake.ceiling_refusals.len(), + 1, + "the one gate — the actual-confirmability bound — refused it at prepare" + ); + assert!( + fake.pay_refusals.is_empty(), + "the wallet never got to refuse" + ); + assert_eq!( + fake.pool_value(), + Some(32), + "wallet delta exactly 0: no swap, no melt" + ); + assert!(fake.proofs_spent.is_empty()); + assert_eq!( + fake.melt_results.len(), + 1, + "the scripted payment was never consumed" + ); + assert!(fake.admitted_seen.is_empty(), "refused before the fence"); + assert_eq!( + out.matches("REFUSED before spending").count(), + 1, + "exactly one refusal line:\n{out}" + ); + assert!( + !out.contains("Prepared melt of quote"), + "refused before the prepared line:\n{out}" + ); + assert!(!out.contains("WARNING"), "{out}"); + let quote = registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get("paid-quote-lnbc-fake-12-2") + .cloned() + .expect("the payment quote was raised"); + assert_eq!( + quote.state, + MeltQuoteState::Unpaid, + "no request reached the mint" + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_refused_before_fence_row_stays_planned(&out, &rows[0], 20, "quote-lnbc-fake-12-2"); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Failed); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (0, 20, 0), + "nothing paid; the gross is in flight on the planned row until the next attempt reconciles it (addendum 10 §2)" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Fee-exceeds refusal (addendum 8 §1.5 (ii), kept by addendum 9 §1.4 (iii)): the same seller, mint and layout (plan: invoice 12, ceiling + // 20), but the payment quote's reserve grew to 7: 12 + 7 = 19 passes the two-figure check + // (≤ 20), yet need = 19 = 16+2+1 ⇒ three output proofs ⇒ input fee 3; selection 22 ⇒ swap fee 1; + // total 19 + 3 + 1 = 23 > 20 — over, by the fees. The actual-arithmetic bound refuses BEFORE any + // swap or melt: wallet delta exactly 0, no request posted (the registry's quote is still + // UNPAID), the row NOT written (stays Planned, unbound, receipts pinned; released by the next + // attempt's reconciliation — addendum 10 §2), one REFUSED line naming the total, its parts and + // the ceiling. + #[test] + fn a_fee_bearing_total_that_exceeds_the_gross_by_the_fee_is_refused_before_any_swap_or_melt() { + let (store, root) = store_with_fees("fee-bearing-over", &[10, 10]); + let mut fake = Fake::new(|_| 3); + fake.live_reserve_for = Some(Box::new(|_| 7)); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let registry = quote_registry(); + fake.registry = Some(Arc::clone(®istry)); + fake.melt_results = vec![Ok((12, 1))]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Collect, 100); + match &outcome { + RemitOutcome::MeltRefused { + remittance_id, + reason, + } => { + assert_eq!(remittance_id, "hash-12-2"); + assert!( + reason.contains("would debit 23 sats in total (12 sats invoice + 7 sats fee reserve + 3 sats proof input fee + 1 sats swap fee) against a ceiling of 20 sats; the prepared melt was cancelled and its proofs released; nothing was posted to the mint"), + "{reason}" + ); + } + other => panic!("expected MeltRefused, got {other:?}\n{out}"), + } + assert!(fake.melts.is_empty(), "no melt"); + assert_eq!( + fake.ceiling_refusals.len(), + 1, + "refused by the total bound, once" + ); + assert!( + fake.cancels.is_empty(), + "cancelled inside prepare, not by the fence" + ); + assert!(fake.prepared.is_empty()); + assert_eq!( + fake.pool_value(), + Some(32), + "wallet delta exactly 0: no swap, no melt" + ); + assert_eq!( + fake.melt_results.len(), + 1, + "the scripted payment was never consumed" + ); + assert!(fake.admitted_seen.is_empty(), "refused before the fence"); + assert_eq!( + out.matches("REFUSED before spending").count(), + 1, + "exactly one refusal line:\n{out}" + ); + assert!( + out.contains("REFUSED before spending — melt refused before spending: mint https://mint.example quote paid-quote-lnbc-fake-12-2 would debit 23 sats in total (12 sats invoice + 7 sats fee reserve + 3 sats proof input fee + 1 sats swap fee) against a ceiling of 20 sats"), + "{out}" + ); + assert!(!out.contains("Prepared melt of quote"), "{out}"); + let quote = registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get("paid-quote-lnbc-fake-12-2") + .cloned() + .expect("the payment quote was raised"); + assert_eq!( + quote.state, + MeltQuoteState::Unpaid, + "no request reached the mint" + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_refused_before_fence_row_stays_planned(&out, &rows[0], 20, "quote-lnbc-fake-12-2"); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Failed); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (0, 20, 0), + "nothing paid; the gross is in flight on the planned row until the next attempt reconciles it (addendum 10 §2)" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum 9 §1.2, fee-aware planning: the same fee-bearing mint and layout with NO reserve + // slack (estimate reserve 2, payment reserve 2). Without the fees in the plan the invoice would + // be 18 and the payment total 18 + 2 + 2 + 1 = 23 > 20 — refused every time, forever. Round 7 + // planned 14 (20 − 2 − probe's 4): its prepared figures 14 + 2 + 1 + 1 = 18 fit, but confirm would + // swap to 17 = [16, 1] ⇒ actual input 2 ⇒ 18 > 17 — CDK refuses after the swap (verdict da0ee92 + // §4.4). Planning now searches down from 18 for the first invoice whose post-swap arithmetic + // holds: 13 (need 15 = 8+4+2+1 ⇒ prepared 4 ⇒ target 19 = [16, 2, 1] ⇒ actual 3; 19 ≥ 18; worst + // 15 + 3 + 1 = 19 ≤ 20). Re-estimate on 15: input 4 + swap 1 = 5 ⇒ the old ceiling line says 20. + // Payment: prepared 13 + 2 + 4 + 1 = 20 ≤ 20 admitted; swap to 19 (fee 1, change [8, 4]), + // actual 3, needs 18 ≤ 19, melts; the mint takes its full 2-sat reserve and returns 19 − 13 − 2 − + // 3 = 1 as change; `fee_paid` = 19 − 13 − 1 = 5 = Lightning 2 + actual input 3. Wallet 32 → 12 + + // 1 = 13: delta 19 = 13 + 2 + 3 + 1 ≤ 20, measured. + #[test] + fn fee_aware_planning_sizes_the_invoice_so_a_fee_bearing_payment_fits_without_reserve_slack() { + let (store, root) = store_with_fees("fee-aware-plan", &[10, 10]); + let mut fake = Fake::new(|_| 2); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + fake.registry = Some(quote_registry()); + fake.melt_results = vec![Ok((13, 2))]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::DryRun, 100); + assert_eq!(outcome, RemitOutcome::DryRun, "{out}"); + for needle in [ + "unremitted platform fee (gross): 20 sats", + "mint melt fee reserve (bounds the Lightning fee): 2 sats — taken out of the gross, never on top", + "invoice amount (maxplayer@agi.cash receives): 13 sats", + "leaves your wallet: at most 19 sats (≤ 20); unused reserve returns as change", + "expected proof fees (SDK estimate, bounded exactly at payment): 5 sats = estimated proof input fee 4 sats + swap fee 1 sats; actual proof input fee the SDK recomputes on the swapped proofs: 3 sats ⇒ worst case 19 sats leaves the wallet (≤ 20) = invoice 13 + reserve 2 (bounds the Lightning fee) + actual proof input fee 3 + swap fee 1; the inclusive melt fee (Lightning + actual proof input fee) is known only at payment", + "DRY RUN — nothing moved. Re-run with --confirm to pay 13 sats to maxplayer@agi.cash.", + ] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + assert_eq!( + fake.invoices, + vec![20, 13], + "probe on the gross, then the invoice planning chose — not 14" + ); + assert_eq!(fake.pool_value(), Some(32), "estimating reserves nothing"); + assert!(fake.swaps.is_empty() && fake.melts.is_empty()); + + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 101); + assert!(is_paid(&outcome), "{out}"); + assert_eq!(fake.melts, vec!["lnbc-fake-13-4".to_owned()], "one melt"); + assert_eq!(fake.swaps.len(), 1, "one swap"); + assert_eq!(fake.swaps[0].received, vec![16, 2, 1]); + assert_eq!(fake.swaps[0].change, vec![8, 4]); + assert!( + out.contains("Prepared melt of quote paid-quote-lnbc-fake-13-4: proof input fee 4 sats (estimate; actual on the swapped proofs 3 sats), swap fee 1 sats (the wallet's proofs do not fit: a pre-melt swap will be performed); total debit 19 sats (13 invoice + 2 reserve + fees) fits the ceiling of 20 sats"), + "{out}" + ); + assert_eq!( + fake.pool_value(), + Some(13), + "32 − 1 swap fee − 13 − 2 Lightning fee − 3 ACTUAL input fee" + ); + assert!( + out.contains("actual debit: 19 sats = net + melt fee + swap fee") + && out.contains("stays in your wallet (unused reserve): 1 sats"), + "{out}" + ); + assert!(!out.contains("WARNING"), "{out}"); + let lost = 32 - fake.pool_value().expect("pool"); + assert_eq!(lost, 13 + 2 + 3 + 1); + assert!(lost <= 20, "the wallet lost {lost} sats against 20 accrued"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!((rows[0].gross_sats, rows[0].net_sats), (20, 13)); + assert_eq!( + rows[0].melt_fee_sats, + Some(5), + "`fee_paid`, inclusive: Lightning 2 + actual input 3" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // The planning refusal for fees that can never fit (addendum 8 §1.3, re-sited by addendum 11 + // §1): a 1000-ppk mint against a 3-sat gross with a 1-sat reserve. No probe-side fee veto any + // more — the exact search runs every candidate: invoice 2 ⇒ need 3, prepared 2, target 5 = + // [4, 1], actual 2, worst 2 + 1 + 2 + 1 = 6 > 3; invoice 1 ⇒ need 2, prepared 1, target 3 = + // [2, 1], actual 2, worst 1 + 1 + 2 + 1 = 5 > 3. NO candidate fits ⇒ FeesDoNotFit with the + // probe's expected 2 (popcount(4) = 1 + swap 1) on the "no invoice fits" line, no invoice for + // any net, nothing journaled, the balance intact. + #[test] + fn fees_that_can_never_fit_are_refused_at_planning_not_at_payment() { + let (store, root) = store_with_fees("fee-never-fits", &[3]); + let mut fake = Fake::new(|_| 1); + fake.input_fee_ppk = 1000; + fake.proofs = Some(fake_proofs(&[32])); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Command, 100); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::FeesDoNotFit { + gross: 3, + reserve: 1, + expected_fees: 2, + }), + "{out}" + ); + assert!( + out.contains("REFUSED — no invoice fits: at mint https://mint.example's 1000 ppk proof fee, no amount up to 2 sats (3 sats less the 1 sats melt fee reserve) can be paid for at most 3 sats once the SDK recomputes its proof input fee on the swapped proofs plus 2 sats of expected proof fees. The balance accumulates. Nothing moved."), + "{out}" + ); + assert!( + !out.contains("which leaves nothing for the destination"), + "the reserve-only veto did not fire (1 < 3):\n{out}" + ); + assert_eq!(fake.invoices, vec![3], "only the probe"); + assert!(fake.quotes.is_empty() && fake.melts.is_empty()); + assert_eq!(fake.pool_value(), Some(32)); + assert!(store.remittances().expect("rows").is_empty()); + assert_eq!( + Refusal::FeesDoNotFit { + gross: 3, + reserve: 1, + expected_fees: 2 + } + .to_string(), + "the mint's melt fee reserve (1 sats) plus the expected proof fees (2 sats) do not fit inside the 3 sats accrued" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // ---- addendum 3 §1: the money hold, at the moment of spending (gate 2f) -------------------- + + // Gate 2f: gross 15, the ESTIMATE quotes a 2-sat reserve (invoice 13, ceiling 15 holds), but the + // quote the mint raises FOR THE PAYMENT carries a 4-sat reserve: 13 + 4 = 17 > 15. The melt is + // REFUSED before any proof is spent — zero debits — the attempt is journaled FAILED naming the + // row, the row stays planned with its receipts pinned (the next attempt's reconciliation + // releases it — addendum 10 §2), and nothing left the wallet. Then the same seller + // with a payment-time reserve that FITS pays exactly once. + #[test] + fn a_reserve_that_grows_between_estimate_and_payment_is_refused_before_spending() { + let (store, root) = store_with_fees("ceiling-refused", &[10, 5]); + let mut fake = Fake::new(|_| 2); + fake.live_reserve_for = Some(Box::new(|_| 4)); + fake.melt_results = vec![Ok((13, 1))]; + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Collect, 100); + match &outcome { + RemitOutcome::MeltRefused { + remittance_id, + reason, + } => { + assert_eq!(remittance_id, "hash-13-2"); + assert!( + reason.contains("would debit 17 sats (13 sats invoice + 4 sats fee reserve; planned invoice 13 sats) against a ceiling of 15 sats; nothing left the wallet"), + "{reason}" + ); + } + other => panic!("expected MeltRefused, got {other:?}\n{out}"), + } + assert!( + fake.melts.is_empty(), + "ZERO debits: the refusal happens before the proofs are touched" + ); + // Addendum 5 §1 rule 1: the payment quote was raised and checked BEFORE the fence — the row + // was never admitted; it is left planned, unbound and pinned (addendum 10 §2), not released + // here. + assert_eq!(fake.quotes, vec!["lnbc-fake-13-2".to_owned()]); + assert!(fake.admitted_seen.is_empty(), "refused before the fence"); + assert!( + fake.ceiling_refusals.is_empty(), + "the wallet's own re-check never ran: nothing reached the payment" + ); + assert_eq!( + fake.melt_results.len(), + 1, + "the scripted payment was never consumed" + ); + assert!( + out.contains("REFUSED before spending — melt refused before spending"), + "{out}" + ); + // Journaled as a FAILED attempt naming the row; the row itself is NOT written (addendum 10 + // §2): it stays planned with its receipts pinned, for the next attempt to reconcile. + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Failed); + assert_eq!(attempts[0].remittance_id.as_deref(), Some("hash-13-2")); + assert!( + attempts[0] + .detail + .starts_with("refused before spending: melt refused before spending"), + "{}", + attempts[0].detail + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_refused_before_fence_row_stays_planned(&out, &rows[0], 15, "quote-lnbc-fake-13-2"); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (0, 15, 0), + "nothing paid; the gross is in flight on the planned row until the next attempt reconciles it" + ); + // It is a failure for the pacing: the backoff escalates. + let report = RemitReport { + outcome: Ok(outcome), + lines: Vec::new(), + }; + assert!(report.is_failure()); + assert!( + report + .summary() + .starts_with("melt REFUSED before spending (") + ); + + // The payment-time reserve fits (2: 13 + 2 = 15 ≤ 15): pays exactly once, and the + // settlement records the PAYING quote's reserve beside the actual fee. + fake.live_reserve_for = Some(Box::new(|_| 2)); + let (outcome, out) = run_remit(&store, &mut fake, RemitTrigger::Retry, 101); + assert!(is_paid(&outcome), "{out}"); + assert_eq!(fake.melts.len(), 1, "exactly one debit, ever"); + // Addendum 10 §2: the next attempt FIRST reconciles the refused row — released as this + // process's own earlier attempt (planned → failed, receipts back) — THEN plans a new row + // and pays it: [Failed, Settled], the receipts discharged by the second. + let release_at = out + .find("the row is this process's own earlier attempt, which is over") + .unwrap_or_else(|| panic!("the refused row is reconciled first:\n{out}")); + let plan_at = out + .find("Journaled remittance hash-13-4") + .unwrap_or_else(|| panic!("a new row is planned:\n{out}")); + assert!(release_at < plan_at, "release before the new plan:\n{out}"); + assert!( + out.contains("melt fee taken by the mint: 1 sats (quote paid-quote-lnbc-fake-13-4 reserved 2 sats; ceiling 15 sats held at the moment of spending; this is the SDK's fee_paid = Lightning fee + actual proof input fee)"), + "{out}" + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].remittance_id, "hash-13-2"); + assert_eq!( + rows[0].state, + RemittanceState::Failed, + "released by reconciliation" + ); + assert_eq!(rows[0].receipts, 0); + assert_eq!(rows[1].remittance_id, "hash-13-4"); + assert_eq!(rows[1].receipts, 2); + assert_eq!(rows[1].state, RemittanceState::Settled); + assert_eq!(rows[1].melt_fee_sats, Some(1)); + assert_eq!(rows[1].melt_fee_reserve_sats, Some(2)); + assert_eq!( + rows[1].settled_by, + Some(crate::seller_node::store::SettledBy::Melt) + ); + assert_eq!( + rows[1].melt_quote_id, + Some("paid-quote-lnbc-fake-13-4".to_owned()), + "the quote that actually paid is the one recorded" + ); + assert_eq!(store.accrued_fees().expect("read").remitted_fee_sats, 15); + + // Exactly at the ceiling is admitted; one sat over is not (the pure rule the melt applies). + let ceiling = MeltCeiling { + max_debit_sats: 15, + invoice_sats: 13, + planned_quote_id: None, + }; + assert!(ceiling.admits(13, 2)); + assert!(!ceiling.admits(13, 3)); + assert!( + !ceiling.admits(12, 0), + "a quote for a different amount than planned is refused too" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // ---- addendum 3 §2: ownership — never release a live payer's intent (gate 2g) --------------- + + /// A quote status whose expiry is far in the future: live until a test says otherwise. + fn status(state: MeltQuoteState, quote_id: &str) -> MeltQuoteStatus { + MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: quote_id.to_owned(), + state, + amount_sats: 13, + fee_reserve_sats: 2, + expiry_unix: u64::MAX, + } + } + + fn planned_row(owner: Option<&str>, lease_until: Option) -> FeeRemittance { + FeeRemittance { + remittance_id: "x".to_owned(), + gross_sats: 15, + melt_fee_sats: None, + melt_fee_reserve_sats: Some(2), + net_sats: 13, + destination: PLATFORM_FEE_ADDRESS.to_owned(), + melt_quote_id: Some("q".to_owned()), + payment_hash: "x".to_owned(), + bolt11: "ln-x".to_owned(), + state: RemittanceState::Planned, + created_at_unix: 100, + settled_at_unix: None, + settled_by: None, + owner: owner.map(str::to_owned), + lease_until_unix: lease_until, + spending_since_unix: None, + spending_quote_id: None, + receipts: 2, + } + } + + /// A row whose owner's compare-and-set admitted the melt at `since` and BOUND the payment + /// quote `q-bound` to it (addendum 4 §1.2, addendum 5 §1). + fn spending_row(owner: &str, lease_until: i64, since: i64) -> FeeRemittance { + FeeRemittance { + state: RemittanceState::Spending, + spending_since_unix: Some(since), + spending_quote_id: Some("q-bound".to_owned()), + ..planned_row(Some(owner), Some(lease_until)) + } + } + + /// The conditional transition a decision carries, or `None` when it is not a release. + fn release_on(decision: &Reconcile) -> Option { + match decision { + Reconcile::Release { on, .. } => Some(on.clone()), + _ => None, + } + } + + // The release rule as a table: PAID settles; PENDING/UNKNOWN hold; FAILED releases whoever owns + // the row; UNPAID / no quote release only for the row's own process or after the lease — and + // HOLD while another process's lease stands. A pre-v11 row (no owner, no lease) reads as expired. + #[test] + fn reconciliation_releases_only_terminal_quotes_own_rows_or_expired_leases() { + let theirs = planned_row(Some("proc-b"), Some(400)); + let mine = planned_row(Some("proc-a"), Some(400)); + let legacy = planned_row(None, None); + let paid = status(MeltQuoteState::Paid, "q-paid"); + let pending = status(MeltQuoteState::Pending, "q-pending"); + let unknown = status(MeltQuoteState::Unknown, "q-unknown"); + let failed = status(MeltQuoteState::Failed, "q-failed"); + let unpaid = status(MeltQuoteState::Unpaid, "q-unpaid"); + + assert_eq!( + reconcile_decision(&theirs, Some(&paid), "proc-a", 200), + Reconcile::Settle + ); + assert_eq!( + reconcile_decision(&theirs, Some(&pending), "proc-a", 200), + Reconcile::Hold(Refusal::Settling { + remittance_id: "x".to_owned() + }) + ); + assert_eq!( + reconcile_decision(&mine, Some(&unknown), "proc-a", 200), + Reconcile::Hold(Refusal::Settling { + remittance_id: "x".to_owned() + }), + "unknown is not terminal, even on our own row" + ); + assert!(matches!( + reconcile_decision(&theirs, Some(&failed), "proc-a", 200), + Reconcile::Release { reason, .. } + if reason.contains("FAILED at the mint, and the row was never admitted to spend") + )); + // UNPAID / no quote, another process's live lease: HOLD — "not yet" is not "abandoned". + assert_eq!( + reconcile_decision(&theirs, Some(&unpaid), "proc-a", 200), + Reconcile::Hold(Refusal::HeldByOwner { + remittance_id: "x".to_owned(), + owner: "proc-b".to_owned(), + lease_until_unix: 400, + }) + ); + assert_eq!( + reconcile_decision(&theirs, None, "proc-a", 399), + Reconcile::Hold(Refusal::HeldByOwner { + remittance_id: "x".to_owned(), + owner: "proc-b".to_owned(), + lease_until_unix: 400, + }), + "one second before the lease ends it still holds" + ); + // …and RELEASE once the lease has run out (the owner is provably gone or not spending) — + // by the LEASE transition, which carries the clock it was decided on and applies to a + // planned row only (addendum 5 §1, rule 2). + let on_lease = reconcile_decision(&theirs, Some(&unpaid), "proc-a", 400); + assert!(matches!( + &on_lease, + Reconcile::Release { reason, .. } if reason.contains("lease ran out at unix 400") + )); + assert_eq!( + release_on(&on_lease), + Some(ReleaseOn::LeaseExpired { now_unix: 400 }) + ); + assert_eq!( + release_on(&reconcile_decision(&theirs, None, "proc-a", 401)), + Some(ReleaseOn::LeaseExpired { now_unix: 401 }), + "never raised a melt quote — released on the lease" + ); + // Our own row: release on UNPAID / none at any time — our earlier attempt is over — by the + // OWN-PLANNED transition, naming us. + let own = reconcile_decision(&mine, Some(&unpaid), "proc-a", 101); + assert!(matches!( + &own, + Reconcile::Release { reason, .. } if reason.contains("this process's own earlier attempt") + )); + assert_eq!( + release_on(&own), + Some(ReleaseOn::OwnPlanned { + owner: "proc-a".to_owned() + }) + ); + assert_eq!( + release_on(&reconcile_decision(&mine, None, "proc-a", 101)), + Some(ReleaseOn::OwnPlanned { + owner: "proc-a".to_owned() + }) + ); + // Terminal quotes on a PLANNED row release by the planned-terminal transition. + assert_eq!( + release_on(&reconcile_decision(&theirs, Some(&failed), "proc-a", 200)), + Some(ReleaseOn::TerminalQuotePlanned) + ); + // A pre-v11 row: nobody's, lease expired ⇒ releasable on UNPAID / none, settled on PAID. + assert!(matches!( + reconcile_decision(&legacy, Some(&unpaid), "proc-a", 101), + Reconcile::Release { reason, .. } if reason.contains("owner none recorded") + )); + assert_eq!( + reconcile_decision(&legacy, Some(&paid), "proc-a", 101), + Reconcile::Settle + ); + assert!( + !Refusal::HeldByOwner { + remittance_id: "x".to_owned(), + owner: "proc-b".to_owned(), + lease_until_unix: 400, + } + .is_threshold() + ); + // An UNPAID quote whose expiry is behind the clock is terminal for a PLANNED row too. + let mut unpaid_expired = status(MeltQuoteState::Unpaid, "q-expired"); + unpaid_expired.expiry_unix = 150; + assert!(matches!( + reconcile_decision(&theirs, Some(&unpaid_expired), "proc-a", 151), + Reconcile::Release { reason, .. } if reason.contains("the quote expired at unix 150") + )); + assert_eq!( + reconcile_decision(&theirs, Some(&unpaid_expired), "proc-a", 150), + Reconcile::Hold(Refusal::HeldByOwner { + remittance_id: "x".to_owned(), + owner: "proc-b".to_owned(), + lease_until_unix: 400, + }), + "at the expiry second the quote is still live" + ); + } + + // The SPENDING row's rule as a pure table (kept as an extra beside the full-path gate 2g (d) + // below — addendum 5 §2, addendum 6 §1.2): the status is the BOUND quote's. PAID settles. + // Everything else HOLDS — FAILED, UNPAID live, UNPAID expired by a second or by ten thousand, + // no quote at all — whoever owns the row and however long ago its lease ran out; PENDING / + // UNKNOWN hold as for any row. No clock appears in the bound-spending rule: the mint pays an + // UNPAID or FAILED quote regardless of its expiry, so no observation proves the bound quote + // cannot still debit. Only a spending row admitted before quotes were bound keeps the v12 + // release (unbound-spending transition), out of this round's scope. + #[test] + fn a_spending_row_is_never_released_by_reconciliation_only_settled() { + let theirs = spending_row("proc-b", 400, 150); + let mine = spending_row("proc-a", 400, 150); + let paid = status(MeltQuoteState::Paid, "q-bound"); + let pending = status(MeltQuoteState::Pending, "q-bound"); + let unknown = status(MeltQuoteState::Unknown, "q-bound"); + let failed = status(MeltQuoteState::Failed, "q-bound"); + let unpaid_live = status(MeltQuoteState::Unpaid, "q-bound"); + let mut unpaid_expired = status(MeltQuoteState::Unpaid, "q-bound"); + unpaid_expired.expiry_unix = 900; + + assert_eq!( + reconcile_decision(&theirs, Some(&paid), "proc-a", 10_000), + Reconcile::Settle + ); + // HOLD on everything but PAID — theirs AND ours, live or expired, FAILED or absent. + let held = |row: &FeeRemittance, owner: &str, seen: Option<&MeltQuoteStatus>| { + Reconcile::Hold(Refusal::SpendingHeld { + remittance_id: row.remittance_id.clone(), + owner: owner.to_owned(), + spending_since_unix: 150, + quote_id: row.spending_quote_id.clone(), + observed: match seen { + None => "this wallet holds no such melt quote".to_owned(), + Some(seen) => format!( + "mint https://mint.example reports melt quote {} {} (expiry unix {})", + seen.quote_id, seen.state, seen.expiry_unix + ), + }, + held_sats: 15, + }) + }; + assert_eq!( + reconcile_decision(&theirs, Some(&unpaid_live), "proc-a", 10_000), + held(&theirs, "proc-b", Some(&unpaid_live)) + ); + assert_eq!( + reconcile_decision(&mine, Some(&unpaid_live), "proc-a", 10_000), + held(&mine, "proc-a", Some(&unpaid_live)), + "our own spending row: the melt that errored may have reached the mint" + ); + for now in [900, 960, 961, 10_000, i64::MAX] { + assert_eq!( + reconcile_decision(&mine, Some(&unpaid_expired), "proc-a", now), + held(&mine, "proc-a", Some(&unpaid_expired)), + "UNPAID past expiry (900) is held at {now}: no clock releases a bound spending row" + ); + assert_eq!( + reconcile_decision(&theirs, Some(&failed), "proc-a", now), + held(&theirs, "proc-b", Some(&failed)), + "FAILED is held at {now}: the mint pays a FAILED quote, so it is not cancellation" + ); + } + assert_eq!( + reconcile_decision(&theirs, None, "proc-a", 10_000), + held(&theirs, "proc-b", None), + "no quote is not the mint saying terminal" + ); + assert!( + release_on(&reconcile_decision(&mine, Some(&failed), "proc-a", 10_000)).is_none() + && release_on(&reconcile_decision( + &theirs, + Some(&unpaid_expired), + "proc-a", + i64::MAX + )) + .is_none(), + "no decision on a bound spending row carries a ReleaseOn" + ); + // PENDING / UNKNOWN on a bound spending row: the same HELD refusal as every other non-PAID + // answer, never the planned row's "settling" (addendum 7 §2). + assert_eq!( + reconcile_decision(&theirs, Some(&pending), "proc-a", 10_000), + held(&theirs, "proc-b", Some(&pending)), + "PENDING holds a bound spending row as HELD" + ); + assert_eq!( + reconcile_decision(&mine, Some(&unknown), "proc-a", 10_000), + held(&mine, "proc-a", Some(&unknown)), + "UNKNOWN holds a bound spending row as HELD, ours included" + ); + // A spending row admitted before v13 (no bound quote): the status is the invoice's; FAILED + // or plain expiry releases it by the unbound-spending transition, as v12 did. + let unbound = FeeRemittance { + spending_quote_id: None, + ..spending_row("proc-b", 400, 150) + }; + assert_eq!( + release_on(&reconcile_decision(&unbound, Some(&failed), "proc-a", 200)), + Some(ReleaseOn::TerminalUnboundSpending) + ); + assert_eq!( + release_on(&reconcile_decision( + &unbound, + Some(&unpaid_expired), + "proc-a", + 901 + )), + Some(ReleaseOn::TerminalUnboundSpending) + ); + assert_eq!( + reconcile_decision(&unbound, Some(&unpaid_live), "proc-a", 10_000), + held(&unbound, "proc-b", Some(&unpaid_live)) + ); + assert!( + !Refusal::SpendingHeld { + remittance_id: "x".to_owned(), + owner: "proc-b".to_owned(), + spending_since_unix: 150, + quote_id: Some("q-bound".to_owned()), + observed: String::new(), + held_sats: 15, + } + .is_threshold(), + "a held spending row is a refusal an operator should see, and a failure for pacing" + ); + } + + /// The paused side's (outcome, output) and every `meanwhile` run's (outcome, output). + type PausedRun = ( + (Result, String), + Vec<(RemitOutcome, String)>, + ); + + /// Where the paused side stops (addendum 4 §1, addendum 5 §2, addendum 6 §2.1 tests): after + /// its plan is journaled and BEFORE its payment quote; after its payment quote passed the + /// ceiling and BEFORE the fence (the row is still `planned`, the quote exists at the mint); + /// after the fence admitted it (the row is `spending`, bound to that quote) and BEFORE the + /// melt; or INSIDE the melt, after the wallet's last local check and BEFORE the request reaches + /// the mint. + #[derive(Clone, Copy)] + enum PauseAt { + /// After the plan is journaled (`Fake::plan_gate`). + Plan, + /// After the payment quote passed the ceiling, before the fence (`Fake::quote_gate`). + Quote, + /// After the fence admitted the melt and bound the quote, before paying + /// (`Fake::admit_gate`). + Admit, + /// Inside the payment: after the ceiling, the proof selection and `prepare_melt`'s expiry + /// check, before the mint sees the request (`Fake::melt_gate`) — the verdict's B3 pause. + Melt, + } + + /// Two processes against one store: `first` is paused at `gate` (at `pause`), `second` runs + /// whatever the test scripts meanwhile — including moving the clock `first` will read FRESH at + /// its fence when it resumes (`first.clock`, cloned by the test before `first` is moved in). + /// Returns each side's outcome and output. + fn run_paused( + db: &PathBuf, + mut first: Fake, + first_now: i64, + pause: PauseAt, + gate: Arc, + melts: Arc, + meanwhile: impl FnOnce(&SellerStore) -> Vec<(RemitOutcome, String)>, + ) -> PausedRun { + match pause { + PauseAt::Plan => first.plan_gate = Some(Arc::clone(&gate)), + PauseAt::Quote => first.quote_gate = Some(Arc::clone(&gate)), + PauseAt::Admit => first.admit_gate = Some(Arc::clone(&gate)), + PauseAt::Melt => first.melt_gate = Some(Arc::clone(&gate)), + } + first.melt_counter = Some(Arc::clone(&melts)); + first.set_clock(first_now); + let db_a = db.clone(); + let a = std::thread::spawn(move || { + let store = SellerStore::open(&db_a).expect("open A"); + let mut out = Vec::new(); + let outcome = remit( + &store, + &mut first, + RemitTrigger::Collect, + first_now, + &mut out, + ); + (outcome, String::from_utf8_lossy(&out).into_owned()) + }); + gate.wait_arrived(Duration::from_secs(10)); + let store_b = SellerStore::open(db).expect("open B"); + let b = meanwhile(&store_b); + gate.release(); + let a = a.join().expect("A's thread"); + (a, b) + } + + // Gate 2g (a), the live owner: process A journals its plan (invoice X) and PAUSES before + // spending. Process B — another owner, another connection, distinct invoices, reading the SAME + // fake mint through the shared quote registry (addendum 5 §2, addendum 6 §2.2: no scripted + // status) — runs reconciliation and then `--confirm`: it asks the mint about X's invoice, finds + // the estimate quote A raised UNPAID, sees X planned by a LIVE owner, and HOLDS. It plans + // nothing, pays nothing. A resumes, passes the pre-spend gate, pays X once. Exactly one debit + // (melts counted, not settlement reports); one settled row; B's refusals journaled. + #[test] + fn a_second_process_cannot_release_a_live_owners_planned_row_and_exactly_one_debit_happens() { + let (store, root) = store_with_fees("live-owner", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let registry = quote_registry(); + let (a_result, b_results) = run_paused( + &db, + first_process(®istry), + 100, + PauseAt::Plan, + super::test_support::Gate::new(), + Arc::clone(&melts), + |store_b| { + { + // What the mint holds while A is paused after its plan: A's two estimate + // quotes (probe on the gross, then the net invoice), both UNPAID; no payment + // quote yet. + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!(quotes[X_ESTIMATE_QUOTE].state, MeltQuoteState::Unpaid); + assert!(!quotes.contains_key(X_PAYMENT_QUOTE)); + } + let mut results = Vec::new(); + for (trigger, now) in [(RemitTrigger::DryRun, 110), (RemitTrigger::Command, 111)] { + let mut b = second_process(®istry, &melts); + let (outcome, out) = run_remit(store_b, &mut b, trigger, now); + assert_eq!( + b.status_calls, + vec![X_BOLT11.to_owned()], + "B asks the shared mint about X's invoice: {out}" + ); + assert!( + b.quote_status_calls.is_empty(), + "a planned row has no bound quote to ask about: {out}" + ); + assert!(b.melts.is_empty(), "B must not pay: {out}"); + assert!( + b.invoices.is_empty() && b.quotes.is_empty(), + "B must not even plan on top of a held row: {out}" + ); + assert_eq!(ledger(store_b), (0, 15, 0), "receipts pinned to X"); + results.push((outcome, out)); + } + results + }, + ); + for (outcome, out) in &b_results { + assert_eq!( + outcome, + &RemitOutcome::Refused(Refusal::HeldByOwner { + remittance_id: "hash-13-2-a".to_owned(), + owner: "proc-a".to_owned(), + lease_until_unix: 400, + }), + "{out}" + ); + assert!( + out.contains("is planned by another live process (proc-a, lease until unix 400) and its quote is not terminal; not releasing a live payer's intent. REFUSED"), + "{out}" + ); + } + let (a_outcome, a_out) = a_result; + assert!( + matches!(a_outcome, Ok(RemitOutcome::Paid { .. })), + "A pays once it resumes: {a_outcome:?}\n{a_out}" + ); + assert_eq!(melts.load(Ordering::SeqCst), 1, "exactly one actual debit"); + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1, "one row, A's: {rows:?}"); + assert_eq!(rows[0].remittance_id, "hash-13-2-a"); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!(rows[0].owner.as_deref(), Some("proc-a")); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (15, 0, 0) + ); + // B's --confirm refusal is journaled (the dry run is not an attempt). + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2, "{attempts:?}"); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Paid); + assert_eq!(attempts[1].trigger, RemitAttemptTrigger::Command); + assert_eq!(attempts[1].outcome, RemitAttemptOutcome::Refused); + assert_eq!(attempts[1].remittance_id.as_deref(), Some("hash-13-2-a")); + let _ = std::fs::remove_dir_all(&root); + } + + // Gate 2g (b), addendum 4 §1 — the dangerous ordering the verdict traced: A journals X, its + // fence ADMITS the melt (X is spending) and A pauses BEFORE the spend. The clock then moves past + // A's lease (100 + 300 = 400 → 400 and beyond) while A is paused. B — another owner, another + // connection, distinct invoices — runs `--dry-run` reconciliation and then `--confirm`, with the + // mint saying UNPAID: B must HOLD on X (a spending row is never released on time) and therefore + // NOT plan or pay Y. A resumes and pays X exactly once. One melt, one settled row. + #[test] + fn a_spending_row_is_not_released_when_its_lease_expires_and_its_owner_pays_exactly_once() { + let (store, root) = store_with_fees("spending-held", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let mut a = Fake::new(|_| 2); + a.owner = "proc-a".to_owned(); + a.invoice_tag = "-a".to_owned(); + a.melt_results = vec![Ok((13, 1))]; + let clock = Arc::clone(&a.clock); + let (a_result, b_results) = run_paused( + &db, + a, + 100, + PauseAt::Admit, + super::test_support::Gate::new(), + Arc::clone(&melts), + |store_b| { + // A is paused after admission: X is SPENDING in the store, stamped 100. + let x = store_b + .in_flight_remittance() + .expect("query") + .expect("A's row"); + assert_eq!(x.state, RemittanceState::Spending); + assert_eq!(x.spending_since_unix, Some(100)); + assert_eq!(x.lease_until_unix, Some(400)); + // The clock A will read when it resumes moves PAST its lease. + clock.store(450, Ordering::SeqCst); + let mut results = Vec::new(); + for (trigger, now) in [(RemitTrigger::DryRun, 450), (RemitTrigger::Command, 451)] { + let mut b = Fake::new(|_| 2); + b.owner = "proc-b".to_owned(); + b.invoice_tag = "-b".to_owned(); + b.melt_results = vec![Ok((13, 1))]; + b.melt_counter = Some(Arc::clone(&melts)); + b.status = Ok(Some(status( + MeltQuoteState::Unpaid, + "quote-lnbc-fake-13-2-a", + ))); + let (outcome, out) = run_remit(store_b, &mut b, trigger, now); + assert!(b.melts.is_empty(), "B must not pay: {out}"); + assert!( + b.invoices.is_empty(), + "B must not even plan on top of a spending row: {out}" + ); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: "hash-13-2-a".to_owned(), + owner: "proc-a".to_owned(), + spending_since_unix: 100, + quote_id: Some(X_PAYMENT_QUOTE.to_owned()), + observed: format!( + "mint https://mint.example reports melt quote quote-lnbc-fake-13-2-a UNPAID (expiry unix {})", + u64::MAX + ), + held_sats: 15, + }), + "{out}" + ); + assert!( + out.contains("HELD: remittance hash-13-2-a is SPENDING (admitted by proc-a at unix 100), bound to melt quote paid-quote-lnbc-fake-13-2-a; mint https://mint.example reports melt quote quote-lnbc-fake-13-2-a UNPAID"), + "{out}" + ); + assert!( + out.contains("15 sats of receipts stay pinned to it — a spending row is released by nobody and on no clock"), + "{out}" + ); + results.push((outcome, out)); + } + assert_eq!( + store_b + .in_flight_remittance() + .expect("query") + .expect("still A's row") + .state, + RemittanceState::Spending, + "B changed nothing" + ); + results + }, + ); + let (a_outcome, a_out) = a_result; + assert!( + matches!(a_outcome, Ok(RemitOutcome::Paid { .. })), + "A pays X once it resumes — it was admitted before the clock moved: {a_outcome:?}\n{a_out}" + ); + assert_eq!(melts.load(Ordering::SeqCst), 1, "exactly one actual debit"); + assert_eq!(b_results.len(), 2); + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1, "one row, A's: {rows:?}"); + assert_eq!(rows[0].remittance_id, "hash-13-2-a"); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!(rows[0].spending_since_unix, Some(100)); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (15, 0, 0) + ); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!( + attempts.len(), + 2, + "A's payment and B's refused --confirm: {attempts:?}" + ); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Paid); + assert_eq!(attempts[1].trigger, RemitAttemptTrigger::Command); + assert_eq!(attempts[1].outcome, RemitAttemptOutcome::Refused); + assert_eq!(attempts[1].remittance_id.as_deref(), Some("hash-13-2-a")); + let _ = std::fs::remove_dir_all(&root); + } + + // Gate 2g (c), addendum 4 §1 — the gone owner: A journals X and pauses BEFORE its fence; the + // clock moves past A's lease while it is paused. B runs `--dry-run` reconciliation (which + // releases X: planned, UNPAID, lease run out) and then `--confirm`, planning a DISTINCT invoice + // Y and paying it. A resumes, reads the clock FRESH at its fence, and its compare-and-set + // changes zero rows (X is no longer planned): A REFUSES without touching the wallet. Exactly + // one debit — B's. + #[test] + fn an_owner_that_outlives_its_lease_is_released_and_its_fence_then_changes_zero_rows() { + let (store, root) = store_with_fees("expired-owner", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let mut a = Fake::new(|_| 2); + a.owner = "proc-a".to_owned(); + a.invoice_tag = "-a".to_owned(); + a.melt_results = vec![Ok((13, 1))]; + let clock = Arc::clone(&a.clock); + let (a_result, b_results) = run_paused( + &db, + a, + 100, + PauseAt::Plan, + super::test_support::Gate::new(), + Arc::clone(&melts), + |store_b| { + assert_eq!( + store_b + .in_flight_remittance() + .expect("query") + .expect("A's row") + .state, + RemittanceState::Planned, + "A is paused BEFORE its fence" + ); + // 100 + REMIT_LEASE (300) = 400: the lease has run out — for B's reconciliation and + // for the clock A reads fresh when it resumes. + clock.store(400, Ordering::SeqCst); + let mut results = Vec::new(); + // B's dry run reconciles: X released. B's confirm then plans and pays Y. + let mut b = Fake::new(|_| 2); + b.owner = "proc-b".to_owned(); + b.invoice_tag = "-b".to_owned(); + b.status = Ok(Some(status( + MeltQuoteState::Unpaid, + "quote-lnbc-fake-13-2-a", + ))); + let (outcome, out) = run_remit(store_b, &mut b, RemitTrigger::DryRun, 400); + assert_eq!(outcome, RemitOutcome::DryRun, "{out}"); + assert!( + out.contains("its owner's lease ran out at unix 400 (owner proc-a); released 15 sats back to unremitted"), + "{out}" + ); + assert!(b.melts.is_empty()); + results.push((outcome, out)); + let mut b = Fake::new(|_| 2); + b.owner = "proc-b".to_owned(); + b.invoice_tag = "-b".to_owned(); + b.melt_results = vec![Ok((13, 1))]; + b.melt_counter = Some(Arc::clone(&melts)); + b.status = Ok(None); + let (outcome, out) = run_remit(store_b, &mut b, RemitTrigger::Command, 401); + assert!(is_paid(&outcome), "B pays Y once X is released: {out}"); + assert_eq!(b.melts, vec!["lnbc-fake-13-2-b".to_owned()]); + results.push((outcome, out)); + results + }, + ); + let (a_outcome, a_out) = a_result; + match a_outcome { + Ok(RemitOutcome::Refused(Refusal::OwnershipLost { + remittance_id, + reason, + })) => { + assert_eq!(remittance_id, "hash-13-2-a"); + assert!( + reason.contains( + "the row is no longer planned (now failed): another process reconciled it" + ), + "{reason}" + ); + } + other => panic!("A must refuse at the fence, got {other:?}\n{a_out}"), + } + assert!( + a_out.contains("REFUSED before spending — the row is no longer planned (now failed): another process reconciled it (checked at unix 400)"), + "{a_out}" + ); + assert_eq!( + melts.load(Ordering::SeqCst), + 1, + "exactly one actual debit — B's" + ); + assert_eq!(b_results.len(), 2); + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!( + rows.iter() + .map(|r| (r.remittance_id.as_str(), r.state, r.spending_since_unix)) + .collect::>(), + vec![ + ("hash-13-2-a", RemittanceState::Failed, None), + ("hash-13-2-b", RemittanceState::Settled, Some(401)) + ], + "X was never admitted; Y was admitted at B's clock" + ); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (15, 0, 0) + ); + // A's refusal at the fence is journaled as a refused attempt naming X. + let attempts = store.recent_remit_attempts(10).expect("attempts"); + let a_attempt = attempts + .iter() + .find(|a| a.trigger == RemitAttemptTrigger::Collect) + .expect("A's attempt"); + assert_eq!(a_attempt.outcome, RemitAttemptOutcome::Refused); + assert_eq!(a_attempt.remittance_id.as_deref(), Some("hash-13-2-a")); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum 4 §1.1, the time condition alone: A journals X at 100 (lease until 400) and pauses + // before its fence; NOBODY else runs, but the clock moves to 340 — exactly SPEND_MARGIN left. + // A resumes, reads the clock fresh, and its compare-and-set changes zero rows (`400 > 340 + 60` + // is false): A refuses, spends nothing, and — the row being still planned and its own — releases + // it for the next attempt. The stale entry time (100) is not what the fence compares. + #[test] + fn an_owner_whose_lease_ran_down_while_it_paused_is_refused_by_its_own_fence() { + let (store, root) = store_with_fees("lease-margin", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let mut a = Fake::new(|_| 2); + a.owner = "proc-a".to_owned(); + a.melt_results = vec![Ok((13, 1))]; + let clock = Arc::clone(&a.clock); + let (a_result, _) = run_paused( + &db, + a, + 100, + PauseAt::Plan, + super::test_support::Gate::new(), + Arc::clone(&melts), + |_| { + clock.store(340, Ordering::SeqCst); + Vec::new() + }, + ); + let (a_outcome, a_out) = a_result; + match a_outcome { + Ok(RemitOutcome::Refused(Refusal::OwnershipLost { + remittance_id, + reason, + })) => { + assert_eq!(remittance_id, "hash-13-2"); + assert_eq!( + reason, + OwnershipLost::LeaseTooShort { + lease_until_unix: Some(400), + now_unix: 340, + margin_secs: 60, + } + .to_string() + ); + } + other => panic!("A must refuse at the fence, got {other:?}\n{a_out}"), + } + assert!( + a_out.contains("(checked at unix 340). Nothing moved by this run; released 15 sats back to unremitted"), + "{a_out}" + ); + assert_eq!(melts.load(Ordering::SeqCst), 0, "no debit"); + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Failed); + assert_eq!(rows[0].spending_since_unix, None, "never admitted"); + assert_eq!(store.accrued_fees().expect("read").unremitted_fee_sats, 15); + assert_eq!(lease_secs(REMIT_LEASE), 300); + assert_eq!(lease_secs(SPEND_MARGIN), 60); + let _ = std::fs::remove_dir_all(&root); + } + + // ---- addendum 5 §2: the bound quote and the conditional release, full path ------------------- + + /// A's invoice X, its estimate quote and its PAYMENT quote Q, as the Fake names them: A probes + /// the gross (invoice 1, 15 sats), invoices the net (invoice 2, 13 sats) and quotes it twice — + /// the estimate at plan time and the payment quote before the fence. + const X_BOLT11: &str = "lnbc-fake-13-2-a"; + const X_ID: &str = "hash-13-2-a"; + const X_ESTIMATE_QUOTE: &str = "quote-lnbc-fake-13-2-a"; + const X_PAYMENT_QUOTE: &str = "paid-quote-lnbc-fake-13-2-a"; + + /// Process A for the addendum 5 §2 tests: owner `proc-a`, invoices tagged `-a`, one payment + /// scripted, speaking to the shared fake mint. + fn first_process(registry: &QuoteRegistry) -> Fake { + let mut a = Fake::new(|_| 2); + a.owner = "proc-a".to_owned(); + a.invoice_tag = "-a".to_owned(); + a.melt_results = vec![Ok((13, 1))]; + a.registry = Some(Arc::clone(registry)); + a + } + + /// Process B: another owner, another connection's effects, DISTINCT invoices (`-b`), reading the + /// SAME fake mint as A and counting its debits on the shared counter. + fn second_process(registry: &QuoteRegistry, melts: &Arc) -> Fake { + let mut b = Fake::new(|_| 2); + b.owner = "proc-b".to_owned(); + b.invoice_tag = "-b".to_owned(); + b.melt_results = vec![Ok((13, 1))]; + b.melt_counter = Some(Arc::clone(melts)); + b.registry = Some(Arc::clone(registry)); + b + } + + fn ledger(store: &SellerStore) -> (u64, u64, u64) { + let accrued = store.accrued_fees().expect("read"); + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats, + ) + } + + // Gate 2g (b1), addendum 5 §2 — the ordering the verdict traced at 534247b (B1): A's fence + // admitted X and BOUND the payment quote Q; A pauses before paying. Q is live, but the ESTIMATE + // quote A raised earlier for the same invoice hits its mint expiry while A is paused (a mint + // quote's clock is not the invoice's), and A's lease runs out too. B — another owner, another + // connection, the SAME fake mint — runs `--dry-run` then `--confirm`: it asks the mint about Q + // BY ID (never "the most alive quote for the invoice"), finds it UNPAID and live, and HOLDS. It + // plans no Y and pays nothing. A resumes and pays Q — exactly one debit, and the mint saw + // exactly one quote paid. + #[test] + fn a_spending_row_is_reconciled_by_its_bound_quote_not_by_an_expired_estimate() { + let (store, root) = store_with_fees("bound-quote-live", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let registry = quote_registry(); + let (a_result, b_results) = run_paused( + &db, + first_process(®istry), + 100, + PauseAt::Admit, + Gate::new(), + Arc::clone(&melts), + |store_b| { + let x = store_b + .in_flight_remittance() + .expect("query") + .expect("A's row"); + assert_eq!(x.state, RemittanceState::Spending); + assert_eq!(x.spending_since_unix, Some(100)); + assert_eq!(x.spending_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + // The mint: A's ESTIMATE quote for X expires at 130; Q stays live. + { + let mut quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + quotes + .get_mut(X_ESTIMATE_QUOTE) + .expect("A's estimate quote") + .expiry_unix = 130; + assert_eq!(quotes[X_PAYMENT_QUOTE].state, MeltQuoteState::Unpaid); + assert_eq!(quotes[X_PAYMENT_QUOTE].expiry_unix, u64::MAX); + } + let mut results = Vec::new(); + // Past the estimate's expiry AND past A's lease (400): neither releases a spending + // row whose bound quote is live. + for (trigger, now) in [(RemitTrigger::DryRun, 450), (RemitTrigger::Command, 451)] { + let mut b = second_process(®istry, &melts); + let (outcome, out) = run_remit(store_b, &mut b, trigger, now); + assert_eq!( + b.quote_status_calls, + vec![X_PAYMENT_QUOTE.to_owned()], + "B asks the mint about the BOUND quote, by id: {out}" + ); + assert!( + b.status_calls.is_empty(), + "B never ranks the invoice's quotes for a bound row: {out}" + ); + assert!(b.melts.is_empty(), "B must not pay: {out}"); + assert!( + b.invoices.is_empty(), + "B must not plan on top of a held row: {out}" + ); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: X_ID.to_owned(), + owner: "proc-a".to_owned(), + spending_since_unix: 100, + quote_id: Some(X_PAYMENT_QUOTE.to_owned()), + observed: format!( + "mint https://mint.example reports melt quote {X_PAYMENT_QUOTE} UNPAID (expiry unix {})", + u64::MAX + ), + held_sats: 15, + }), + "{out}" + ); + assert!( + out.contains("bound to melt quote paid-quote-lnbc-fake-13-2-a: asking the mint about that quote by id"), + "{out}" + ); + results.push((outcome, out)); + } + assert_eq!(ledger(store_b), (0, 15, 0), "receipts still pinned to X"); + results + }, + ); + let (a_outcome, a_out) = a_result; + assert!( + matches!(a_outcome, Ok(RemitOutcome::Paid { .. })), + "A pays Q once it resumes: {a_outcome:?}\n{a_out}" + ); + assert_eq!( + melts.load(Ordering::SeqCst), + 1, + "exactly one actual debit — A's, on Q" + ); + assert_eq!(b_results.len(), 2); + { + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!( + quotes[X_PAYMENT_QUOTE].state, + MeltQuoteState::Paid, + "the mint saw exactly Q paid" + ); + assert_eq!( + quotes[X_ESTIMATE_QUOTE].state, + MeltQuoteState::Unpaid, + "the expired estimate was never paid" + ); + assert_eq!( + quotes + .values() + .filter(|q| q.state == MeltQuoteState::Paid) + .count(), + 1 + ); + } + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1, "one row, A's: {rows:?}"); + assert_eq!(rows[0].remittance_id, X_ID); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!(rows[0].melt_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + assert_eq!(ledger(&store), (15, 0, 0)); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2, "{attempts:?}"); + assert_eq!(attempts[0].outcome, RemitAttemptOutcome::Paid); + assert_eq!(attempts[1].trigger, RemitAttemptTrigger::Command); + assert_eq!(attempts[1].outcome, RemitAttemptOutcome::Refused); + assert_eq!(attempts[1].remittance_id.as_deref(), Some(X_ID)); + let _ = std::fs::remove_dir_all(&root); + } + + // Gate 2g (b2), addendum 5 §2 as addendum 6 §1.2 re-rules it — the same pause, the quote + // expiring: the mint stamps every quote it raises with expiry 130. A runs at 60 (lease until + // 360): Q is raised with 70 s of life — more than the margin — so A's fence admits X and binds + // Q; A pauses before paying. While it is paused the clock, SHARED by A and B, moves to 200: Q + // has expired and the old spending margin has passed. B `--dry-run` then `--confirm`: asks + // about Q by id, finds it UNPAID past expiry — and HOLDS: expiry is not cancellation (the mint + // pays an expired UNPAID quote), so X stays spending, its receipts stay pinned by real SQL, and + // B plans no Y and pays nothing. A resumes: it reads the clock fresh, refuses to pay a bound + // quote inside (here: past) its margin — attempt avoidance, not the safety — journals the + // attempt failed, raises no other quote and does not touch the mint. ZERO melts. X is held for + // the mint's PAID or an operator; nobody's clock releases it. + #[test] + fn a_bound_quote_expired_past_the_margin_is_held_and_its_owner_refuses_to_pay_it() { + let (store, root) = store_with_fees("bound-quote-expired", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let registry = quote_registry(); + let mut a = first_process(®istry); + a.quote_expiry_unix = 130; + let clock = Arc::clone(&a.clock); + let (a_result, b_results) = run_paused( + &db, + a, + 60, + PauseAt::Admit, + Gate::new(), + Arc::clone(&melts), + |store_b| { + let x = store_b + .in_flight_remittance() + .expect("query") + .expect("A's row"); + assert_eq!(x.state, RemittanceState::Spending); + assert_eq!(x.spending_since_unix, Some(60)); + assert_eq!(x.spending_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + { + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!(quotes[X_PAYMENT_QUOTE].expiry_unix, 130, "Q expires at 130"); + assert_eq!(quotes[X_PAYMENT_QUOTE].state, MeltQuoteState::Unpaid); + } + // The clock both processes read moves to 200: past 130, and past the old margin. + let mut results = Vec::new(); + for (trigger, now) in [(RemitTrigger::DryRun, 200), (RemitTrigger::Command, 201)] { + let mut b = second_process(®istry, &melts); + b.clock = Arc::clone(&clock); + let (outcome, out) = run_remit(store_b, &mut b, trigger, now); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: X_ID.to_owned(), + owner: "proc-a".to_owned(), + spending_since_unix: 60, + quote_id: Some(X_PAYMENT_QUOTE.to_owned()), + observed: format!( + "mint https://mint.example reports melt quote {X_PAYMENT_QUOTE} UNPAID (expiry unix 130)" + ), + held_sats: 15, + }), + "at {now}: {out}" + ); + assert_eq!(b.quote_status_calls, vec![X_PAYMENT_QUOTE.to_owned()]); + assert!(b.status_calls.is_empty()); + assert!( + out.contains("HELD: remittance hash-13-2-a is SPENDING (admitted by proc-a at unix 60), bound to melt quote paid-quote-lnbc-fake-13-2-a; mint https://mint.example reports melt quote paid-quote-lnbc-fake-13-2-a UNPAID (expiry unix 130); 15 sats of receipts stay pinned to it — a spending row is released by nobody and on no clock"), + "at {now}: {out}" + ); + assert!(!out.contains("released 15 sats"), "at {now}: {out}"); + assert!( + b.invoices.is_empty() && b.melts.is_empty(), + "B neither plans nor pays on a held row: {out}" + ); + assert_eq!( + store_b + .in_flight_remittance() + .expect("query") + .expect("still X") + .state, + RemittanceState::Spending + ); + assert_eq!( + ledger(store_b), + (0, 15, 0), + "receipts still pinned to X at {now}: the expired quote may yet be paid" + ); + results.push((outcome, out)); + } + results + }, + ); + let (a_outcome, a_out) = a_result; + match a_outcome { + Ok(RemitOutcome::MeltFailed { + remittance_id, + error, + }) => { + assert_eq!(remittance_id, X_ID); + assert_eq!( + error, + "bound melt quote paid-quote-lnbc-fake-13-2-a expires at unix 130, within 60 s of now (unix 201); not paid" + ); + } + other => { + panic!("A must refuse its bound quote and pay nothing, got {other:?}\n{a_out}") + } + } + assert!( + a_out.contains("this process raises no other quote for it"), + "{a_out}" + ); + assert_eq!( + melts.load(Ordering::SeqCst), + 0, + "no debit at all: A refused its bound quote and B was held" + ); + assert_eq!(b_results.len(), 2); + { + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!( + quotes[X_PAYMENT_QUOTE].state, + MeltQuoteState::Unpaid, + "Q was never paid" + ); + assert!( + quotes.keys().all(|id| id.ends_with("-a")), + "B raised no quote at all: {:?}", + quotes.keys().collect::>() + ); + assert_eq!( + quotes.len(), + 3, + "A raised its two estimates and Q, and nothing after: {:?}", + quotes.keys().collect::>() + ); + } + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!( + rows.iter() + .map(|r| (r.remittance_id.as_str(), r.state)) + .collect::>(), + vec![(X_ID, RemittanceState::Spending)], + "X is held, bound, for the mint's PAID or an operator" + ); + assert_eq!(rows[0].spending_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + assert_eq!(ledger(&store), (0, 15, 0), "nothing paid, nothing released"); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2, "{attempts:?}"); + assert_eq!( + ( + attempts[0].trigger, + attempts[0].outcome, + attempts[0].remittance_id.as_deref() + ), + ( + RemitAttemptTrigger::Collect, + RemitAttemptOutcome::Failed, + Some(X_ID) + ), + "A's refusal of its own bound quote is journaled as a failed attempt" + ); + assert_eq!( + ( + attempts[1].trigger, + attempts[1].outcome, + attempts[1].remittance_id.as_deref() + ), + ( + RemitAttemptTrigger::Command, + RemitAttemptOutcome::Refused, + Some(X_ID) + ), + "B's hold is journaled naming X" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Gate 2g (c), addendum 5 §2 — the gone owner, paused one step later than addendum 4's (c): A + // has journaled X AND raised its payment quote Q (Q exists at the mint, live) and pauses BEFORE + // the fence; the shared clock moves past A's lease. B `--dry-run` reconciles: X is planned + // (never admitted — `spending_since_unix IS NULL`), the invoice's quotes are live UNPAID, the + // lease has run out — released by the lease transition; B `--confirm` plans and pays a DISTINCT + // Y. A resumes: its fence reads the clock fresh and changes zero rows (X is failed): it refuses + // and never pays Q. One melt — Y's. Q is left UNPAID at the mint, bound to nothing. + #[test] + fn an_owner_paused_after_its_quote_and_past_its_lease_is_released_and_never_pays_that_quote() { + let (store, root) = store_with_fees("quote-then-lease", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let registry = quote_registry(); + let a = first_process(®istry); + let clock = Arc::clone(&a.clock); + let (a_result, b_results) = run_paused( + &db, + a, + 100, + PauseAt::Quote, + Gate::new(), + Arc::clone(&melts), + |store_b| { + let x = store_b + .in_flight_remittance() + .expect("query") + .expect("A's row"); + assert_eq!( + x.state, + RemittanceState::Planned, + "A is paused BEFORE its fence" + ); + assert_eq!(x.spending_since_unix, None); + assert_eq!(x.spending_quote_id, None); + { + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!( + quotes[X_PAYMENT_QUOTE].state, + MeltQuoteState::Unpaid, + "Q exists at the mint before the fence" + ); + } + // 100 + REMIT_LEASE (300) = 400: the lease has run out — for B's reconciliation and + // for the clock A reads fresh inside its fence when it resumes. + clock.store(400, Ordering::SeqCst); + let mut results = Vec::new(); + let mut b = second_process(®istry, &melts); + b.clock = Arc::clone(&clock); + let (outcome, out) = run_remit(store_b, &mut b, RemitTrigger::DryRun, 400); + assert_eq!(outcome, RemitOutcome::DryRun, "{out}"); + assert_eq!( + b.status_calls, + vec![X_BOLT11.to_owned()], + "a planned row has no bound quote: the invoice's quotes are asked: {out}" + ); + assert!(b.quote_status_calls.is_empty()); + assert!( + out.contains("its owner's lease ran out at unix 400 (owner proc-a); released 15 sats back to unremitted (release condition: planned, never admitted, and its owner's lease had run out at unix 400)"), + "{out}" + ); + assert!(b.melts.is_empty()); + assert_eq!(ledger(store_b), (0, 0, 15)); + results.push((outcome, out)); + let mut b = second_process(®istry, &melts); + b.clock = Arc::clone(&clock); + let (outcome, out) = run_remit(store_b, &mut b, RemitTrigger::Command, 401); + assert!(is_paid(&outcome), "B pays Y once X is released: {out}"); + assert_eq!(b.melts, vec!["lnbc-fake-13-2-b".to_owned()]); + results.push((outcome, out)); + results + }, + ); + let (a_outcome, a_out) = a_result; + match a_outcome { + Ok(RemitOutcome::Refused(Refusal::OwnershipLost { + remittance_id, + reason, + })) => { + assert_eq!(remittance_id, X_ID); + assert!( + reason.contains( + "the row is no longer planned (now failed): another process reconciled it" + ), + "{reason}" + ); + } + other => panic!("A must refuse at the fence, got {other:?}\n{a_out}"), + } + assert!( + a_out.contains("REFUSED before spending — the row is no longer planned (now failed): another process reconciled it (checked at unix 401)"), + "{a_out}" + ); + assert_eq!( + melts.load(Ordering::SeqCst), + 1, + "exactly one actual debit — B's" + ); + assert_eq!(b_results.len(), 2); + { + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!( + quotes[X_PAYMENT_QUOTE].state, + MeltQuoteState::Unpaid, + "Q was raised and never paid" + ); + assert_eq!( + quotes + .values() + .filter(|q| q.state == MeltQuoteState::Paid) + .count(), + 1 + ); + } + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!( + rows.iter() + .map(|r| ( + r.remittance_id.as_str(), + r.state, + r.spending_since_unix, + r.spending_quote_id.as_deref() + )) + .collect::>(), + vec![ + (X_ID, RemittanceState::Failed, None, None), + ( + "hash-13-2-b", + RemittanceState::Settled, + Some(401), + Some("paid-quote-lnbc-fake-13-2-b") + ) + ], + "X was never admitted and binds no quote; Y was admitted at B's clock, bound to its quote" + ); + assert_eq!(ledger(&store), (15, 0, 0)); + let a_attempt = store + .recent_remit_attempts(10) + .expect("attempts") + .into_iter() + .find(|a| a.trigger == RemitAttemptTrigger::Collect) + .expect("A's attempt"); + assert_eq!(a_attempt.outcome, RemitAttemptOutcome::Refused); + assert_eq!(a_attempt.remittance_id.as_deref(), Some(X_ID)); + let _ = std::fs::remove_dir_all(&root); + } + + // Gate 2g (B2), addendum 5 §2 — the verdict's second ordering, deterministic: B reads X planned + // by A (lease until 400) on an entry clock of 401 and DECIDES to release it on lease expiry; + // before B writes, A's fence lands — the clock A reads INSIDE the store's lock is the shared + // one, still 100 — admitting X and binding Q. B resumes: its release is the conditional lease + // transition (`state = 'planned' AND spending_since_unix IS NULL AND lease_until_unix <= 401`) + // and changes ZERO rows: B HOLDS, says the row changed under it, plans nothing, pays nothing. + // A pays X. One melt. Both Fakes share one clock `Arc` and one fake mint; three gates order the + // two threads (A after its quote, B after its decision, A after its admission). + #[test] + fn a_release_decided_on_a_stale_planned_snapshot_cannot_revoke_a_later_admission() { + let (store, root) = store_with_fees("stale-release", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let registry = quote_registry(); + let mut a = first_process(®istry); + a.melt_counter = Some(Arc::clone(&melts)); + let clock = Arc::clone(&a.clock); + a.set_clock(100); + let a_quote_gate = Gate::new(); + let a_admit_gate = Gate::new(); + a.quote_gate = Some(Arc::clone(&a_quote_gate)); + a.admit_gate = Some(Arc::clone(&a_admit_gate)); + let db_a = db.clone(); + let a_thread = std::thread::spawn(move || { + let store = SellerStore::open(&db_a).expect("open A"); + let mut out = Vec::new(); + let outcome = remit(&store, &mut a, RemitTrigger::Collect, 100, &mut out); + (outcome, String::from_utf8_lossy(&out).into_owned(), a) + }); + // 1. A: X planned (lease until 400), Q raised, paused before its fence. + a_quote_gate.wait_arrived(Duration::from_secs(10)); + // 2. B, entry clock 401: reads X planned with its lease run out, decides to release it, and + // pauses before writing. (Its Fake shares A's clock, which still reads 100.) + let mut b = second_process(®istry, &melts); + b.clock = Arc::clone(&clock); + let b_decision_gate = Gate::new(); + b.decision_gate = Some(Arc::clone(&b_decision_gate)); + let db_b = db.clone(); + let b_thread = std::thread::spawn(move || { + let store = SellerStore::open(&db_b).expect("open B"); + let mut out = Vec::new(); + let outcome = remit(&store, &mut b, RemitTrigger::Command, 401, &mut out); + (outcome, String::from_utf8_lossy(&out).into_owned(), b) + }); + b_decision_gate.wait_arrived(Duration::from_secs(10)); + { + let store = SellerStore::open(&db).expect("open"); + let x = store.in_flight_remittance().expect("query").expect("X"); + assert_eq!( + x.state, + RemittanceState::Planned, + "B has decided; nothing is written yet" + ); + } + // 3. A's fence lands: the clock read inside the lock is 100 — 400 > 100 + 60 — admitted, + // Q bound. + a_quote_gate.release(); + a_admit_gate.wait_arrived(Duration::from_secs(10)); + { + let store = SellerStore::open(&db).expect("open"); + let x = store.in_flight_remittance().expect("query").expect("X"); + assert_eq!(x.state, RemittanceState::Spending); + assert_eq!(x.spending_since_unix, Some(100)); + assert_eq!(x.spending_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + } + // 4. B resumes and writes its release: zero rows changed. HOLD. + b_decision_gate.release(); + let (b_outcome, b_out, b) = b_thread.join().expect("B's thread"); + assert_eq!( + b_outcome, + Ok(RemitOutcome::Refused(Refusal::RowChangedUnderMe { + remittance_id: X_ID.to_owned(), + })), + "{b_out}" + ); + assert!( + b_out.contains("its owner's lease ran out at unix 400 (owner proc-a) — but the row changed under me between that decision and the release (condition: planned, never admitted, and its owner's lease had run out at unix 401): nothing written. REFUSED — nothing moved by this run"), + "{b_out}" + ); + assert_eq!(b.decisions_seen.len(), 1); + assert!( + matches!( + &b.decisions_seen[0], + Reconcile::Release { + on: ReleaseOn::LeaseExpired { now_unix: 401 }, + .. + } + ), + "B's decision was the lease release: {:?}", + b.decisions_seen + ); + assert!( + b.melts.is_empty() && b.invoices.is_empty(), + "B planned and paid nothing: {b_out}" + ); + { + let store = SellerStore::open(&db).expect("open"); + let x = store + .in_flight_remittance() + .expect("query") + .expect("X, still A's"); + assert_eq!( + x.state, + RemittanceState::Spending, + "B's release touched nothing" + ); + assert_eq!(ledger(&store), (0, 15, 0), "receipts still pinned to X"); + } + // 5. A pays X. + a_admit_gate.release(); + let (a_outcome, a_out, a) = a_thread.join().expect("A's thread"); + assert!( + matches!(a_outcome, Ok(RemitOutcome::Paid { .. })), + "{a_outcome:?}\n{a_out}" + ); + assert_eq!(a.melts, vec![X_BOLT11.to_owned()]); + assert_eq!(melts.load(Ordering::SeqCst), 1, "exactly one actual debit"); + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!( + ( + rows[0].remittance_id.as_str(), + rows[0].state, + rows[0].spending_since_unix + ), + (X_ID, RemittanceState::Settled, Some(100)) + ); + assert_eq!(ledger(&store), (15, 0, 0)); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2, "{attempts:?}"); + assert_eq!( + (attempts[0].trigger, attempts[0].outcome), + (RemitAttemptTrigger::Collect, RemitAttemptOutcome::Paid) + ); + assert_eq!( + ( + attempts[1].trigger, + attempts[1].outcome, + attempts[1].remittance_id.as_deref() + ), + ( + RemitAttemptTrigger::Command, + RemitAttemptOutcome::Refused, + Some(X_ID) + ), + "B's hold is journaled naming X" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // Gate 2g (d), addendum 5 §2 — the FULL PATH for a spending row bound to Q, four arms, each on + // its own store with a real second process (the pure decision table above is kept as an extra): + // A's fence admitted X and bound Q; A pauses before paying; the fake mint's registry moves Q to + // the arm's state; B — another owner, another connection, the same mint — runs `--dry-run` + // then `--confirm` at 450/451, past A's lease. Melts are counted per arm. Under addendum 6 + // §1.2 B HOLDS in EVERY arm — X stays spending, its receipts stay pinned (real SQL), B plans no + // Y and pays nothing — and what differs is what the mint then does with A's prepared payment: + // FAILED → the mint ACCEPTS Q (CDK 0.17.2 admits UNPAID or FAILED): A pays. ONE melt (A's). + // Had B released on FAILED and paid Y, that would have been two. + // UNPAID, live, lease long run out → A pays Q. ONE melt (A's). + // PENDING → the mint refuses Q (PENDING); no debit. ZERO melts; X spending, receipts pinned. + // UNKNOWN → the same way. ZERO melts; X spending, receipts pinned. + // Every arm asserts the attempts journal: A's outcome, then B's --confirm refusal naming X. + #[test] + fn a_spending_rows_bound_quote_decides_its_release_on_the_full_path() { + struct Arm { + label: &'static str, + state: MeltQuoteState, + a_pays: bool, + hold_text: &'static str, + } + let arms = [ + Arm { + label: "failed", + state: MeltQuoteState::Failed, + a_pays: true, + hold_text: "HELD: remittance hash-13-2-a is SPENDING (admitted by proc-a at unix 100), bound to melt quote paid-quote-lnbc-fake-13-2-a; mint https://mint.example reports melt quote paid-quote-lnbc-fake-13-2-a FAILED", + }, + Arm { + label: "unpaid-live", + state: MeltQuoteState::Unpaid, + a_pays: true, + hold_text: "HELD: remittance hash-13-2-a is SPENDING (admitted by proc-a at unix 100), bound to melt quote paid-quote-lnbc-fake-13-2-a; mint https://mint.example reports melt quote paid-quote-lnbc-fake-13-2-a UNPAID", + }, + Arm { + label: "pending", + state: MeltQuoteState::Pending, + a_pays: false, + hold_text: "HELD: remittance hash-13-2-a is SPENDING (admitted by proc-a at unix 100), bound to melt quote paid-quote-lnbc-fake-13-2-a; mint https://mint.example reports melt quote paid-quote-lnbc-fake-13-2-a PENDING", + }, + Arm { + label: "unknown", + state: MeltQuoteState::Unknown, + a_pays: false, + hold_text: "HELD: remittance hash-13-2-a is SPENDING (admitted by proc-a at unix 100), bound to melt quote paid-quote-lnbc-fake-13-2-a; mint https://mint.example reports melt quote paid-quote-lnbc-fake-13-2-a UNKNOWN", + }, + ]; + for arm in &arms { + let (store, root) = store_with_fees(&format!("full-path-{}", arm.label), &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let registry = quote_registry(); + let (a_result, b_results) = run_paused( + &db, + first_process(®istry), + 100, + PauseAt::Admit, + Gate::new(), + Arc::clone(&melts), + |store_b| { + let x = store_b + .in_flight_remittance() + .expect("query") + .expect("A's row"); + assert_eq!(x.state, RemittanceState::Spending, "[{}]", arm.label); + assert_eq!(x.spending_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + // The mint moves Q to this arm's state. + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_mut(X_PAYMENT_QUOTE) + .expect("Q") + .state = arm.state; + let mut results = Vec::new(); + for (trigger, now) in + [(RemitTrigger::DryRun, 450), (RemitTrigger::Command, 451)] + { + let mut b = second_process(®istry, &melts); + let (outcome, out) = run_remit(store_b, &mut b, trigger, now); + assert_eq!( + b.quote_status_calls, + vec![X_PAYMENT_QUOTE.to_owned()], + "[{}] B asks the mint about Q by id, and about nothing else: {out}", + arm.label + ); + assert!(b.status_calls.is_empty(), "[{}] {out}", arm.label); + assert!( + b.melts.is_empty() && b.invoices.is_empty() && b.quotes.is_empty(), + "[{}] B must neither plan, quote nor pay on a held row: {out}", + arm.label + ); + // Addendum 7 §2: every bound non-PAID observation — FAILED, UNPAID, PENDING, + // UNKNOWN — is the SAME refusal and renders the SAME single `HELD:` line. + let expected_hold = RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: X_ID.to_owned(), + owner: "proc-a".to_owned(), + spending_since_unix: 100, + quote_id: Some(X_PAYMENT_QUOTE.to_owned()), + observed: format!( + "mint https://mint.example reports melt quote {X_PAYMENT_QUOTE} {} (expiry unix {})", + arm.state, + u64::MAX + ), + held_sats: 15, + }); + assert_eq!(outcome, expected_hold, "[{}] {out}", arm.label); + assert!( + out.contains(arm.hold_text) + && out.contains("15 sats of receipts stay pinned to it") + && out.contains("REFUSED — nothing moved by this run"), + "[{}] {out}", + arm.label + ); + assert_eq!( + out.lines() + .filter(|line| line.starts_with(" HELD: remittance hash-13-2-a")) + .count(), + 1, + "[{}] exactly one HELD line, naming the row, on {trigger:?}: {out}", + arm.label + ); + assert!( + !out.contains("still settling"), + "[{}] a bound spending row is HELD, never merely 'settling': {out}", + arm.label + ); + assert!(!out.contains("released 15 sats"), "[{}] {out}", arm.label); + let x = store_b + .in_flight_remittance() + .expect("query") + .expect("still X"); + assert_eq!( + (x.state, x.spending_quote_id.as_deref()), + (RemittanceState::Spending, Some(X_PAYMENT_QUOTE)), + "[{}] held: nothing written", + arm.label + ); + assert_eq!( + ledger(store_b), + (0, 15, 0), + "[{}] receipts pinned to X while Q can still be paid", + arm.label + ); + results.push((outcome, out)); + } + results + }, + ); + let (a_outcome, a_out) = a_result; + if arm.a_pays { + assert!( + matches!(a_outcome, Ok(RemitOutcome::Paid { .. })), + "[{}] {a_outcome:?}\n{a_out}", + arm.label + ); + } else { + match a_outcome { + Ok(RemitOutcome::MeltFailed { + remittance_id, + error, + }) => { + assert_eq!(remittance_id, X_ID); + assert!( + error.contains( + "refuses to pay melt quote paid-quote-lnbc-fake-13-2-a: it is" + ), + "[{}] {error}", + arm.label + ); + } + other => panic!( + "[{}] A must be refused by the mint, got {other:?}\n{a_out}", + arm.label + ), + } + assert!( + a_out.contains("This process raises no other quote for the row"), + "[{}] {a_out}", + arm.label + ); + } + assert_eq!(b_results.len(), 2); + let expected_melts = usize::from(arm.a_pays); + assert_eq!( + melts.load(Ordering::SeqCst), + expected_melts, + "[{}] actual debits — never two", + arm.label + ); + let store = SellerStore::open(&db).expect("open"); + let rows = store + .remittances() + .expect("rows") + .into_iter() + .map(|r| (r.remittance_id, r.state)) + .collect::>(); + if arm.a_pays { + assert_eq!( + rows, + vec![(X_ID.to_owned(), RemittanceState::Settled)], + "[{}] one row, X, paid by its owner", + arm.label + ); + assert_eq!(ledger(&store), (15, 0, 0), "[{}]", arm.label); + } else { + assert_eq!( + rows, + vec![(X_ID.to_owned(), RemittanceState::Spending)], + "[{}] held for the mint to resolve", + arm.label + ); + assert_eq!(ledger(&store), (0, 15, 0), "[{}]", arm.label); + } + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2, "[{}] {attempts:?}", arm.label); + assert_eq!( + ( + attempts[0].trigger, + attempts[0].outcome, + attempts[0].remittance_id.as_deref() + ), + ( + RemitAttemptTrigger::Collect, + if arm.a_pays { + RemitAttemptOutcome::Paid + } else { + RemitAttemptOutcome::Failed + }, + Some(X_ID) + ), + "[{}] A's attempt, journaled last", + arm.label + ); + assert_eq!( + ( + attempts[1].trigger, + attempts[1].outcome, + attempts[1].remittance_id.as_deref() + ), + ( + RemitAttemptTrigger::Command, + RemitAttemptOutcome::Refused, + Some(X_ID) + ), + "[{}] B's --confirm hold, journaled naming X (the dry run is not an attempt)", + arm.label + ); + let _ = std::fs::remove_dir_all(&root); + } + } + + // Addendum 6 §2.1 / verdict at 6fc77e1 §4 B3 — the DELAYED CONFIRM, on the full path with a real + // store, two owners, two connections, one shared fake mint and one shared fake wallet: + // A plans X at 100, its fence admits X and binds Q (Q expires at 200; X's invoice is still + // valid), A passes its own pre-await margin check (200 > 100 + 60), the wallet's ceiling and + // proof selection (exact denominations 8+4+2+1 out of two disjoint such sets) and + // `prepare_melt`'s expiry check on the wallet's clock — and PAUSES there, its request built + // but not yet at the mint. + // The shared clock moves to 261: Q is past expiry and past the old margin. + // B — another owner, disjoint proofs left in the same wallet — is refused at TWO named + // boundaries, exercised separately (addendum 7 §1; verdict at 6fd13df §5 D1): + // (i) RECONCILIATION: B runs `--dry-run` then `--confirm` through the real `remit`. Step 1 + // of `remit_inner` finds X in flight, asks the mint about Q BY ID, gets UNPAID (expired), + // and `reconcile_decision` HOLDS — `Refusal::SpendingHeld`, returned before any plan. + // While X is bound and spending, EVERY `remit` on this store stops here by design; it + // never reaches `plan_remittance`, so these two runs do not exercise the store's own + // predicate — the next boundary does. + // (ii) STORE: between those two runs B makes a concrete planning attempt for a distinct + // invoice Y, on B's own connection, through the store's admission entry + // `plan_remittance` — the call `remit_inner` step 6 makes. Its in-flight predicate + // (`in_flight_remittance_in`, inside the `IMMEDIATE` transaction) refuses with + // `PlanRefused::InFlight` naming X: no Y row, receipts still pinned to X, no quote + // raised, no debit. This is the race-closing layer the ordinary held-row path never + // reaches while X is spending; it is reached here directly, not by hand-written SQL. + // At both boundaries 15 sats of exact proofs are still in the wallet, so neither refusal is + // for want of funds. + // A resumes: the mint (as the inspected CDK 0.17.2 implementation does) accepts the expired + // UNPAID Q and pays X. + // Exactly ONE debit, never two — and it is one because B was never admitted while X was bound + // and spending, NOT because Q expired: at 6fc77e1 the same ordering released X on the expired + // Q, B paid Y, and A's late request paid Q — two payments against one accrued balance. The + // receipt invariant is asserted through the ordering (pinned to X at each of B's three + // observations, discharged once by A's debit), not only in the final row count. + #[test] + fn a_payment_prepared_before_expiry_cannot_be_doubled_by_a_release_after_it() { + let (store, root) = store_with_fees("delayed-confirm", &[10, 5]); + drop(store); + let db = root.join(STATE_DB_FILE); + let melts = Arc::new(AtomicUsize::new(0)); + let registry = quote_registry(); + // Two disjoint exact sets for a 15-sat payment (13 + reserve 2): whichever A takes, B's + // selection cannot collide with it. + let proofs = fake_proofs(&[8, 4, 2, 1, 8, 4, 2, 1]); + let remaining = |proofs: &super::test_support::FakeProofs| { + let mut left = proofs.lock().unwrap_or_else(|e| e.into_inner()).clone(); + left.sort_unstable(); + left + }; + let mut a = first_process(®istry); + a.quote_expiry_unix = 200; + a.proofs = Some(Arc::clone(&proofs)); + let clock = Arc::clone(&a.clock); + let (a_result, b_results) = run_paused( + &db, + a, + 100, + PauseAt::Melt, + Gate::new(), + Arc::clone(&melts), + |store_b| { + // A is inside its payment: X spending, bound to Q; Q UNPAID at the mint, expiring + // at 200; A's proofs reserved — one exact set left for anyone else. + let x = store_b + .in_flight_remittance() + .expect("query") + .expect("A's row"); + assert_eq!(x.state, RemittanceState::Spending); + assert_eq!(x.spending_since_unix, Some(100)); + assert_eq!(x.spending_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + { + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!(quotes[X_PAYMENT_QUOTE].state, MeltQuoteState::Unpaid); + assert_eq!(quotes[X_PAYMENT_QUOTE].expiry_unix, 200); + } + assert_eq!( + remaining(&proofs), + vec![1, 2, 4, 8], + "A reserved one exact 15-sat set; the other is still in the wallet" + ); + // The clock both processes read moves past Q's expiry and past the old margin. + clock.store(261, Ordering::SeqCst); + let mut results = Vec::new(); + for (trigger, now) in [(RemitTrigger::DryRun, 261), (RemitTrigger::Command, 262)] { + let mut b = second_process(®istry, &melts); + b.clock = Arc::clone(&clock); + b.proofs = Some(Arc::clone(&proofs)); + let (outcome, out) = run_remit(store_b, &mut b, trigger, now); + assert_eq!( + outcome, + RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: X_ID.to_owned(), + owner: "proc-a".to_owned(), + spending_since_unix: 100, + quote_id: Some(X_PAYMENT_QUOTE.to_owned()), + observed: format!( + "mint https://mint.example reports melt quote {X_PAYMENT_QUOTE} UNPAID (expiry unix 200)" + ), + held_sats: 15, + }), + "at {now}: {out}" + ); + assert_eq!( + b.quote_status_calls, + vec![X_PAYMENT_QUOTE.to_owned()], + "B asks about Q by id and nothing else: {out}" + ); + assert!(b.status_calls.is_empty(), "{out}"); + assert!( + b.invoices.is_empty() && b.quotes.is_empty() && b.melts.is_empty(), + "B was held at RECONCILIATION, before any plan: no invoice, no quote, no payment: {out}" + ); + assert!(!out.contains("released 15 sats"), "at {now}: {out}"); + assert_eq!( + out.lines() + .filter(|line| line.starts_with(" HELD: remittance hash-13-2-a")) + .count(), + 1, + "at {now}: exactly one HELD line, naming the row: {out}" + ); + assert_eq!( + store_b + .in_flight_remittance() + .expect("query") + .expect("still X") + .state, + RemittanceState::Spending, + "at {now}: held, nothing written" + ); + assert_eq!( + ledger(store_b), + (0, 15, 0), + "at {now}: the receipts stay pinned to X while A's prepared Q can still pay" + ); + assert_eq!( + remaining(&proofs), + vec![1, 2, 4, 8], + "at {now}: B had exact proofs for a 15-sat payment and did not use them — reconciliation held it before the wallet was asked; the store's own refusal is exercised below, not inferred from this" + ); + results.push((outcome, out)); + + if trigger == RemitTrigger::DryRun { + // Boundary (ii), the STORE — between B's dry-run and its confirm, with A + // still parked inside its payment and X spending/bound to Q. B builds a + // concrete plan for a DISTINCT invoice Y exactly as `remit_inner` would + // (its LNURL pay request, its own `-b`-tagged invoice for the 13-sat net, + // the same 15-sat gross and 2-sat reserve its dry-run would print) and + // takes it to the store's admission entry — the call at step 6 — on B's + // own connection. Y's melt quote is None on purpose: raising an estimate + // for Y at the mint IS a quote raised, which this observation asserts did + // not happen, and the store's in-flight predicate runs before any use of + // the plan's quote id. + let mut b_plan = second_process(®istry, &melts); + b_plan.clock = Arc::clone(&clock); + b_plan.proofs = Some(Arc::clone(&proofs)); + let address = LightningAddress::parse(PLATFORM_FEE_ADDRESS) + .expect("platform address"); + let pay = b_plan.pay_request(&address).expect("LNURL pay request"); + let y = b_plan.invoice(&pay, 13).expect("Y invoice"); + assert!( + y.payment_hash.ends_with("-b") && y.payment_hash != X_ID, + "Y is B's own invoice, distinct from X: {}", + y.payment_hash + ); + let plan_y = RemittancePlan { + payment_hash: y.payment_hash.clone(), + gross_sats: 15, + net_sats: 13, + melt_fee_reserve_sats: 2, + destination: address.to_string(), + bolt11: y.bolt11.clone(), + melt_quote_id: None, + }; + let refused = store_b + .plan_remittance( + &plan_y, + b_plan.owner(), + now.saturating_add(lease_secs(REMIT_LEASE)), + now, + ) + .expect_err("the STORE refuses B's plan while X is in flight"); + match &refused { + PlanRefused::InFlight(active) => { + assert_eq!( + ( + active.remittance_id.as_str(), + active.state, + active.spending_quote_id.as_deref(), + active.owner.as_deref(), + active.spending_since_unix, + ), + ( + X_ID, + RemittanceState::Spending, + Some(X_PAYMENT_QUOTE), + Some("proc-a"), + Some(100), + ), + "the STORE refused B's plan naming X, spending and bound to Q: {active:?}" + ); + } + other => panic!( + "expected the STORE's PlanRefused::InFlight naming X, got {other:?}" + ), + } + let printed = refused.to_string(); + assert!( + printed.contains("remittance hash-13-2-a") + && printed.contains("still in flight"), + "what `remit_inner` would print as REFUSED — ...: {printed}" + ); + // Nothing moved by that attempt: no Y row, receipts still pinned to X, no + // quote raised at the mint, no proof selected, no debit. + assert_eq!( + store_b + .remittances() + .expect("rows") + .iter() + .map(|row| (row.remittance_id.as_str(), row.state)) + .collect::>(), + vec![(X_ID, RemittanceState::Spending)], + "the STORE wrote no Y row" + ); + assert_eq!( + ledger(store_b), + (0, 15, 0), + "the STORE's refusal left every receipt pinned to X; none became payable" + ); + { + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert!( + quotes.keys().all(|id| id.ends_with("-a")), + "B's planning attempt raised no quote at the mint: {:?}", + quotes.keys().collect::>() + ); + assert_eq!(quotes[X_PAYMENT_QUOTE].state, MeltQuoteState::Unpaid); + } + assert_eq!( + b_plan.invoices, + vec![13], + "Y's LNURL invoice is the only effect" + ); + assert!( + b_plan.estimates.is_empty() + && b_plan.quotes.is_empty() + && b_plan.melts.is_empty(), + "no estimate, no payment quote, no melt for Y" + ); + assert_eq!( + melts.load(Ordering::SeqCst), + 0, + "no debit while A is still parked and B is refused by the STORE" + ); + assert_eq!( + remaining(&proofs), + vec![1, 2, 4, 8], + "B had exact proofs for Y and the STORE, not the wallet, refused it" + ); + } + } + results + }, + ); + // A resumes: the mint accepts the expired UNPAID quote (no expiry check on that path) and X + // is paid — once. + let (a_outcome, a_out) = a_result; + assert!( + matches!(a_outcome, Ok(RemitOutcome::Paid { .. })), + "A's prepared payment lands: {a_outcome:?}\n{a_out}" + ); + assert!( + a_out.contains("PAID — remittance hash-13-2-a settled"), + "{a_out}" + ); + assert_eq!(b_results.len(), 2); + assert_eq!( + melts.load(Ordering::SeqCst), + 1, + "exactly one actual debit — A's, on X; never two" + ); + assert_eq!( + remaining(&proofs), + vec![1, 2, 4, 8], + "A spent exactly its reserved 15 sats; B's set is untouched" + ); + { + let quotes = registry.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!( + quotes[X_PAYMENT_QUOTE].state, + MeltQuoteState::Paid, + "the mint paid the expired quote" + ); + assert!( + quotes.keys().all(|id| id.ends_with("-a")), + "B raised no quote: {:?}", + quotes.keys().collect::>() + ); + assert_eq!( + quotes + .values() + .filter(|quote| quote.state == MeltQuoteState::Paid) + .count(), + 1, + "one quote paid at the mint, ever" + ); + } + let store = SellerStore::open(&db).expect("open"); + let rows = store.remittances().expect("rows"); + assert_eq!( + rows.iter() + .map(|row| (row.remittance_id.as_str(), row.state)) + .collect::>(), + vec![(X_ID, RemittanceState::Settled)], + "one row, X, settled by its owner's melt; no Y ever existed" + ); + assert_eq!(rows[0].melt_quote_id.as_deref(), Some(X_PAYMENT_QUOTE)); + assert_eq!(rows[0].settled_by, Some(SettledBy::Melt)); + assert_eq!( + ledger(&store), + (15, 0, 0), + "the 15 sats accrued were discharged exactly once" + ); + // Two attempts journaled: A's paid collect and B's refused confirm (dry-runs journal + // nothing). B's direct planning attempt at the store journals nothing either — attempts are + // written by `remit`'s wrapper, and `plan_remittance` refused inside its own transaction. + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2, "{attempts:?}"); + assert_eq!( + ( + attempts[0].trigger, + attempts[0].outcome, + attempts[0].remittance_id.as_deref() + ), + ( + RemitAttemptTrigger::Collect, + RemitAttemptOutcome::Paid, + Some(X_ID) + ) + ); + assert_eq!( + ( + attempts[1].trigger, + attempts[1].outcome, + attempts[1].remittance_id.as_deref() + ), + ( + RemitAttemptTrigger::Command, + RemitAttemptOutcome::Refused, + Some(X_ID) + ), + "B's --confirm hold at RECONCILIATION is journaled naming X" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // The attempts journal is bounded and newest-first, and a limit of zero returns nothing. + #[test] + fn recent_attempts_are_newest_first_and_bounded() { + let (store, root) = store_with_fees("attempts-order", &[]); + for at in 1..=7 { + store + .record_remit_attempt(&RemitAttempt { + attempt_id: 0, + started_at_unix: at, + trigger: RemitAttemptTrigger::Collect, + unremitted_sats: 3, + outcome: RemitAttemptOutcome::Failed, + detail: format!("failure {at}"), + remittance_id: None, + }) + .expect("record"); + } + let recent = store + .recent_remit_attempts(RECENT_ATTEMPTS_SHOWN) + .expect("read"); + assert_eq!( + recent.iter().map(|a| a.started_at_unix).collect::>(), + vec![7, 6, 5, 4, 3] + ); + assert!(store.recent_remit_attempts(0).expect("read").is_empty()); + let mut out = Vec::new(); + print_recent_attempts(&store, &mut out).expect("print"); + let text = String::from_utf8(out).expect("utf8"); + assert!( + text.starts_with("Recent attempts (newest first, last 5):\n unix 7: automatic (after collect) attempt saw 3 sats unremitted — FAILED: failure 7\n"), + "{text}" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // ---- retry pacing (addendum 2, gate 2d) ---------------------------------------------------- + + fn failed(error: &str) -> RemitReport { + RemitReport { + outcome: Err(error.to_owned()), + lines: Vec::new(), + } + } + + fn paid(net_sats: u64) -> RemitReport { + RemitReport { + outcome: Ok(RemitOutcome::Paid { + remittance_id: "r".to_owned(), + net_sats, + melt_fee_sats: 1, + }), + lines: Vec::new(), + } + } + + fn refused(refusal: Refusal) -> RemitReport { + RemitReport { + outcome: Ok(RemitOutcome::Refused(refusal)), + lines: Vec::new(), + } + } + + // Gate 2d: consecutive failures GROW the delay from the base, doubling, and it is CAPPED; the + // jittered delay never exceeds the computed delay for that streak and never falls below zero; a + // success RESETS it to base. Driven on the pure computation — nothing here sleeps. + #[test] + fn retry_backoff_doubles_from_base_caps_at_thirty_minutes_and_resets_on_success() { + let mut pacing = RemitBackoff::new(); + assert_eq!(pacing.computed_delay(), Duration::from_secs(30)); + assert_eq!(RETRY_BASE, Duration::from_secs(30)); + assert_eq!(RETRY_CAP, Duration::from_secs(30 * 60)); + + assert_eq!( + pacing.observe(&failed("host down"), 1_000), + Pacing::FirstFailure + ); + let mut expected = vec![Duration::from_secs(30)]; + let mut seen_cap_transition = 0; + for attempt in 2..=16u32 { + let delay = pacing.computed_delay(); + expected.push(delay); + // Never above the computed delay, never below zero, for the extremes and the middle. + for entropy in [0, 1, u64::MAX / 3, u64::MAX / 2, u64::MAX - 1, u64::MAX] { + let slept = pacing_jitter_probe(&pacing, entropy); + assert!( + slept <= delay, + "streak {}: {slept:?} > {delay:?}", + pacing.streak() + ); + assert!(slept >= Duration::ZERO); + } + assert_eq!(pacing_jitter_probe(&pacing, u64::MAX), delay); + assert_eq!(pacing_jitter_probe(&pacing, 0), Duration::ZERO); + let drawn = pacing.next_delay(); + assert!( + drawn <= delay, + "the RNG draw stays under the computed delay" + ); + match pacing.observe(&failed("host down"), 1_000 + i64::from(attempt) * 60) { + Pacing::RepeatFailure { + streak, + entered_cap, + } => { + assert_eq!(streak, attempt); + if entered_cap { + seen_cap_transition += 1; + } + } + other => panic!("attempt {attempt}: expected a repeat failure, got {other:?}"), + } + } + // 30, 60, 120, 240, 480, 960, 1800 (cap), 1800, 1800, ... + assert_eq!( + expected + .iter() + .take(8) + .map(Duration::as_secs) + .collect::>(), + vec![30, 60, 120, 240, 480, 960, 1800, 1800] + ); + assert!( + expected.iter().all(|delay| *delay <= RETRY_CAP), + "the doubling is capped: {expected:?}" + ); + assert!( + expected.iter().skip(6).all(|delay| *delay == RETRY_CAP), + "once capped it stays capped: {expected:?}" + ); + assert_eq!( + seen_cap_transition, 1, + "the transition into the cap is reported exactly once" + ); + assert!(pacing.at_cap()); + + // A success resets to base and reports the streak it ended. + assert_eq!( + pacing.observe(&paid(10), 1_000 + 17 * 60), + Pacing::Recovered { + failed_attempts: 16, + owed_for_secs: 17 * 60, + } + ); + assert_eq!(pacing.streak(), 0); + assert_eq!(pacing.computed_delay(), RETRY_BASE); + assert!(!pacing.at_cap()); + // A success with no streak behind it is just a payment. + assert_eq!(pacing.observe(&paid(10), 2_000), Pacing::Paid); + assert_eq!(pacing.computed_delay(), RETRY_BASE); + } + + fn pacing_jitter_probe(pacing: &RemitBackoff, entropy: u64) -> Duration { + jittered(pacing.computed_delay(), entropy) + } + + // Rules 4 and 5: below the threshold is NOT a failure, and a zero balance is not one either — + // neither escalates the streak, neither resets it, and neither is logged as a failure. + #[test] + fn a_balance_under_the_threshold_or_at_zero_neither_escalates_nor_resets_the_backoff() { + let mut pacing = RemitBackoff::new(); + let below = refused(Refusal::BelowMinimum { + unremitted: 0, + min_sats: 1, + }); + let zero = refused(Refusal::NothingUnremitted); + assert!(!below.is_failure() && !zero.is_failure()); + assert!(below.is_quiet() && zero.is_quiet()); + + // Healthy node: idle at the base interval. + assert_eq!(pacing.observe(&below, 1), Pacing::Idle); + assert_eq!(pacing.observe(&zero, 2), Pacing::Idle); + assert_eq!(pacing.computed_delay(), RETRY_BASE); + assert_eq!(pacing.streak(), 0); + + // Mid-streak: the threshold outcomes leave the streak exactly where it was — they are not a + // success, so they do not reset it (rule 3: success and nothing else), and they are not a + // failure, so they do not lengthen it. + pacing.observe(&failed("a"), 10); + pacing.observe(&failed("b"), 11); + let before = pacing.clone(); + assert_eq!(pacing.observe(&below, 12), Pacing::Idle); + assert_eq!(pacing.observe(&zero, 13), Pacing::Idle); + assert_eq!(pacing, before); + assert_eq!(pacing.computed_delay(), Duration::from_secs(120)); + + // A refusal that is NOT at the threshold left the fee owed for a reason retrying every 30 s + // cannot fix: it paces like a failure. + let reserve = refused(Refusal::ReserveDoesNotFit { + gross: 2, + reserve: 2, + }); + assert!(reserve.is_failure()); + assert_eq!( + pacing.observe(&reserve, 14), + Pacing::RepeatFailure { + streak: 3, + entered_cap: false + } + ); + // A melt that failed after the plan is a failure too. + let melt_failed = RemitReport { + outcome: Ok(RemitOutcome::MeltFailed { + remittance_id: "r".to_owned(), + error: "mint timeout".to_owned(), + }), + lines: Vec::new(), + }; + assert!(melt_failed.is_failure()); + // A dry run is nothing to the pacing. + let dry = RemitReport { + outcome: Ok(RemitOutcome::DryRun), + lines: Vec::new(), + }; + assert!(!dry.is_failure()); + assert_eq!(pacing.observe(&dry, 15), Pacing::Idle); + } + + // Explicit bounds (for the loop test that cannot sleep 30 minutes): the cap clamps to the base, + // and the arithmetic is the same. + #[test] + fn retry_backoff_honours_explicit_bounds() { + let mut pacing = + RemitBackoff::with_bounds(Duration::from_millis(20), Duration::from_millis(50)); + assert_eq!(pacing.computed_delay(), Duration::from_millis(20)); + pacing.observe(&failed("x"), 0); + assert_eq!(pacing.computed_delay(), Duration::from_millis(40)); + pacing.observe(&failed("x"), 0); + assert_eq!(pacing.computed_delay(), Duration::from_millis(50)); + assert!(pacing.at_cap()); + let clamped = RemitBackoff::with_bounds(Duration::from_secs(5), Duration::from_secs(1)); + assert_eq!(clamped.computed_delay(), Duration::from_secs(5)); + assert!(clamped.at_cap()); + } + + // Addendum 3 RULING 1: the first attempt after boot fires no earlier than one base delay, with + // an additive jitter in [0, base] — [30 s, 60 s]. Zero is never a legal first delay. + #[test] + fn the_boot_delay_is_never_less_than_the_base_and_at_most_twice_it() { + assert_eq!(boot_delay_for(RETRY_BASE, 0), Duration::from_secs(30)); + assert_eq!( + boot_delay_for(RETRY_BASE, u64::MAX), + Duration::from_secs(60) + ); + let mid = boot_delay_for(RETRY_BASE, u64::MAX / 2); + assert!(mid > Duration::from_secs(44) && mid < Duration::from_secs(46)); + for entropy in [0, 1, u64::MAX / 3, u64::MAX / 2, u64::MAX - 1, u64::MAX] { + let delay = boot_delay_for(RETRY_BASE, entropy); + assert!(delay >= RETRY_BASE, "{delay:?} is below the base"); + assert!(delay <= RETRY_BASE * 2, "{delay:?} is above twice the base"); + } + assert_eq!(boot_delay_for(Duration::MAX, u64::MAX), Duration::MAX); + let pacing = RemitBackoff::new(); + for _ in 0..64 { + let drawn = pacing.boot_delay(); + assert!(drawn >= RETRY_BASE && drawn <= RETRY_BASE * 2, "{drawn:?}"); + } + // Explicit bounds: the boot delay follows the base the test set. + let short = + RemitBackoff::with_bounds(Duration::from_millis(25), Duration::from_millis(100)); + let drawn = short.boot_delay(); + assert!(drawn >= Duration::from_millis(25) && drawn <= Duration::from_millis(50)); + } + + // Full jitter is a uniform point in [0, computed]: the two extremes are exact, and a huge + // computed delay does not overflow. + #[test] + fn full_jitter_stays_inside_zero_to_computed() { + let computed = Duration::from_secs(1800); + assert_eq!(jittered(computed, 0), Duration::ZERO); + assert_eq!(jittered(computed, u64::MAX), computed); + let half = jittered(computed, u64::MAX / 2); + assert!(half > Duration::from_secs(899) && half < Duration::from_secs(901)); + assert!(jittered(Duration::MAX, u64::MAX) <= Duration::MAX); + assert_eq!(jittered(Duration::ZERO, u64::MAX), Duration::ZERO); + } + + // Addendum 2 §3: one attempt in flight, ever; a second taker skips (gets `None`) and does not + // block; the slot frees when the permit drops. + #[test] + fn single_flight_admits_one_attempt_and_frees_the_slot_on_drop() { + let flight = RemitFlight::new(); + assert!(!flight.in_flight()); + let permit = flight.try_acquire().expect("the slot starts free"); + assert!(flight.in_flight()); + assert!( + flight.try_acquire().is_none(), + "a second taker skips while the first holds the slot" + ); + let sibling = flight.clone(); + assert!( + sibling.try_acquire().is_none(), + "clones share the one slot — the loop and the collect thread see the same guard" + ); + drop(permit); + assert!(!flight.in_flight()); + let again = sibling.try_acquire(); + assert!( + again.is_some(), + "the slot is free again once the permit dropped" + ); + // A permit dropped by a panicking thread frees the slot too: Drop runs on unwind. + let flight_for_thread = flight.clone(); + drop(again); + let outcome = std::thread::spawn(move || { + let _permit = flight_for_thread.try_acquire().expect("free"); + panic!("attempt died"); + }) + .join(); + assert!(outcome.is_err()); + assert!(!flight.in_flight(), "the slot is not leaked by a panic"); + } +} diff --git a/crates/maxplayer-core/src/home.rs b/crates/maxplayer-core/src/home.rs index b88b19040..5194ba79a 100644 --- a/crates/maxplayer-core/src/home.rs +++ b/crates/maxplayer-core/src/home.rs @@ -42,6 +42,7 @@ //! | `telemetry.mirror_file` | `MAXPLAYER_TELEMETRY__MIRROR_FILE` | //! | `seller_heartbeat.interval_secs` | `MAXPLAYER_SELLER_HEARTBEAT__INTERVAL_SECS` | //! | `seller_preflight.boot_push_preflight` | `MAXPLAYER_SELLER_PREFLIGHT__BOOT_PUSH_PREFLIGHT` | +//! | `platform_fee.auto_remit` | `MAXPLAYER_PLATFORM_FEE__AUTO_REMIT` | //! | `buyer.hop_fee_buffer_multiplier` | `MAXPLAYER_BUYER__HOP_FEE_BUFFER_MULTIPLIER` | //! | `contribution.allowed_paths` (list) | `MAXPLAYER_CONTRIBUTION__ALLOWED_PATHS=…` | //! @@ -1182,6 +1183,65 @@ pub fn default_boot_push_preflight() -> bool { true } +/// `[platform_fee]` — the ONE operational switch on the seller node's automatic platform fee +/// remittance (seller fee stage 2a, addendum 1). Top-level rather than inside `[seller]` for the +/// same structural reason as [`SellerPreflightConfig`]: a serde default on a nested table reaches +/// every existing `config.toml` without a rewrite. +/// +/// ## What this is +/// +/// An **operational safety valve**. Money leaves the seller's wallet with no human in the loop once +/// a payment is collected (see `seller_node::run` and `fee_remit`), so an operator needs a way to +/// stop those outbound payments without patching a binary — a mint that is misbehaving, a payout +/// host that is down, an incident. `auto_remit = false` (or `MAXPLAYER_PLATFORM_FEE__AUTO_REMIT=false`) +/// does exactly one thing: the node stops ATTEMPTING a remittance — on BOTH of its paths, the +/// collect path's attempt and the run loop's retry tick (stage 2a, addendum 2). One flag, both paths. +/// +/// ## What this is NOT +/// +/// - **Not an authorization boundary.** The fee is owed whether or not this is on; the switch does not +/// forgive it. Accrual is untouched: every collected payment still journals its platform fee, the +/// unremitted balance keeps growing and stays visible in `maxplayer seller fees`, and the next +/// remittance — automatic once the switch is back on, or `maxplayer seller fees remit --confirm` +/// run by hand — pays the whole accumulated balance. +/// - **Not a way to change where or how much.** It cannot touch the destination +/// ([`crate::platform_fee::PLATFORM_FEE_ADDRESS`]) or the rate +/// ([`crate::platform_fee::PLATFORM_FEE_BPS`]); both are compiled in, and this table +/// (`deny_unknown_fields`) has no key for either. +/// - **Not a gate on the manual command.** `maxplayer seller fees remit --confirm` is the operator's +/// recovery path and pays regardless of this switch; the switch governs the automatic attempt only. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlatformFeeConfig { + /// Attempt to remit the accrued platform fee automatically — after each collected payment, and + /// on the node's retry tick while a remittance is failing. Default **true**. Set false (or the + /// env override `MAXPLAYER_PLATFORM_FEE__AUTO_REMIT=false`) to stop both automatic attempts; the + /// fee keeps accruing and the balance stays owed and visible. + #[serde(default = "default_auto_remit")] + pub auto_remit: bool, +} + +impl Default for PlatformFeeConfig { + fn default() -> Self { + Self { + auto_remit: default_auto_remit(), + } + } +} + +impl PlatformFeeConfig { + /// True when every field is at its shipped default (so config.toml stays clean — the section is + /// only serialized once an operator sets a non-default knob). + fn is_default(&self) -> bool { + *self == Self::default() + } +} + +/// serde default for [`PlatformFeeConfig::auto_remit`] — automatic remittance ON. +pub fn default_auto_remit() -> bool { + true +} + impl SellerMemoryConfig { /// True when every field is at its shipped default (so config.toml stays clean — the section /// is only serialized once an operator sets a non-default knob). @@ -1470,6 +1530,10 @@ pub struct MaxplayerConfig { /// `[seller_preflight]` boot push-probe config. Defaults (probe ON) when absent. #[serde(default, skip_serializing_if = "SellerPreflightConfig::is_default")] pub seller_preflight: SellerPreflightConfig, + /// `[platform_fee]` — the off switch on the seller node's automatic platform fee remittance. + /// Defaults (automatic remittance ON) when absent. See [`PlatformFeeConfig`] for what it is not. + #[serde(default, skip_serializing_if = "PlatformFeeConfig::is_default")] + pub platform_fee: PlatformFeeConfig, /// `[buyer_reservation_floor]` local-clock release of an unattempted reservation. /// Defaults (feature OFF) when absent. #[serde(default, skip_serializing_if = "BuyerReservationFloorConfig::is_default")] @@ -1622,6 +1686,7 @@ impl Default for MaxplayerConfig { telemetry: TelemetryConfig::default(), seller_heartbeat: SellerHeartbeatConfig::default(), seller_preflight: SellerPreflightConfig::default(), + platform_fee: PlatformFeeConfig::default(), buyer_reservation_floor: BuyerReservationFloorConfig::default(), buyer: BuyerConfig::default(), contribution: None, @@ -2053,6 +2118,15 @@ fn documented_config_toml(config: &MaxplayerConfig) -> Result "Set false to force testnut/dev-only (any real mint is then refused fail-closed).", ], ), + ( + "auto_remit", + &[ + "Seller platform fee auto-remittance: after each collected payment the node pays the", + "accrued platform fee to the platform's fixed Lightning address. Set false to STOP the", + "automatic attempt — an operational valve, not a waiver: the fee keeps accruing and stays", + "owed (`maxplayer seller fees remit --confirm` pays it by hand). Cannot change rate/address.", + ], + ), ]; let body = @@ -2724,6 +2798,76 @@ mod tests { ); } + // Seller fee stage 2a, addendum 1 §4: the automatic remittance's off switch. ON by default and + // absent from a clean config; `false` in the file or via env turns the automatic attempt off; the + // table has no key for the destination or the rate, so neither can be moved through it. + #[test] + fn platform_fee_auto_remit_defaults_on_and_the_switch_cannot_touch_rate_or_address() { + assert!(default_auto_remit()); + let defaults = MaxplayerConfig::default(); + assert!( + defaults.platform_fee.auto_remit, + "automatic remittance is ON by default" + ); + assert!( + !toml::to_string_pretty(&defaults) + .expect("ser") + .contains("[platform_fee]"), + "a default config carries no [platform_fee] section" + ); + + let absent = parse_config_toml("relay_url = 'r'\nper_job_budget_sats = 1\n") + .expect("absent [platform_fee] parses"); + assert!( + absent.platform_fee.auto_remit, + "absent ⇒ ON: existing homes keep remitting" + ); + + let off = parse_config_toml( + "relay_url = 'r'\nper_job_budget_sats = 1\n[platform_fee]\nauto_remit = false\n", + ) + .expect("explicit off parses"); + assert!(!off.platform_fee.auto_remit); + assert!( + toml::to_string_pretty(&off) + .expect("ser") + .contains("[platform_fee]\nauto_remit = false"), + "a non-default switch is serialized so it survives a rewrite" + ); + + let via_env = apply_env_layer( + &MaxplayerConfig::default(), + env(&[("MAXPLAYER_PLATFORM_FEE__AUTO_REMIT", "false")]), + ) + .expect("env override parses"); + assert!( + !via_env.platform_fee.auto_remit, + "the env override turns it off" + ); + let back_on = apply_env_layer(&off, env(&[("MAXPLAYER_PLATFORM_FEE__AUTO_REMIT", "true")])) + .expect("env override parses"); + assert!( + back_on.platform_fee.auto_remit, + "and back on over a file that says off" + ); + + // No key for the destination or the rate: the switch cannot be turned into a redirect. + for key in ["address", "destination", "fee_bps", "rate"] { + let refused = parse_config_toml(&format!( + "relay_url = 'r'\nper_job_budget_sats = 1\n[platform_fee]\n{key} = 1\n" + )); + assert!( + refused.is_err(), + "[platform_fee] {key} must be refused (deny_unknown_fields)" + ); + } + let malformed = apply_env_layer( + &MaxplayerConfig::default(), + env(&[("MAXPLAYER_PLATFORM_FEE__AUTO_REMIT", "sometimes")]), + ); + assert!(malformed.is_err(), "a non-boolean refuses, never defaults"); + } + #[test] fn shipped_defaults_are_real_money_and_the_fence_admits_them() { // #378 flipped fresh nodes real-money-capable. The whole default posture in one place; the diff --git a/crates/maxplayer-core/src/lib.rs b/crates/maxplayer-core/src/lib.rs index 33b473b35..a8d78d388 100644 --- a/crates/maxplayer-core/src/lib.rs +++ b/crates/maxplayer-core/src/lib.rs @@ -39,11 +39,21 @@ pub mod engine; pub mod env_provision; pub mod episode; pub mod event; +/// Paying the accrued platform fee: the ONE remit path in the product, behind an effects trait so +/// the decision logic is tested without a network or a mint. Three callers — the seller node's +/// collect path (automatic, best-effort), the seller node's retry tick (automatic, backed off), and +/// `maxplayer seller fees remit` (inspection and recovery). +#[cfg(feature = "wallet")] +pub mod fee_remit; pub mod format; pub mod gateway; pub mod heartbeat; pub mod home; pub mod kinds; +/// LNURL-pay (LUD-06/LUD-16) resolution of a Lightning address to a bolt11 invoice, fail-closed. +/// Used by [`fee_remit`] only; `wallet`-gated because it rides the `reqwest` client. +#[cfg(feature = "wallet")] +pub mod lnurl_pay; pub mod log; // Ungated on purpose: the CLI's MCP tool table reads the long-poll cap from here on a build with // no `wallet` feature, where `job_lifecycle` is compiled out. @@ -64,9 +74,10 @@ pub mod payment; pub mod payment_send; #[cfg(feature = "wallet")] pub mod payment_wallet; -/// Seller-side platform fee (stage 1): the product-set rate in basis points and the fee arithmetic. -/// Ungated so the arithmetic builds and tests everywhere; accrued and journaled at collect, -/// remitted nowhere. +/// Seller-side platform fee: the product-set rate in basis points, the fee arithmetic, and the +/// product-set payout address. Ungated so the arithmetic builds and tests everywhere; accrued and +/// journaled at collect, remitted by [`fee_remit`] — automatically after each collect, or by +/// `maxplayer seller fees remit --confirm`. pub mod platform_fee; pub mod receipt; /// Shared NIP-42 relay-auth handshake, neutral to any single consumer (seller receive + buyer diff --git a/crates/maxplayer-core/src/lnurl_pay.rs b/crates/maxplayer-core/src/lnurl_pay.rs new file mode 100644 index 000000000..9d5c2a3b8 --- /dev/null +++ b/crates/maxplayer-core/src/lnurl_pay.rs @@ -0,0 +1,1287 @@ +//! LNURL-pay resolution of a Lightning address to a bolt11 invoice — LUD-16 (`user@host`) over +//! LUD-06 (payRequest), **fail-closed at every step**. +//! +//! The flow, and the single place each of its hazards is refused: +//! +//! 1. `user@host` → `GET https://host/.well-known/lnurlp/user` ([`LightningAddress::well_known_url`]). +//! The address is validated before any URL is built ([`LightningAddress::parse`]). +//! 2. The body must be a 200 with a JSON object tagged `payRequest`, an absolute **https** `callback` +//! on the **same host** as the address, and integer `minSendable`/`maxSendable` in millisats +//! ([`parse_pay_request`]). Anything else is a typed [`LnurlError`], never a default. +//! 3. `GET callback?amount=` — only for an amount inside the advertised bounds +//! ([`PayRequest::invoice_url`]). +//! 4. The body must carry `pr`, a bolt11 that decodes, is not expired, and whose amount **equals** the +//! millisats requested ([`parse_invoice_response`]). The decoded payment hash is returned so the +//! caller can journal it. +//! +//! No `http://` is accepted anywhere: the well-known URL is built `https://`, the callback must be +//! `https://`, and the shipped fetcher ([`HttpsFetch`]) refuses non-https URLs and follows no +//! redirects (a 3xx is a non-200 and is refused). Every network read goes through the [`LnurlFetch`] +//! trait so the parsing and the refusals are tested without a network. +//! +//! **Units.** The wire is millisats; the ledger is sats. The conversion lives in exactly three named +//! functions — [`sats_to_msat`], [`msat_to_sats_ceil`], [`msat_to_sats_floor`] — and nowhere else. +//! A minimum rounds UP to sats (1500 msat means you need 2 whole sats), a maximum rounds DOWN. +//! +//! This module moves no money. It produces an invoice; paying it is the caller's act +//! ([`crate::fee_remit`], through `wallet_ops::prepare_melt_payment_blocking` → +//! `PreparedMeltPayment::confirm` under a total ceiling — paying the one quote its store fence +//! bound, never a fresh one; `wallet_ops::pay_melt_quote_blocking` is retained but no longer called +//! on that path). + +use std::fmt; +use std::str::FromStr; +use std::time::Duration; + +use cdk::Bolt11Invoice; +/// The URL type the flow is expressed in (reqwest's re-export of `url::Url`), re-exported so a +/// caller can name a [`PayRequest::callback`] without depending on `reqwest` directly. +pub use reqwest::Url; + +/// Millisats in one sat — the one conversion constant. +pub const MSAT_PER_SAT: u64 = 1_000; + +/// How long one LNURL HTTP round trip may take before it is refused as a transport failure. +const HTTP_TIMEOUT: Duration = Duration::from_secs(20); + +/// Largest response body accepted from an LNURL server. A payRequest is a few hundred bytes; a +/// bolt11 response is under 2 KiB. Anything approaching this is not an LNURL answer. +const MAX_BODY_BYTES: usize = 64 * 1024; + +/// A LUD-16 Lightning address, `user@host`, validated on construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LightningAddress { + user: String, + host: String, +} + +impl LightningAddress { + /// Parse and validate `user@host`. Refuses anything that is not exactly one `@`, a non-empty + /// user of `[A-Za-z0-9._-]`, and a non-empty hostname of `[A-Za-z0-9.-]` with no empty labels. + /// The host is lower-cased (DNS is case-insensitive); the user is kept as written (LUD-16 says + /// lowercase, and servers may be strict). + pub fn parse(raw: &str) -> Result { + let invalid = |reason: &'static str| LnurlError::InvalidAddress { + address: raw.to_owned(), + reason, + }; + let (user, host) = raw.split_once('@').ok_or_else(|| invalid("no @"))?; + if host.contains('@') { + return Err(invalid("more than one @")); + } + if user.is_empty() { + return Err(invalid("empty user")); + } + if !user + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return Err(invalid("user has a character outside [A-Za-z0-9._-]")); + } + if host.is_empty() { + return Err(invalid("empty host")); + } + if !host + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-')) + { + return Err(invalid("host has a character outside [A-Za-z0-9.-]")); + } + if host.split('.').any(str::is_empty) { + return Err(invalid("host has an empty label")); + } + Ok(Self { + user: user.to_owned(), + host: host.to_ascii_lowercase(), + }) + } + + pub fn user(&self) -> &str { + &self.user + } + + pub fn host(&self) -> &str { + &self.host + } + + /// `https://host/.well-known/lnurlp/user` — always https, never anything else. + pub fn well_known_url(&self) -> Url { + Url::parse(&format!( + "https://{}/.well-known/lnurlp/{}", + self.host, self.user + )) + .expect("a validated address builds a valid https URL") + } +} + +impl fmt::Display for LightningAddress { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}@{}", self.user, self.host) + } +} + +/// Every way the resolution refuses. One variant per hazard so a caller (and a test) can name which +/// gate closed; none of them is recoverable by retrying with a different default. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LnurlError { + InvalidAddress { + address: String, + reason: &'static str, + }, + /// A URL in the flow is not `https://`. Checked before every fetch, independent of the fetcher. + InsecureScheme { + url: String, + }, + /// The HTTP round trip itself failed (DNS, TLS, timeout, connection). + Transport { + url: String, + detail: String, + }, + /// Not a 200. A redirect is a 3xx here — redirects are never followed. + HttpStatus { + url: String, + status: u16, + }, + /// The body is not a JSON object. + NotJson { + url: String, + detail: String, + }, + /// The server answered `{"status":"ERROR","reason":...}`. + ServiceError { + reason: String, + }, + /// `tag` missing or not `payRequest`. + WrongTag { + found: Option, + }, + CallbackMissing, + CallbackNotAbsolute { + callback: String, + }, + CallbackNotHttps { + callback: String, + }, + /// The callback names a host other than the address's domain. Paying it would send the fee + /// wherever a compromised or misconfigured well-known endpoint pointed. + CallbackHostMismatch { + expected: String, + found: String, + }, + /// The callback carries userinfo (`https://u:p@host/...`). Never legitimate here. + CallbackHasCredentials { + callback: String, + }, + /// A millisat field is absent, or is not a non-negative JSON integer: strings, floats, exponent + /// notation, negatives and out-of-range values are all refused, never prefix-parsed. + AmountNotInteger { + field: &'static str, + found: String, + }, + /// `minSendable` is zero (LUD-06 requires `> 0`) or exceeds `maxSendable`. + BoundsInvalid { + min_msat: u64, + max_msat: u64, + }, + AmountBelowMin { + requested_msat: u64, + min_msat: u64, + }, + AmountAboveMax { + requested_msat: u64, + max_msat: u64, + }, + /// `sats × 1000` does not fit a `u64`. + AmountOverflow { + sats: u64, + }, + InvoiceMissing, + InvoiceUndecodable { + detail: String, + }, + InvoiceExpired, + /// The bolt11's amount is absent or differs from what was requested. Paying it would pay a + /// figure the server chose, not the one the ledger owes. + InvoiceAmountMismatch { + expected_msat: u64, + found_msat: Option, + }, +} + +impl fmt::Display for LnurlError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidAddress { address, reason } => { + write!( + formatter, + "lnurl: invalid lightning address {address:?}: {reason}" + ) + } + Self::InsecureScheme { url } => { + write!(formatter, "lnurl: refusing non-https URL {url}") + } + Self::Transport { url, detail } => { + write!(formatter, "lnurl: GET {url} failed: {detail}") + } + Self::HttpStatus { url, status } => { + write!( + formatter, + "lnurl: GET {url} returned HTTP {status} (need 200)" + ) + } + Self::NotJson { url, detail } => { + write!( + formatter, + "lnurl: GET {url} body is not a JSON object: {detail}" + ) + } + Self::ServiceError { reason } => { + write!(formatter, "lnurl: service reported an error: {reason}") + } + Self::WrongTag { found: Some(tag) } => { + write!(formatter, "lnurl: tag is {tag:?}, need \"payRequest\"") + } + Self::WrongTag { found: None } => write!(formatter, "lnurl: tag missing"), + Self::CallbackMissing => write!(formatter, "lnurl: callback missing"), + Self::CallbackNotAbsolute { callback } => { + write!( + formatter, + "lnurl: callback is not an absolute URL: {callback:?}" + ) + } + Self::CallbackNotHttps { callback } => { + write!(formatter, "lnurl: callback is not https: {callback}") + } + Self::CallbackHostMismatch { expected, found } => write!( + formatter, + "lnurl: callback host {found} is not the address host {expected}; refusing" + ), + Self::CallbackHasCredentials { callback } => { + write!(formatter, "lnurl: callback carries credentials: {callback}") + } + Self::AmountNotInteger { field, found } => write!( + formatter, + "lnurl: {field} is not a non-negative JSON integer (millisats): {found}" + ), + Self::BoundsInvalid { min_msat, max_msat } => write!( + formatter, + "lnurl: sendable bounds invalid: minSendable={min_msat} maxSendable={max_msat} msat" + ), + Self::AmountBelowMin { + requested_msat, + min_msat, + } => write!( + formatter, + "lnurl: {requested_msat} msat is below minSendable {min_msat} msat" + ), + Self::AmountAboveMax { + requested_msat, + max_msat, + } => write!( + formatter, + "lnurl: {requested_msat} msat is above maxSendable {max_msat} msat" + ), + Self::AmountOverflow { sats } => { + write!(formatter, "lnurl: {sats} sats overflows millisats") + } + Self::InvoiceMissing => write!(formatter, "lnurl: response has no pr (bolt11)"), + Self::InvoiceUndecodable { detail } => { + write!(formatter, "lnurl: pr is not a decodable bolt11: {detail}") + } + Self::InvoiceExpired => { + write!(formatter, "lnurl: the returned bolt11 is already expired") + } + Self::InvoiceAmountMismatch { + expected_msat, + found_msat: Some(found), + } => write!( + formatter, + "lnurl: bolt11 amount {found} msat != requested {expected_msat} msat; refusing" + ), + Self::InvoiceAmountMismatch { + expected_msat, + found_msat: None, + } => write!( + formatter, + "lnurl: bolt11 carries no amount (requested {expected_msat} msat); refusing" + ), + } + } +} + +impl std::error::Error for LnurlError {} + +/// A validated LUD-06 payRequest: where to ask for an invoice and the bounds it will honour. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PayRequest { + pub callback: Url, + pub min_sendable_msat: u64, + pub max_sendable_msat: u64, +} + +impl PayRequest { + /// The smallest whole-sat amount the service accepts (millisats rounded UP). + pub fn min_sendable_sats(&self) -> u64 { + msat_to_sats_ceil(self.min_sendable_msat) + } + + /// The largest whole-sat amount the service accepts (millisats rounded DOWN). + pub fn max_sendable_sats(&self) -> u64 { + msat_to_sats_floor(self.max_sendable_msat) + } + + /// `callback?amount=` for `amount_sats`, refused if the amount is outside the + /// advertised bounds or the callback stopped being https. + pub fn invoice_url(&self, amount_sats: u64) -> Result { + let requested_msat = sats_to_msat(amount_sats)?; + if requested_msat < self.min_sendable_msat { + return Err(LnurlError::AmountBelowMin { + requested_msat, + min_msat: self.min_sendable_msat, + }); + } + if requested_msat > self.max_sendable_msat { + return Err(LnurlError::AmountAboveMax { + requested_msat, + max_msat: self.max_sendable_msat, + }); + } + let mut url = self.callback.clone(); + url.query_pairs_mut() + .append_pair("amount", &requested_msat.to_string()); + require_https(&url)?; + Ok(url) + } +} + +/// A bolt11 the service issued for exactly the amount asked, with the figures the ledger journals. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedInvoice { + pub bolt11: String, + /// Hex of the invoice's payment hash — the remittance's idempotency key. + pub payment_hash: String, + pub amount_sats: u64, + pub amount_msat: u64, +} + +/// Sats → millisats, refusing overflow. +pub fn sats_to_msat(sats: u64) -> Result { + sats.checked_mul(MSAT_PER_SAT) + .ok_or(LnurlError::AmountOverflow { sats }) +} + +/// Millisats → sats, rounding UP: the right direction for a minimum (1 msat above a whole sat +/// means the next whole sat is the least you can send). +pub fn msat_to_sats_ceil(msat: u64) -> u64 { + msat.div_ceil(MSAT_PER_SAT) +} + +/// Millisats → sats, rounding DOWN: the right direction for a maximum. +pub fn msat_to_sats_floor(msat: u64) -> u64 { + msat / MSAT_PER_SAT +} + +fn require_https(url: &Url) -> Result<(), LnurlError> { + if url.scheme() != "https" { + return Err(LnurlError::InsecureScheme { + url: url.to_string(), + }); + } + Ok(()) +} + +fn json_object( + url: &Url, + body: &[u8], +) -> Result, LnurlError> { + let value: serde_json::Value = + serde_json::from_slice(body).map_err(|error| LnurlError::NotJson { + url: url.to_string(), + detail: error.to_string(), + })?; + match value { + serde_json::Value::Object(map) => Ok(map), + other => Err(LnurlError::NotJson { + url: url.to_string(), + detail: format!("top-level JSON is {}", json_kind(&other)), + }), + } +} + +fn json_kind(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "a boolean", + serde_json::Value::Number(_) => "a number", + serde_json::Value::String(_) => "a string", + serde_json::Value::Array(_) => "an array", + serde_json::Value::Object(_) => "an object", + } +} + +/// `{"status":"ERROR","reason":"..."}` is how LNURL services report failure (LUD-06). Surface it as +/// its own refusal rather than a confusing "tag missing". +fn refuse_service_error( + map: &serde_json::Map, +) -> Result<(), LnurlError> { + if map.get("status").and_then(serde_json::Value::as_str) == Some("ERROR") { + return Err(LnurlError::ServiceError { + reason: map + .get("reason") + .and_then(serde_json::Value::as_str) + .unwrap_or("(no reason given)") + .to_owned(), + }); + } + Ok(()) +} + +/// A millisat field must be a JSON **number** that is a non-negative integer. `serde_json` yields +/// `as_u64() == None` for floats (`1000.0`), exponents (`1e3`), negatives and anything past +/// `u64::MAX`; strings (`"1000"`, `"0junk"`, `" 1000"`), hex and leading-zero forms are either not +/// numbers or not valid JSON at all. Nothing here is prefix-parsed. +fn msat_field( + map: &serde_json::Map, + field: &'static str, +) -> Result { + let value = map.get(field).ok_or(LnurlError::AmountNotInteger { + field, + found: "(missing)".to_owned(), + })?; + match value { + serde_json::Value::Number(number) => { + number.as_u64().ok_or_else(|| LnurlError::AmountNotInteger { + field, + found: number.to_string(), + }) + } + other => Err(LnurlError::AmountNotInteger { + field, + found: other.to_string(), + }), + } +} + +/// Validate a well-known payRequest body against the address it was fetched for. +pub fn parse_pay_request( + body: &[u8], + address: &LightningAddress, +) -> Result { + let url = address.well_known_url(); + let map = json_object(&url, body)?; + refuse_service_error(&map)?; + match map.get("tag") { + Some(serde_json::Value::String(tag)) if tag == "payRequest" => {} + Some(other) => { + return Err(LnurlError::WrongTag { + found: Some(other.to_string()), + }); + } + None => return Err(LnurlError::WrongTag { found: None }), + } + let callback_raw = match map.get("callback") { + Some(serde_json::Value::String(callback)) => callback.as_str(), + Some(other) => { + return Err(LnurlError::CallbackNotAbsolute { + callback: other.to_string(), + }); + } + None => return Err(LnurlError::CallbackMissing), + }; + let callback = Url::parse(callback_raw).map_err(|_| LnurlError::CallbackNotAbsolute { + callback: callback_raw.to_owned(), + })?; + if callback.cannot_be_a_base() || callback.host_str().is_none() { + return Err(LnurlError::CallbackNotAbsolute { + callback: callback_raw.to_owned(), + }); + } + if callback.scheme() != "https" { + return Err(LnurlError::CallbackNotHttps { + callback: callback_raw.to_owned(), + }); + } + if !callback.username().is_empty() || callback.password().is_some() { + return Err(LnurlError::CallbackHasCredentials { + callback: callback_raw.to_owned(), + }); + } + let callback_host = callback + .host_str() + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if callback_host != address.host() { + return Err(LnurlError::CallbackHostMismatch { + expected: address.host().to_owned(), + found: callback_host, + }); + } + let min_sendable_msat = msat_field(&map, "minSendable")?; + let max_sendable_msat = msat_field(&map, "maxSendable")?; + if min_sendable_msat == 0 || min_sendable_msat > max_sendable_msat { + return Err(LnurlError::BoundsInvalid { + min_msat: min_sendable_msat, + max_msat: max_sendable_msat, + }); + } + Ok(PayRequest { + callback, + min_sendable_msat, + max_sendable_msat, + }) +} + +/// Validate a callback response: `pr` present, a decodable, unexpired bolt11 whose amount equals +/// `expected_msat`. Returns the invoice with its payment hash. +pub fn parse_invoice_response( + callback_url: &Url, + body: &[u8], + expected_msat: u64, +) -> Result { + let map = json_object(callback_url, body)?; + refuse_service_error(&map)?; + let pr = match map.get("pr") { + Some(serde_json::Value::String(pr)) if !pr.trim().is_empty() => pr.trim().to_owned(), + _ => return Err(LnurlError::InvoiceMissing), + }; + let invoice = Bolt11Invoice::from_str(&pr).map_err(|error| LnurlError::InvoiceUndecodable { + detail: error.to_string(), + })?; + if invoice.is_expired() { + return Err(LnurlError::InvoiceExpired); + } + let found_msat = invoice.amount_milli_satoshis(); + if found_msat != Some(expected_msat) { + return Err(LnurlError::InvoiceAmountMismatch { + expected_msat, + found_msat, + }); + } + Ok(ResolvedInvoice { + bolt11: pr, + payment_hash: invoice.payment_hash().to_string(), + amount_sats: msat_to_sats_floor(expected_msat), + amount_msat: expected_msat, + }) +} + +/// One HTTP GET, abstracted so the flow is testable offline. Implementations MUST NOT follow +/// redirects and MUST refuse non-https URLs; [`fetch_pay_request`] / [`request_invoice`] check the +/// scheme again before calling, so a permissive implementation still cannot reach `http://`. +pub trait LnurlFetch { + fn get(&self, url: &Url) -> Result<(u16, Vec), LnurlError>; +} + +/// The shipped fetcher: reqwest, https-only, no redirects, bounded timeout and body. +pub struct HttpsFetch { + client: reqwest::blocking::Client, +} + +impl HttpsFetch { + pub fn new() -> Result { + let client = reqwest::blocking::Client::builder() + .https_only(true) + .redirect(reqwest::redirect::Policy::none()) + .timeout(HTTP_TIMEOUT) + .user_agent("maxplayer-seller-fees-remit") + .build() + .map_err(|error| LnurlError::Transport { + url: String::new(), + detail: format!("build client: {error}"), + })?; + Ok(Self { client }) + } +} + +impl LnurlFetch for HttpsFetch { + fn get(&self, url: &Url) -> Result<(u16, Vec), LnurlError> { + require_https(url)?; + let response = self + .client + .get(url.clone()) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .map_err(|error| LnurlError::Transport { + url: url.to_string(), + detail: error.to_string(), + })?; + let status = response.status().as_u16(); + let body = response.bytes().map_err(|error| LnurlError::Transport { + url: url.to_string(), + detail: error.to_string(), + })?; + if body.len() > MAX_BODY_BYTES { + return Err(LnurlError::Transport { + url: url.to_string(), + detail: format!("body of {} bytes exceeds {MAX_BODY_BYTES}", body.len()), + }); + } + Ok((status, body.to_vec())) + } +} + +fn get_ok(fetch: &dyn LnurlFetch, url: &Url) -> Result, LnurlError> { + require_https(url)?; + let (status, body) = fetch.get(url)?; + if status != 200 { + return Err(LnurlError::HttpStatus { + url: url.to_string(), + status, + }); + } + Ok(body) +} + +/// Step 1–2: fetch and validate the address's payRequest. +pub fn fetch_pay_request( + fetch: &dyn LnurlFetch, + address: &LightningAddress, +) -> Result { + let body = get_ok(fetch, &address.well_known_url())?; + parse_pay_request(&body, address) +} + +/// Step 3–4: ask the callback for an invoice of exactly `amount_sats` and validate what came back. +pub fn request_invoice( + fetch: &dyn LnurlFetch, + pay: &PayRequest, + amount_sats: u64, +) -> Result { + let url = pay.invoice_url(amount_sats)?; + let body = get_ok(fetch, &url)?; + parse_invoice_response(&url, &body, sats_to_msat(amount_sats)?) +} + +#[cfg(test)] +pub(crate) mod test_support { + //! A signed bolt11 for tests: real encoding, real signature, chosen amount and expiry. + + use cdk::lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret}; + use cdk::secp256k1::hashes::{Hash, sha256}; + use cdk::secp256k1::{Secp256k1, SecretKey}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + /// A bolt11 for `amount_msat` (or amountless when `None`) timestamped `now - age`, expiring an + /// hour after its timestamp. Its payment hash is the sha256 of `seed`. + pub(crate) fn signed_bolt11(amount_msat: Option, seed: &[u8], age: Duration) -> String { + let key = SecretKey::from_slice(&[0x42; 32]).expect("32 bytes is a key"); + let payment_hash = sha256::Hash::hash(seed); + let mut builder = InvoiceBuilder::new(Currency::Bitcoin) + .description("platform fee remittance (test)".into()) + .payment_hash(payment_hash) + .payment_secret(PaymentSecret([7u8; 32])) + .timestamp(SystemTime::now() - age) + .min_final_cltv_expiry_delta(144) + .expiry_time(Duration::from_secs(3600)); + if let Some(msat) = amount_msat { + builder = builder.amount_milli_satoshis(msat); + } + builder + .build_signed(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &key)) + .expect("a well-formed invoice signs") + .to_string() + } + + pub(crate) fn payment_hash_hex(seed: &[u8]) -> String { + sha256::Hash::hash(seed).to_string() + } + + #[allow(dead_code)] + pub(crate) fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_secs() + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{payment_hash_hex, signed_bolt11}; + use super::*; + use std::cell::RefCell; + + fn address() -> LightningAddress { + LightningAddress::parse("maxplayer@agi.cash").expect("valid") + } + + fn pay_request_json(callback: &str, min: &str, max: &str) -> Vec { + format!( + r#"{{"tag":"payRequest","callback":{callback},"minSendable":{min},"maxSendable":{max},"metadata":"[]"}}"# + ) + .into_bytes() + } + + fn good_pay_request() -> PayRequest { + parse_pay_request( + &pay_request_json( + r#""https://agi.cash/lnurlp/maxplayer/callback""#, + "1000", + "1000000000", + ), + &address(), + ) + .expect("the measured agi.cash answer parses") + } + + // ---- the address ---- + + #[test] + fn address_parses_user_at_host_and_builds_the_https_well_known_url() { + let address = address(); + assert_eq!((address.user(), address.host()), ("maxplayer", "agi.cash")); + assert_eq!( + address.well_known_url().as_str(), + "https://agi.cash/.well-known/lnurlp/maxplayer" + ); + assert_eq!(address.to_string(), "maxplayer@agi.cash"); + // Host is case-insensitive and lower-cased; user is kept. + let mixed = LightningAddress::parse("Max.player_1@AGI.Cash").expect("valid"); + assert_eq!((mixed.user(), mixed.host()), ("Max.player_1", "agi.cash")); + } + + #[test] + fn address_refuses_every_malformed_shape() { + for bad in [ + "", + "maxplayer", + "@agi.cash", + "maxplayer@", + "max@player@agi.cash", + "max player@agi.cash", + "maxplayer@agi cash", + "maxplayer@agi..cash", + "maxplayer@.agi.cash", + "maxplayer@agi.cash/", + "maxplayer@agi.cash:443", + "maxplayer@agi.cash?x=1", + "max/player@agi.cash", + "https://agi.cash/.well-known/lnurlp/maxplayer", + ] { + assert!( + matches!( + LightningAddress::parse(bad), + Err(LnurlError::InvalidAddress { .. }) + ), + "{bad:?} must be refused" + ); + } + } + + // ---- units: one named place, boundaries tested ---- + + #[test] + fn millisat_conversions_round_the_right_way_at_the_boundaries() { + assert_eq!(sats_to_msat(0).unwrap(), 0); + assert_eq!(sats_to_msat(1).unwrap(), 1000); + assert_eq!(sats_to_msat(1_000_000).unwrap(), 1_000_000_000); + assert_eq!( + sats_to_msat(u64::MAX), + Err(LnurlError::AmountOverflow { sats: u64::MAX }) + ); + assert_eq!( + sats_to_msat(u64::MAX / 1000).unwrap(), + (u64::MAX / 1000) * 1000 + ); + assert!(sats_to_msat(u64::MAX / 1000 + 1).is_err()); + + // A minimum rounds UP: 1000 msat is 1 sat; 1001 msat means 2 whole sats are the least you + // can send; 999 msat means 1 sat clears it. + assert_eq!(msat_to_sats_ceil(0), 0); + assert_eq!(msat_to_sats_ceil(1), 1); + assert_eq!(msat_to_sats_ceil(999), 1); + assert_eq!(msat_to_sats_ceil(1000), 1); + assert_eq!(msat_to_sats_ceil(1001), 2); + assert_eq!(msat_to_sats_ceil(1999), 2); + assert_eq!(msat_to_sats_ceil(2000), 2); + // A maximum rounds DOWN. + assert_eq!(msat_to_sats_floor(999), 0); + assert_eq!(msat_to_sats_floor(1000), 1); + assert_eq!(msat_to_sats_floor(1999), 1); + assert_eq!(msat_to_sats_floor(1_000_000_000), 1_000_000); + + // The measured agi.cash bounds: 1000 msat = 1 sat, 1_000_000_000 msat = 1_000_000 sats. + let pay = good_pay_request(); + assert_eq!(pay.min_sendable_sats(), 1); + assert_eq!(pay.max_sendable_sats(), 1_000_000); + } + + // ---- payRequest parsing ---- + + #[test] + fn pay_request_parses_the_measured_shape_and_builds_the_invoice_url() { + let pay = good_pay_request(); + assert_eq!(pay.min_sendable_msat, 1000); + assert_eq!(pay.max_sendable_msat, 1_000_000_000); + let url = pay.invoice_url(21).expect("21 sats is in range"); + assert_eq!( + url.as_str(), + "https://agi.cash/lnurlp/maxplayer/callback?amount=21000" + ); + // An existing query string is extended, not clobbered. + let with_query = parse_pay_request( + &pay_request_json(r#""https://agi.cash/cb?u=maxplayer""#, "1000", "2000"), + &address(), + ) + .expect("valid"); + assert_eq!( + with_query.invoice_url(2).expect("in range").as_str(), + "https://agi.cash/cb?u=maxplayer&amount=2000" + ); + } + + #[test] + fn pay_request_refuses_non_json_and_non_object_bodies() { + for body in [ + b"".as_slice(), + b"not json", + b"502", + b"[]", + b"\"payRequest\"", + b"42", + b"null", + ] { + assert!( + matches!( + parse_pay_request(body, &address()), + Err(LnurlError::NotJson { .. }) + ), + "{:?} must be refused as not-JSON-object", + String::from_utf8_lossy(body) + ); + } + } + + #[test] + fn pay_request_refuses_a_service_error_wrong_tag_and_missing_tag() { + assert_eq!( + parse_pay_request(br#"{"status":"ERROR","reason":"no such user"}"#, &address()), + Err(LnurlError::ServiceError { + reason: "no such user".into() + }) + ); + assert_eq!( + parse_pay_request( + br#"{"tag":"withdrawRequest","callback":"https://agi.cash/cb","minSendable":1000,"maxSendable":2000}"#, + &address() + ), + Err(LnurlError::WrongTag { + found: Some("\"withdrawRequest\"".into()) + }) + ); + assert_eq!( + parse_pay_request( + br#"{"callback":"https://agi.cash/cb","minSendable":1000,"maxSendable":2000}"#, + &address() + ), + Err(LnurlError::WrongTag { found: None }) + ); + // Case matters: "PayRequest" is not the LUD-06 tag. + assert!(matches!( + parse_pay_request( + br#"{"tag":"PayRequest","callback":"https://agi.cash/cb","minSendable":1000,"maxSendable":2000}"#, + &address() + ), + Err(LnurlError::WrongTag { .. }) + )); + } + + #[test] + fn pay_request_refuses_every_bad_callback() { + /// Does this refusal name the hazard the case was built to trip? + type Accepts = fn(&LnurlError) -> bool; + let cases: [(&str, Accepts); 8] = [ + ( + r#"{"tag":"payRequest","minSendable":1000,"maxSendable":2000}"#, + |e| matches!(e, LnurlError::CallbackMissing), + ), + ( + r#"{"tag":"payRequest","callback":"/lnurlp/cb","minSendable":1000,"maxSendable":2000}"#, + |e| matches!(e, LnurlError::CallbackNotAbsolute { .. }), + ), + ( + r#"{"tag":"payRequest","callback":"agi.cash/cb","minSendable":1000,"maxSendable":2000}"#, + |e| matches!(e, LnurlError::CallbackNotAbsolute { .. }), + ), + ( + r#"{"tag":"payRequest","callback":42,"minSendable":1000,"maxSendable":2000}"#, + |e| matches!(e, LnurlError::CallbackNotAbsolute { .. }), + ), + ( + r#"{"tag":"payRequest","callback":"http://agi.cash/cb","minSendable":1000,"maxSendable":2000}"#, + |e| matches!(e, LnurlError::CallbackNotHttps { .. }), + ), + ( + r#"{"tag":"payRequest","callback":"https://evil.example/cb","minSendable":1000,"maxSendable":2000}"#, + |e| { + matches!( + e, + LnurlError::CallbackHostMismatch { expected, found } + if expected == "agi.cash" && found == "evil.example" + ) + }, + ), + ( + r#"{"tag":"payRequest","callback":"https://agi.cash.evil.example/cb","minSendable":1000,"maxSendable":2000}"#, + |e| matches!(e, LnurlError::CallbackHostMismatch { .. }), + ), + ( + r#"{"tag":"payRequest","callback":"https://user:pw@agi.cash/cb","minSendable":1000,"maxSendable":2000}"#, + |e| matches!(e, LnurlError::CallbackHasCredentials { .. }), + ), + ]; + for (body, accept) in cases { + let error = parse_pay_request(body.as_bytes(), &address()) + .expect_err(&format!("{body} must be refused")); + assert!(accept(&error), "{body}: wrong refusal {error:?}"); + } + // A callback on the same host with a different case is the same host. + assert!(parse_pay_request( + br#"{"tag":"payRequest","callback":"https://AGI.cash/cb","minSendable":1000,"maxSendable":2000}"#, + &address() + ) + .is_ok()); + } + + // The adversarial parse gate (brief §3.1): every junk shape is REFUSED, never prefix-parsed. + #[test] + fn pay_request_refuses_junk_amount_fields_rather_than_prefix_parsing_them() { + let junk = [ + r#""0junk""#, + r#""1000""#, + r#""""#, + r#"" ""#, + r#"" 1000 ""#, + r#""1000\n""#, + "1000.0", + "1000.5", + "1e3", + "1E3", + "-1000", + "-0.5", + "18446744073709551616", + "99999999999999999999999999999", + "true", + "null", + "[1000]", + r#"{"msat":1000}"#, + ]; + for bad in junk { + for field in ["minSendable", "maxSendable"] { + let body = if field == "minSendable" { + pay_request_json(r#""https://agi.cash/cb""#, bad, "2000") + } else { + pay_request_json(r#""https://agi.cash/cb""#, "1000", bad) + }; + let result = parse_pay_request(&body, &address()); + assert!( + matches!(&result, Err(LnurlError::AmountNotInteger { field: f, .. }) if *f == field), + "{field}={bad} must be refused as not-an-integer, got {result:?}" + ); + } + } + // Shapes that are not even JSON (leading zero, hex, trailing junk) fail at the JSON layer. + for bad in ["01000", "0x3e8", "1000junk", "+1000", "1_000"] { + let body = pay_request_json(r#""https://agi.cash/cb""#, bad, "2000"); + assert!( + matches!( + parse_pay_request(&body, &address()), + Err(LnurlError::NotJson { .. }) + ), + "minSendable={bad} must not parse as JSON at all" + ); + } + // Missing fields are refused too, not defaulted. + assert!(matches!( + parse_pay_request( + br#"{"tag":"payRequest","callback":"https://agi.cash/cb","maxSendable":2000}"#, + &address() + ), + Err(LnurlError::AmountNotInteger { + field: "minSendable", + .. + }) + )); + assert!(matches!( + parse_pay_request( + br#"{"tag":"payRequest","callback":"https://agi.cash/cb","minSendable":1000}"#, + &address() + ), + Err(LnurlError::AmountNotInteger { + field: "maxSendable", + .. + }) + )); + // Exactly u64::MAX is an integer and is accepted as a maximum. + assert!( + parse_pay_request( + &pay_request_json(r#""https://agi.cash/cb""#, "1000", "18446744073709551615"), + &address() + ) + .is_ok() + ); + } + + #[test] + fn pay_request_refuses_zero_or_inverted_bounds_and_out_of_range_amounts() { + assert_eq!( + parse_pay_request( + &pay_request_json(r#""https://agi.cash/cb""#, "0", "2000"), + &address() + ), + Err(LnurlError::BoundsInvalid { + min_msat: 0, + max_msat: 2000 + }) + ); + assert_eq!( + parse_pay_request( + &pay_request_json(r#""https://agi.cash/cb""#, "3000", "2000"), + &address() + ), + Err(LnurlError::BoundsInvalid { + min_msat: 3000, + max_msat: 2000 + }) + ); + let pay = parse_pay_request( + &pay_request_json(r#""https://agi.cash/cb""#, "1500", "5000"), + &address(), + ) + .expect("valid"); + // 1500 msat minimum ⇒ 1 sat (1000 msat) is below it; 2 sats clears it. + assert_eq!(pay.min_sendable_sats(), 2); + assert_eq!( + pay.invoice_url(1), + Err(LnurlError::AmountBelowMin { + requested_msat: 1000, + min_msat: 1500 + }) + ); + assert!(pay.invoice_url(2).is_ok()); + assert!(pay.invoice_url(5).is_ok()); + assert_eq!( + pay.invoice_url(6), + Err(LnurlError::AmountAboveMax { + requested_msat: 6000, + max_msat: 5000 + }) + ); + assert_eq!( + pay.invoice_url(0), + Err(LnurlError::AmountBelowMin { + requested_msat: 0, + min_msat: 1500 + }) + ); + assert!(matches!( + pay.invoice_url(u64::MAX), + Err(LnurlError::AmountOverflow { .. }) + )); + } + + // ---- invoice response parsing ---- + + fn callback() -> Url { + Url::parse("https://agi.cash/cb?amount=21000").unwrap() + } + + #[test] + fn invoice_response_accepts_a_matching_unexpired_bolt11_and_returns_its_payment_hash() { + let bolt11 = signed_bolt11(Some(21_000), b"seed-a", Duration::ZERO); + let body = format!(r#"{{"pr":" {bolt11} ","routes":[]}}"#); + let resolved = parse_invoice_response(&callback(), body.as_bytes(), 21_000).expect("valid"); + assert_eq!(resolved.bolt11, bolt11, "trimmed, otherwise verbatim"); + assert_eq!(resolved.payment_hash, payment_hash_hex(b"seed-a")); + assert_eq!(resolved.payment_hash.len(), 64); + assert_eq!((resolved.amount_sats, resolved.amount_msat), (21, 21_000)); + } + + #[test] + fn invoice_response_refuses_missing_undecodable_expired_and_mismatched_invoices() { + let cb = callback(); + assert!(matches!( + parse_invoice_response(&cb, b"not json", 21_000), + Err(LnurlError::NotJson { .. }) + )); + assert_eq!( + parse_invoice_response( + &cb, + br#"{"status":"ERROR","reason":"amount too low"}"#, + 21_000 + ), + Err(LnurlError::ServiceError { + reason: "amount too low".into() + }) + ); + for body in [ + br#"{"routes":[]}"#.as_slice(), + br#"{"pr":""}"#, + br#"{"pr":" "}"#, + br#"{"pr":42}"#, + br#"{"pr":null}"#, + ] { + assert_eq!( + parse_invoice_response(&cb, body, 21_000), + Err(LnurlError::InvoiceMissing), + "{}", + String::from_utf8_lossy(body) + ); + } + assert!(matches!( + parse_invoice_response(&cb, br#"{"pr":"lnbc1notaninvoice"}"#, 21_000), + Err(LnurlError::InvoiceUndecodable { .. }) + )); + // Expired: timestamped two hours ago with a one-hour expiry. + let expired = signed_bolt11(Some(21_000), b"seed-b", Duration::from_secs(7200)); + assert_eq!( + parse_invoice_response(&cb, format!(r#"{{"pr":"{expired}"}}"#).as_bytes(), 21_000), + Err(LnurlError::InvoiceExpired) + ); + // Wrong amount, by one millisat either way, and amountless. + for (msat, found) in [(21_001, Some(21_001)), (20_999, Some(20_999)), (0, None)] { + let pr = signed_bolt11( + if msat == 0 { None } else { Some(msat) }, + b"seed-c", + Duration::ZERO, + ); + assert_eq!( + parse_invoice_response(&cb, format!(r#"{{"pr":"{pr}"}}"#).as_bytes(), 21_000), + Err(LnurlError::InvoiceAmountMismatch { + expected_msat: 21_000, + found_msat: found + }), + "amount {msat}" + ); + } + } + + // ---- the driver, over a scripted fetcher ---- + + /// One scripted answer: the exact URL expected, the status, the body. + type Answer = (String, u16, Vec); + + struct Scripted { + answers: RefCell>, + seen: RefCell>, + } + + impl Scripted { + fn new(answers: Vec<(&str, u16, Vec)>) -> Self { + Self { + answers: RefCell::new( + answers + .into_iter() + .map(|(url, status, body)| (url.to_owned(), status, body)) + .collect(), + ), + seen: RefCell::new(Vec::new()), + } + } + } + + impl LnurlFetch for Scripted { + fn get(&self, url: &Url) -> Result<(u16, Vec), LnurlError> { + self.seen.borrow_mut().push(url.to_string()); + let mut answers = self.answers.borrow_mut(); + let position = answers + .iter() + .position(|(expected, _, _)| expected == url.as_str()) + .unwrap_or_else(|| panic!("unexpected GET {url}")); + let (_, status, body) = answers.remove(position); + Ok((status, body)) + } + } + + #[test] + fn driver_resolves_well_known_then_callback_and_refuses_a_non_200_or_a_redirect() { + let bolt11 = signed_bolt11(Some(21_000), b"seed-d", Duration::ZERO); + let fetch = Scripted::new(vec![ + ( + "https://agi.cash/.well-known/lnurlp/maxplayer", + 200, + pay_request_json( + r#""https://agi.cash/lnurlp/maxplayer/callback""#, + "1000", + "1000000000", + ), + ), + ( + "https://agi.cash/lnurlp/maxplayer/callback?amount=21000", + 200, + format!(r#"{{"pr":"{bolt11}","routes":[]}}"#).into_bytes(), + ), + ]); + let pay = fetch_pay_request(&fetch, &address()).expect("payRequest"); + let resolved = request_invoice(&fetch, &pay, 21).expect("invoice"); + assert_eq!(resolved.amount_sats, 21); + assert_eq!(resolved.payment_hash, payment_hash_hex(b"seed-d")); + assert_eq!( + *fetch.seen.borrow(), + vec![ + "https://agi.cash/.well-known/lnurlp/maxplayer".to_owned(), + "https://agi.cash/lnurlp/maxplayer/callback?amount=21000".to_owned(), + ] + ); + + // A 302 (redirects are never followed) and a 500 are both refused as non-200. + for status in [302u16, 404, 500] { + let fetch = Scripted::new(vec![( + "https://agi.cash/.well-known/lnurlp/maxplayer", + status, + b"whatever".to_vec(), + )]); + assert_eq!( + fetch_pay_request(&fetch, &address()), + Err(LnurlError::HttpStatus { + url: "https://agi.cash/.well-known/lnurlp/maxplayer".into(), + status + }) + ); + } + // Out-of-range amounts never reach the network. + let fetch = Scripted::new(vec![]); + assert!(matches!( + request_invoice(&fetch, &pay, 0), + Err(LnurlError::AmountBelowMin { .. }) + )); + assert!(matches!( + request_invoice(&fetch, &pay, 1_000_001), + Err(LnurlError::AmountAboveMax { .. }) + )); + assert!(fetch.seen.borrow().is_empty()); + } + + // The scheme gate is the driver's, not only the fetcher's: even a fetcher that would happily + // GET http:// is never asked to. + #[test] + fn driver_refuses_an_http_url_before_touching_the_fetcher() { + struct Permissive(RefCell); + impl LnurlFetch for Permissive { + fn get(&self, _: &Url) -> Result<(u16, Vec), LnurlError> { + *self.0.borrow_mut() += 1; + Ok((200, b"{}".to_vec())) + } + } + let fetch = Permissive(RefCell::new(0)); + let pay = PayRequest { + callback: Url::parse("http://agi.cash/cb").unwrap(), + min_sendable_msat: 1000, + max_sendable_msat: 2000, + }; + assert!(matches!( + request_invoice(&fetch, &pay, 1), + Err(LnurlError::InsecureScheme { .. }) + )); + assert_eq!(*fetch.0.borrow(), 0, "the fetcher was never called"); + } + + #[test] + fn shipped_fetcher_refuses_http_without_a_network() { + let fetch = HttpsFetch::new().expect("client builds"); + assert!(matches!( + fetch.get(&Url::parse("http://127.0.0.1:9/x").unwrap()), + Err(LnurlError::InsecureScheme { .. }) + )); + } +} diff --git a/crates/maxplayer-core/src/platform_fee.rs b/crates/maxplayer-core/src/platform_fee.rs index 5c5d03359..6ad5d5f7b 100644 --- a/crates/maxplayer-core/src/platform_fee.rs +++ b/crates/maxplayer-core/src/platform_fee.rs @@ -1,21 +1,32 @@ -//! Seller-side platform fee, stage 1: a product-set rate, computed and journaled when a payment is -//! collected. +//! Seller-side platform fee: a product-set rate, computed and journaled when a payment is collected +//! (stage 1), and a product-set payout destination the accrued balance is remitted to — automatically, +//! by the seller node, as a consequence of collecting (stage 2a). //! -//! Two pieces live here — the rate ([`PLATFORM_FEE_BPS`]) and the arithmetic ([`fee_sats`]) — so the -//! collect seam reads one and calls the other instead of reimplementing either. Ungated on purpose: -//! the arithmetic is worth compiling and testing on every build, not only the money-path one. +//! Three pieces live here — the rate ([`PLATFORM_FEE_BPS`]), the arithmetic ([`fee_sats`]) and the +//! destination ([`PLATFORM_FEE_ADDRESS`]) — so the collect seam and the remit path read them instead +//! of reimplementing any. Ungated on purpose: the arithmetic is worth compiling and testing on every +//! build, not only the money-path one. //! -//! ## What this stage does and does not do +//! ## What is accrued, and what pays it //! -//! The fee is **accrued and recorded**. Every collected payment journals the rate in force and the -//! sats it comes to, against the job that earned it. **Nothing is remitted**: there is no fee -//! recipient in the product yet, so no call here or in any caller moves a sat. Paying the accrued -//! balance out is a later stage that sits on top of this journal. +//! The fee is **accrued and recorded** at collect time: every collected payment journals the rate in +//! force and the sats it comes to, against the job that earned it. **The seller node then remits it +//! automatically**: once the receipt is journaled new, the node makes a best-effort attempt to pay +//! the whole unremitted balance to [`PLATFORM_FEE_ADDRESS`] from the seller's ecash (`fee_remit`, +//! reached from `seller_node::run`). That attempt cannot affect the collect — the job is already +//! paid — and a failed attempt leaves the balance unremitted for the node's retry tick to try again +//! (base 30 s, doubling to a 30-minute cap, full jitter, for as long as the node runs; addendum 2); +//! balances below the destination's minimum accrue until they clear it. `maxplayer seller fees +//! remit` is the operator's inspection and recovery path (dry run by default, `--confirm` to pay +//! now), not the mechanism. The one operational switch, `[platform_fee] auto_remit`, stops the +//! automatic attempt and nothing else: it cannot touch the rate or the destination, and the fee stays +//! owed and visible. //! -//! ## Who sets the rate +//! ## Who sets the rate and the destination //! -//! The product does, in this source file. A seller cannot change it: there is no config key, no env -//! override and no CLI flag, and nothing parses, so nothing can fail at load. +//! The product does, in this source file. A seller cannot change either: there is no config key, no +//! env override and no CLI flag, and nothing parses, so nothing can fail at load. See +//! [`PLATFORM_FEE_ADDRESS`] for why the destination in particular must not be seller-editable. /// The platform fee rate, in **basis points** (`1 bp = 0.01%`, so `250` is 2.5% and `10_000` is the /// whole payment). This constant is the whole specification of the fee: @@ -29,11 +40,33 @@ /// - The rate in force is journaled beside every receipt (`receipts.fee_bps`), so a store that /// outlives a change to this number still says what each collection owed. /// -/// **Currently `1000` — ten percent.** Stage 1 accrues and records what this rate comes to on each -/// collection; it pays nobody, because no payout destination exists in the product yet. The rows -/// this stage writes are a journal, not a bill. +/// **Currently `1000` — ten percent.** Collection accrues and records what this rate comes to on +/// each payment, and the seller node then remits the accrued balance to [`PLATFORM_FEE_ADDRESS`] +/// automatically, best-effort, after the receipt is journaled (`fee_remit`); `maxplayer seller fees +/// remit --confirm` pays it by hand. The receipt rows are the journal every remittance settles +/// against. Nothing about this rate is read from config or environment. pub const PLATFORM_FEE_BPS: u32 = 1000; +/// The Lightning address (LUD-16, `user@host`) the accrued platform fee is remitted to. Ordered by +/// Josip (real-sats authority), 2026-09-07. This constant is the whole specification of where the +/// fee goes: +/// +/// - It is set by the product, here, and **not by the seller**. There is no config key, no env +/// override and no CLI flag, deliberately: the seller runs this binary, and the seller is the +/// party that owes the fee. A seller-editable `fee_address` would let any seller point the +/// platform's fee at themselves. A compiled-in constant costs a release to change; a seller-editable +/// one costs the whole fee. The release is the cheaper defect. +/// - It is resolved at remit time over LNURL-pay (`https://host/.well-known/lnurlp/user`), fail +/// closed, by [`crate::lnurl_pay`] — by the seller node's automatic attempt after a collect and by +/// `maxplayer seller fees remit`, and by nothing else. Nothing in this module contacts it. +/// - The `[platform_fee] auto_remit` switch cannot change it: that table has no key for an address. +/// - **Every remittance journals the literal it paid** (`fee_remittances.destination`), so a later +/// change to this constant leaves a readable history rather than an ambiguous one. +/// +/// The proper long-term fix — an authoritatively signed platform parameter carrying the rate and the +/// address together, so neither needs a release — is a separate, later stage. +pub const PLATFORM_FEE_ADDRESS: &str = "maxplayer@agi.cash"; + /// Basis points in one hundred percent — the ceiling on any rate. pub const BPS_PER_WHOLE: u32 = 10_000; @@ -68,7 +101,8 @@ pub fn fee_sats(face_sats: u64, fee_bps: u32) -> u64 { /// zero. This is a display-time derivation from the three journaled figures — it is deliberately /// NOT stored, so it can never disagree with the columns it comes from. Both deductions are taken /// from the face: the mint's swap fee is what the mint kept before the sats reached the wallet, and -/// the platform fee is what this stage records as owed (and does not remit). +/// the platform fee is what collection records as owed — remitted afterwards by the seller node +/// itself (after the collect, and on its retry clock), or by `maxplayer seller fees remit --confirm`. pub fn kept_sats(face_sats: u64, mint_fee_sats: u64, platform_fee_sats: u64) -> u64 { face_sats .saturating_sub(mint_fee_sats) @@ -168,14 +202,26 @@ mod tests { assert_eq!(bps_to_percent_label(PLATFORM_FEE_BPS), "10%"); } - // ---- the constant ---- + // ---- the constants ---- #[test] fn the_shipped_rate_is_ten_percent_and_within_the_whole() { - // Stage 1 accrues at 10% and pays nobody. The `<= 10_000` bound is enforced at compile time - // by the `const _` assertion in the module body. + // Collection accrues at 10%; the seller node remits it after the collect (`fee_remit`). The + // `<= 10_000` bound is enforced at compile time by the `const _` assertion in the module body. assert_eq!(PLATFORM_FEE_BPS, 1000); assert_eq!(fee_sats(100, PLATFORM_FEE_BPS), 10); assert_eq!(fee_sats(9, PLATFORM_FEE_BPS), 0); } + + // Stage 2a: the destination is the address Josip ordered, in LUD-16 `user@host` shape, and it is + // a constant — no config surface reads or writes it (see the doc comment for why). + #[test] + fn the_shipped_destination_is_the_ordered_lightning_address() { + assert_eq!(PLATFORM_FEE_ADDRESS, "maxplayer@agi.cash"); + let (user, host) = PLATFORM_FEE_ADDRESS + .split_once('@') + .expect("a LUD-16 address has exactly one @"); + assert_eq!((user, host), ("maxplayer", "agi.cash")); + assert!(!host.contains('@') && !host.contains('/')); + } } diff --git a/crates/maxplayer-core/src/seller_node/run.rs b/crates/maxplayer-core/src/seller_node/run.rs index 660451994..228012f8b 100644 --- a/crates/maxplayer-core/src/seller_node/run.rs +++ b/crates/maxplayer-core/src/seller_node/run.rs @@ -1453,7 +1453,9 @@ fn classify_redeem_outcome( /// sats it comes to, rounded down, so the caller journals both beside the receipt. /// /// Called only after the redeem classified `Finalize`; nothing is computed or recorded for a payment -/// that did not land. Accrued, never remitted: this returns numbers for the journal and moves no sat. +/// that did not land. This returns numbers for the journal and moves no sat itself; what pays the +/// accrued balance is the best-effort remittance the collect path starts AFTER the receipt is +/// journaled `New` ([`SellerNodeRunner::remit_platform_fee_after_collect`]). fn platform_fee_at_collect(amount_received: u64) -> (u32, u64) { let fee_bps = crate::platform_fee::PLATFORM_FEE_BPS; ( @@ -1462,6 +1464,15 @@ fn platform_fee_at_collect(amount_received: u64) -> (u32, u64) { ) } +/// The rule for when a collect starts a remittance attempt (stage 2a, addendum 1 rule 1): only a +/// receipt journaled **New**. A replayed wrap (`Duplicate`) already paid the job once and must not +/// pay the fee a second time; a failed write journaled nothing, so there is nothing new to remit. +fn remit_follows_collect( + collected: &Result, +) -> bool { + matches!(collected, Ok(super::store::Collected::New)) +} + /// The seal-sender guard: a payment settles a job ONLY when the authenticated NIP-17 seal sender is /// the bound offer buyer (the pubkey folded into the seller-signed receipt preimage). A third party /// can never pay-once and close someone else's job. @@ -3757,6 +3768,207 @@ pub struct SellerNodeRunner { /// #747: how this node is asked to leave the selling role, so it can publish its terminal /// `accepting=n` beat before exiting. See [`shutdown`] and [`Self::shutdown_handle`]. shutdown: shutdown::ShutdownChannel, + /// Stage 2a, addendum 2 §3: single-flight for the platform-fee remittance. The collect path's + /// thread and the loop's retry tick both reach the one remit entry point; whichever finds the + /// slot taken skips. See [`crate::fee_remit::RemitFlight`]. + remit_flight: crate::fee_remit::RemitFlight, + /// Stage 2a, addendum 2 §2: the ONE backoff for this node's remittance attempts, shared by the + /// retry tick (which sleeps by it) and the collect path's thread (whose outcome also moves it, so + /// a success on either path resets it). See [`crate::fee_remit::RemitBackoff`]. + remit_pacing: Arc>, + /// Stage 2a, addendum 3 §3: the collect thread's outcome moved the shared backoff, so the + /// loop's LIVE retry timer must follow — a success pulls the pending sleep back to base, a + /// failure pushes a too-short deadline out. The thread `notify_one`s; the loop has a `select!` + /// arm on `notified()` that re-arms the `Sleep` ([`rearm_deadline`]). + remit_pacing_changed: Arc, + /// Stage 2a, addendum 3 RULING 2: raised the moment serving ends. No remittance attempt may + /// START after it (collect path or tick); the one already in flight — a melt whose proofs may + /// be with the mint, which is never cancelled — is DRAINED with a bounded wait + /// ([`Self::drain_remit_in_flight`]) before `run` returns. + remit_closed: std::sync::atomic::AtomicBool, + /// Test seam for addendum 2 gate 2e: how many retry-tick attempts this node has STARTED. Read + /// after the loop returned to prove none started after it. + #[cfg(test)] + remit_retry_started: Arc, + /// Test seam for addendum 3 gate 2e (strengthened): scripted effects for the node's attempts in + /// place of the live LNURL + wallet, so a test can hold a PENDING payment across a shutdown. + #[cfg(test)] + remit_effects_for_test: Mutex>, + /// Test seam: a shorter drain bound than [`REMIT_DRAIN_BOUND`], so the "abandoned at the bound" + /// branch is exercised without waiting a minute. + #[cfg(test)] + remit_drain_bound_for_test: Mutex>, +} + +/// How long a requested shutdown waits for a remittance attempt already in flight before it gives +/// up waiting and lets `run` return (addendum 3 RULING 2). The attempt is never cancelled — its +/// proofs may already be with the mint — only the WAIT is bounded; a melt still running past this +/// finishes on its own thread and its planned row is reconciled at the next start. +pub const REMIT_DRAIN_BOUND: Duration = Duration::from_secs(60); + +/// Builds the effects one remittance attempt runs against. Only a test ever installs one; the +/// live node passes `None` and the thread builds [`crate::fee_remit::LiveEffects`]. +type RemitEffectsFactory = + Arc Box + Send + Sync>; + +/// One remittance attempt, on a remittance thread: the live entry point, or the test's scripted +/// effects through the same decision logic. +fn run_remit_attempt( + store: &super::store::SellerStore, + home: MaxplayerHome, + trigger: crate::fee_remit::RemitTrigger, + factory: Option, +) -> crate::fee_remit::RemitReport { + match factory { + Some(factory) => { + let mut effects = factory(); + crate::fee_remit::remit_best_effort(store, effects.as_mut(), trigger, now_unix()) + } + None => crate::fee_remit::remit_live_best_effort(store, home, trigger, now_unix()), + } +} + +/// At what volume [`remit_outcome_lines`] wants its lines logged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemitLogVolume { + /// `opline_verbose!`: the steady state, not for a quiet log. + Verbose, + /// `opline!`. + Normal, +} + +/// The ONE logging policy for a node remittance attempt, whichever path ran it (addendum 3 §3; the +/// collect path and the retry tick used to log differently), as PURE text so the policy is tested +/// by counting lines. A node whose payout host is down retries for hours, and the retry must not +/// become the log — **every attempt logs at most ONE line, the first failure included** (addendum 4 +/// §2.1): +/// +/// - Steady state (nothing owed, or under the destination's minimum) is one verbose-only line. +/// - A payment is one line. +/// - The FIRST failure of a streak is one line carrying the attempt's detail — destination, the +/// balance it saw, its own lines and its error — folded in, and the backoff it starts. +/// - Every later failure in the streak is one line — streak, cause, next attempt — and the +/// transition into the 30-minute cap is said on that same line, never a second one. +/// - A payment that ends a streak is one line saying how many attempts failed and how long the fee +/// sat owed. +/// +/// `next` is the delay the caller re-armed the tick with, when it knows it (the tick's own path); +/// the collect path does not own the timer, so it names the computed delay the re-arm draws from. +pub(crate) fn remit_outcome_lines( + path: &str, + report: &crate::fee_remit::RemitReport, + pacing: &crate::fee_remit::Pacing, + next: Option, + computed: Duration, +) -> (RemitLogVolume, Vec) { + use crate::fee_remit::Pacing; + let when = match next { + Some(next) => format!("in {}s (computed {}s)", next.as_secs(), computed.as_secs()), + None => format!( + "on the retry tick, re-armed to within {}s", + computed.as_secs() + ), + }; + let line = match pacing { + Pacing::Idle => { + return ( + RemitLogVolume::Verbose, + vec![format!( + "seller node platform fee {path}: {}; next check {when}", + report.summary() + )], + ); + } + Pacing::Paid => format!("seller node platform fee {path}: {}", report.summary()), + Pacing::FirstFailure => { + // The whole first failure on ONE line: what it was trying to pay, to where, what it saw + // (the attempt's own lines, joined), and the error — then the backoff it starts. + let detail = if report.lines.is_empty() { + "no detail printed".to_owned() + } else { + report + .lines + .iter() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" | ") + }; + format!( + "seller node platform fee {path}: {} — streak 1; destination {}; attempt detail: {detail}; retrying with backoff (base {}s, doubling to a {}s cap, full jitter): next attempt {when}", + report.summary(), + crate::platform_fee::PLATFORM_FEE_ADDRESS, + crate::fee_remit::RETRY_BASE.as_secs(), + crate::fee_remit::RETRY_CAP.as_secs() + ) + } + Pacing::RepeatFailure { + streak, + entered_cap, + } => { + let cap_note = if *entered_cap { + format!( + "; backoff has reached the {}s cap and stays there until a remittance succeeds", + computed.as_secs() + ) + } else { + String::new() + }; + format!( + "seller node platform fee {path}: still failing (streak {streak}: {}); next attempt {when}{cap_note}", + report.summary() + ) + } + Pacing::Recovered { + failed_attempts, + owed_for_secs, + } => format!( + "seller node platform fee {path}: RECOVERED — {} — after {failed_attempts} failed attempt(s) over {owed_for_secs}s; backoff reset to base", + report.summary() + ), + }; + (RemitLogVolume::Normal, vec![line]) +} + +/// Emit [`remit_outcome_lines`] to the node log at the volume it asks for. +fn log_remit_outcome( + path: &str, + report: &crate::fee_remit::RemitReport, + pacing: &crate::fee_remit::Pacing, + next: Option, + computed: Duration, +) { + let (volume, lines) = remit_outcome_lines(path, report, pacing, next, computed); + for line in lines { + match volume { + RemitLogVolume::Verbose => opline_verbose!("{line}"), + RemitLogVolume::Normal => opline!("{line}"), + } + } +} + +/// Where the live retry timer should fire after a SHARED outcome (a collect-path attempt) moved the +/// backoff (addendum 3 §3): a success (`streak == 0`) resets the pending sleep to the freshly drawn +/// base-streak delay however far away it was; a failure never shortens a deadline — it extends a +/// too-short one to the drawn delay and keeps a longer one. Either way the result is never before +/// `boot_floor` — boot plus [`crate::fee_remit::RETRY_BASE`] — so a collect success in the node's +/// first seconds cannot pull the FIRST retry under 30 s after boot (addendum 3 RULING 1, held under +/// re-arm by addendum 4 §2.2). Pure, so the rule is tested against a real `Sleep` under a paused +/// clock. +pub(crate) fn rearm_deadline( + current_deadline: tokio::time::Instant, + now: tokio::time::Instant, + streak: u32, + drawn: Duration, + boot_floor: tokio::time::Instant, +) -> tokio::time::Instant { + let proposed = now + drawn; + let deadline = if streak == 0 { + proposed + } else { + proposed.max(current_deadline) + }; + deadline.max(boot_floor) } impl SellerNodeRunner { @@ -3886,9 +4098,126 @@ impl SellerNodeRunner { terminal_offers: TerminalOffers::new(TERMINAL_OFFERS_CAP, TERMINAL_AUTHORS_PER_OFFER), fed_under_rate_offers: FedUnderRateOffers::new(FED_UNDER_RATE_OFFERS_CAP), shutdown: shutdown::ShutdownChannel::new(), + remit_flight: crate::fee_remit::RemitFlight::new(), + remit_pacing: Arc::new(Mutex::new(crate::fee_remit::RemitBackoff::new())), + remit_pacing_changed: Arc::new(tokio::sync::Notify::new()), + remit_closed: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + remit_retry_started: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + #[cfg(test)] + remit_effects_for_test: Mutex::new(None), + #[cfg(test)] + remit_drain_bound_for_test: Mutex::new(None), }) } + /// Test seam (addendum 3 gate 2e): run the node's remittance attempts against scripted effects + /// instead of the live LNURL host and wallet, and bound the shutdown drain. Called BEFORE + /// [`Self::run`]. + #[cfg(test)] + fn remit_effects_for_test(&self, factory: RemitEffectsFactory, drain_bound: Option) { + *self + .remit_effects_for_test + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(factory); + *self + .remit_drain_bound_for_test + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = drain_bound; + } + + /// The effects factory a remittance thread should use: the test's, or none (live). + fn remit_effects_factory(&self) -> Option { + #[cfg(test)] + { + self.remit_effects_for_test + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + #[cfg(not(test))] + { + None + } + } + + /// How long [`Self::drain_remit_in_flight`] waits: [`REMIT_DRAIN_BOUND`], or the test's bound. + fn remit_drain_bound(&self) -> Duration { + #[cfg(test)] + { + if let Some(bound) = *self + .remit_drain_bound_for_test + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + { + return bound; + } + } + REMIT_DRAIN_BOUND + } + + /// Addendum 3 RULING 2 — shutdown DRAINS, it does not kill. Called once serving has ended and + /// [`Self::remit_closed`] is raised (so nothing new can start): waits for the remittance attempt + /// in flight, if any, to finish — a melt whose proofs may already be with the mint must not be + /// cancelled mid-flight, which would risk the seller's sats; a slow exit is the lesser harm. + /// The wait is bounded by [`Self::remit_drain_bound`]: if it elapses, one incident line says + /// exactly what was abandoned, and the persisted row makes the outcome recoverable at the next + /// start (reconciliation, made safe by ownership: the next start is a new owner; a row still + /// `planned` it may release once the lease has run out or the invoice's quote is terminal; a + /// row already `spending` it never releases — it settles when the mint reports the bound quote + /// PAID and otherwise holds, on no clock, however long the lease has been over — addendum 6 + /// §1.2). + async fn drain_remit_in_flight(&self) { + if !self.remit_flight.in_flight() { + return; + } + let bound = self.remit_drain_bound(); + opline!( + "seller node platform fee: shutdown with a remittance attempt in flight — waiting up to {}ms for it to finish (a melt whose proofs may be with the mint is never cancelled)", + bound.as_millis() + ); + let started = tokio::time::Instant::now(); + loop { + if !self.remit_flight.in_flight() { + opline!( + "seller node platform fee: the in-flight remittance attempt finished after {}ms; nothing of the remittance is left running", + started.elapsed().as_millis() + ); + return; + } + if started.elapsed() >= bound { + opline!( + "seller node platform fee: INCIDENT — a remittance attempt is still in flight after the {}ms drain bound; abandoning the wait, not the attempt: its thread finishes on its own, and the row it journaled (if any) is reconciled at the next start — a row already admitted and SPENDING, bound to its quote, is settled if the mint reports that quote PAID and otherwise HELD for an operator; a row still PLANNED is released on lease expiry, by its owner, or on a terminal quote, and then re-planned", + bound.as_millis() + ); + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + /// Test seam (addendum 2 gate 2e): shorten the retry tick's bounds so a test can watch several + /// attempts start without sleeping 30 s, and hand back the started-attempts counter. Called + /// BEFORE [`Self::run`], which consumes the runner; the loop reads its first delay from the + /// pacing when it starts. + #[cfg(test)] + fn remit_retry_bounds_for_test( + &self, + base: Duration, + cap: Duration, + ) -> Arc { + *self.remit_pacing_lock() = crate::fee_remit::RemitBackoff::with_bounds(base, cap); + Arc::clone(&self.remit_retry_started) + } + + /// The pacing, poison-proof: a thread that panicked while holding it must not take the retry + /// tick down with it. + fn remit_pacing_lock(&self) -> std::sync::MutexGuard<'_, crate::fee_remit::RemitBackoff> { + self.remit_pacing + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + /// The seller public key (hex). pub fn seller_pubkey(&self) -> String { self.seller_pubkey.to_hex() @@ -4206,7 +4535,14 @@ impl SellerNodeRunner { /// cover for those. Belt AND braces, never a replacement. async fn run_loop(self: Arc) -> Result<(), NodeError> { let served = Arc::clone(&self).serve().await; + // Addendum 3 RULING 2: serving has ended — no remittance attempt may start from here on + // (the flag is checked by both node paths), the seat retracts, and then the attempt already + // in flight (if any) is drained with a bounded wait. `run` returning means nothing of this + // PR's is still running, or one INCIDENT line above said exactly what was abandoned. + self.remit_closed + .store(true, std::sync::atomic::Ordering::SeqCst); self.publish_retraction().await; + self.drain_remit_in_flight().await; served } @@ -4305,6 +4641,35 @@ impl SellerNodeRunner { tokio::time::interval(Duration::from_secs(wrap_backfill_interval_secs)); let mut heartbeat_tick = tokio::time::interval(Duration::from_secs(heartbeat_interval_secs.max(1))); + // Stage 2a, addendum 2: the platform-fee remittance RETRY, on this loop's clock. The collect + // path's attempt is the fast path (the fee normally leaves within a second of the sale); this + // is the safety net behind it — a failed remittance retries with backoff for as long as the + // node runs, and stops SCHEDULING with the loop: a requested shutdown or a relay-pool close + // ends this `select!`, `run_loop` raises `remit_closed`, and the attempt in flight (if any) + // is drained with a bounded wait before `run` returns (addendum 3 RULING 2). + // + // A re-armed `Sleep` rather than an `interval`: the delay changes with every outcome + // (`RemitBackoff`), and `interval` fires immediately on first poll, which would make the whole + // fleet attempt at startup. The first attempt after boot waits the FULL base delay plus a + // jitter in [0, base] — [30 s, 60 s], never zero (addendum 3 RULING 1). `remit_retry_pending` + // holds the in-flight attempt's report channel; while it is `Some` the timer arm is disabled, + // so a slow attempt can never stack a second one. A collect-path outcome that moved the + // shared backoff re-arms this same timer through `remit_pacing_changed` (addendum 3 §3). + let auto_remit = self.node.home().config.platform_fee.auto_remit; + // RULING 1's floor, kept under every re-arm below (addendum 4 §2.2): no retry fires before + // boot + base (30 s shipped; the test seam shortens both together), whatever a collect-path + // outcome does to the shared backoff in the meantime. + let remit_boot_floor = tokio::time::Instant::now() + self.remit_pacing_lock().base(); + let remit_retry = tokio::time::sleep(self.remit_pacing_lock().boot_delay()); + tokio::pin!(remit_retry); + let mut remit_retry_pending: Option< + tokio::sync::oneshot::Receiver, + > = None; + if !auto_remit { + opline!( + "seller node platform fee: automatic remittance is OFF ([platform_fee] auto_remit = false) — neither the collect path nor the retry tick will attempt it; the fee still accrues and is owed, and `maxplayer seller fees remit --confirm` pays it by hand" + ); + } // Watchdog liveness clocks: monotonic instant (staleness measure, robust to wall-clock jumps) // + unix stamp (resubscribe `since` cursor). Refreshed whenever the relay answers our liveness // probe. Seeded to "now" so a healthy node never trips before its first probe. @@ -4368,6 +4733,52 @@ impl SellerNodeRunner { self.drain().await; continue; } + // Stage 2a, addendum 2: the remittance retry tick. Disabled while an attempt is in + // flight (the report arm below re-arms the timer when it lands) and for good when + // `auto_remit` is off — one flag, both paths (§4). + () = &mut remit_retry, if auto_remit && remit_retry_pending.is_none() => { + remit_retry_pending = self.start_retry_remit(); + if remit_retry_pending.is_none() { + // Skipped (an attempt is already in flight, or the thread could not start): + // nothing to observe, so the pacing is unchanged; sleep another jittered + // delay at the current streak. + remit_retry.as_mut().reset( + (tokio::time::Instant::now() + self.remit_pacing_lock().next_delay()) + .max(remit_boot_floor), + ); + } + } + // The in-flight retry attempt reported (or its thread died): fold the outcome into + // the pacing, log it under the §5 discipline, and re-arm the timer by the new delay. + report = async { remit_retry_pending.as_mut().expect("armed only while pending").await }, + if remit_retry_pending.is_some() => { + remit_retry_pending = None; + let next = self.settle_retry_remit(report.ok()); + remit_retry + .as_mut() + .reset((tokio::time::Instant::now() + next).max(remit_boot_floor)); + } + // Addendum 3 §3: a SHARED outcome (the collect thread's attempt) moved the backoff; + // make the live timer follow — a success pulls the pending sleep back to base, a + // failure pushes a too-short deadline out and never shortens a longer one. + () = self.remit_pacing_changed.notified(), if auto_remit => { + let (streak, drawn) = { + let pacing = self.remit_pacing_lock(); + (pacing.streak(), pacing.next_delay()) + }; + let deadline = rearm_deadline( + remit_retry.deadline(), + tokio::time::Instant::now(), + streak, + drawn, + remit_boot_floor, + ); + remit_retry.as_mut().reset(deadline); + opline_verbose!( + "seller node platform fee retry: timer re-armed after a collect-path outcome (streak {streak}); next check in {}ms", + deadline.saturating_duration_since(tokio::time::Instant::now()).as_millis() + ); + } // Re-ask the relay for stored payment wraps AND stored offers, so a silently-deaf 1059 // or offer subscription recovers without a restart (#560). Also the node's only // periodic log lines, and therefore the positive signal external supervision watches. @@ -7747,11 +8158,16 @@ impl SellerNodeRunner { return; } }; - // Platform fee (stage 1): computed only NOW — after the redeem classified `Finalize` — on the - // FACE (what the buyer paid), at the product-set rate, and journaled in the receipt write - // below. `kept` is what the seller keeps once the mint's fee and the platform fee are both - // taken from the face; it is derived for the log and never stored. Accrued, never remitted: - // nothing here or downstream moves a sat on its account. + // Platform fee: computed only NOW — after the redeem classified `Finalize` — on the FACE + // (what the buyer paid), at the product-set rate, and journaled in the receipt write below. + // `kept` is what the seller keeps once the mint's fee and the platform fee are both taken + // from the face; it is derived for the log and never stored. Accrued here; REMITTED + // automatically (stage 2a) — once the receipt is journaled `New` below, and only then, the + // node starts a best-effort attempt to pay the whole unremitted balance to the platform's + // Lightning address (`remit_platform_fee_after_collect`). That attempt runs on a thread of + // its own and cannot affect this collect: the receipt is written and the job marked paid + // before it starts, and a remittance that fails is logged and journaled, leaving the balance + // unremitted for the loop's retry tick (backoff, addendum 2) — and the next collect — to try. let (fee_bps, fee_sats) = platform_fee_at_collect(amount_received); let kept = crate::platform_fee::kept_sats(amount_received, mint_fee_sats, fee_sats); opline!( @@ -7760,7 +8176,7 @@ impl SellerNodeRunner { // Record the receipt AFTER the money landed (invariant 3 order) — deduped on the wrap id, so a // replayed wrap marks the job paid at most once. The fees ride in the same row. - match self.node.store().collect_receipt( + let collected = self.node.store().collect_receipt( &event_id, &job_id, amount_received, @@ -7770,7 +8186,8 @@ impl SellerNodeRunner { fee_sats, }, now_unix(), - ) { + ); + match &collected { Ok(super::store::Collected::New) => { // `event_id` is the kind-1059 payment gift-wrap — the id this collection is // journaled and deduped under. It is NOT the co-signed kind-3400 receipt (the buyer @@ -7788,6 +8205,172 @@ impl SellerNodeRunner { opline!("seller node wrap event={event_id}: receipt write failed for job {job_id} ({error})") } } + // The automatic remittance (stage 2a): after a receipt journaled NEW, and only then — never + // on a replayed wrap, never on a failed write — so a duplicate wrap can never trigger a + // second payment. Best-effort and off this task: the job is already paid above. + if remit_follows_collect(&collected) { + self.remit_platform_fee_after_collect(&job_id); + } + } + + /// Start the best-effort remittance of the accrued platform fee after a collect journaled a NEW + /// receipt — the mechanism that makes the fee a fee (stage 2a, addendum 1), and the fast path in + /// front of the retry tick (addendum 2). Everything about it is arranged so it cannot touch the + /// collect that triggered it: + /// + /// - It runs AFTER the receipt is written and the job is marked paid, on a plain OS thread of + /// its own (the wallet's `*_blocking` wrappers refuse to run inside the Tokio runtime, and a + /// 20-second LNURL timeout must not stall the wrap loop). This method returns at once. + /// - It never propagates: `fee_remit::remit_live_best_effort` returns a report, not an error, + /// and every line of it goes to the operator log. The house pattern is `fail_job`'s — a + /// failure here is logged and journaled, never raised — the loop keeps serving. + /// - A failure leaves the balance unremitted; the loop's retry tick tries again on its backoff + /// clock, and the next collect is one more trigger. Its outcome moves the same pacing the tick + /// sleeps by, so a payment here resets the backoff and a failure here escalates it. + /// - Single-flight (addendum 2 §3): if an attempt is already in flight — the tick's, or an + /// earlier collect's — this one SKIPS; the in-flight attempt pays the whole balance, this + /// receipt's fee included, or the tick retries. The store's one-`planned`-row rule remains what + /// makes a double payment impossible. + /// - `[platform_fee] auto_remit = false` turns this attempt off (and the tick's) and nothing + /// else: the fee still accrues and stays owed; `maxplayer seller fees remit --confirm` pays it. + /// - Its outcome is logged under the ONE policy both paths share ([`log_remit_outcome`]) and, + /// when it moved the backoff, re-arms the loop's live retry timer (addendum 3 §3). + /// - Once serving has ended (`remit_closed`) it does not start: the drain is waiting for the + /// attempt in flight, and nothing new may join it (addendum 3 RULING 2). + fn remit_platform_fee_after_collect(&self, job_id: &str) { + if !self.node.home().config.platform_fee.auto_remit { + opline!( + "seller node platform fee (job_id={job_id}): automatic remittance is OFF ([platform_fee] auto_remit = false); the fee stays accrued and owed — `maxplayer seller fees remit --confirm` pays it by hand" + ); + return; + } + if self.remit_closed.load(std::sync::atomic::Ordering::SeqCst) { + opline!( + "seller node platform fee remit (after job_id={job_id}): the node is shutting down; not starting an attempt — the fee stays accrued for the next start" + ); + return; + } + let Some(permit) = self.remit_flight.try_acquire() else { + opline!( + "seller node platform fee remit (after job_id={job_id}): an attempt is already in flight; skipping — it pays the whole unremitted balance, and the retry tick covers a failure" + ); + return; + }; + let store = self.node.store().clone(); + let home = self.node.home().clone(); + let pacing = Arc::clone(&self.remit_pacing); + let pacing_changed = Arc::clone(&self.remit_pacing_changed); + let effects_factory = self.remit_effects_factory(); + let owned_job_id = job_id.to_owned(); + let spawned = std::thread::Builder::new() + .name("platform-fee-remit".to_owned()) + .spawn(move || { + // The permit lives exactly as long as the attempt: dropped at the end of this + // closure, or on unwind if the attempt panics. + let _permit = permit; + let job_id = owned_job_id; + let report = run_remit_attempt( + &store, + home, + crate::fee_remit::RemitTrigger::Collect, + effects_factory, + ); + let (pacing_change, computed) = { + let mut guard = pacing + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let change = guard.observe(&report, now_unix()); + (change, guard.computed_delay()) + }; + log_remit_outcome( + &format!("remit (after job_id={job_id})"), + &report, + &pacing_change, + None, + computed, + ); + // The shared backoff moved (a payment or a failure): the loop re-arms its timer. + if pacing_change != crate::fee_remit::Pacing::Idle { + pacing_changed.notify_one(); + } + }); + if let Err(error) = spawned { + opline!( + "seller node platform fee remit (after job_id={job_id}): could not start the remittance thread ({error}); the fee stays accrued for the retry tick to try" + ); + } + } + + /// The retry tick's attempt (stage 2a, addendum 2): one best-effort run of the remit entry point + /// under `RemitTrigger::Retry`, on a plain OS thread (same reason as the collect path's: the + /// wallet's blocking wrappers refuse a Tokio context, and a 20-second LNURL timeout must not + /// stall the loop). Returns the channel the report arrives on, or `None` when the tick SKIPPED: + /// an attempt is already in flight (single-flight, §3 — the tick does not queue behind it), + /// serving has ended (`remit_closed`), or the thread could not start. The loop re-arms the timer + /// either way. + /// + /// Lifecycle (addendum 3 RULING 2): the thread is owned by the node through the single-flight + /// permit it holds — when the loop ends with an attempt in flight, `run_loop` raises + /// `remit_closed` (so no new attempt can start) and DRAINS: it waits, bounded, for the permit to + /// drop before `run` returns. The receiver is dropped with the loop, so the thread's `send` + /// fails silently; its attempt is still journaled in the store. + fn start_retry_remit( + &self, + ) -> Option> { + if self.remit_closed.load(std::sync::atomic::Ordering::SeqCst) { + return None; + } + let permit = self.remit_flight.try_acquire()?; + let store = self.node.store().clone(); + let home = self.node.home().clone(); + let effects_factory = self.remit_effects_factory(); + let (report_tx, report_rx) = tokio::sync::oneshot::channel(); + let spawned = std::thread::Builder::new() + .name("platform-fee-remit-retry".to_owned()) + .spawn(move || { + let _permit = permit; + let report = run_remit_attempt( + &store, + home, + crate::fee_remit::RemitTrigger::Retry, + effects_factory, + ); + let _ = report_tx.send(report); + }); + match spawned { + Ok(_) => { + #[cfg(test)] + self.remit_retry_started + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some(report_rx) + } + Err(error) => { + opline!( + "seller node platform fee retry: could not start the remittance thread ({error}); the fee stays accrued and the tick tries again" + ); + None + } + } + } + + /// Fold a finished retry attempt into the pacing and log it under the one policy both paths + /// share ([`log_remit_outcome`]). Returns the (jittered) delay to sleep before the next attempt. + /// + /// `None` means the attempt's thread ended without reporting (it panicked): that is a failure + /// for the pacing, and it is logged as one. + fn settle_retry_remit(&self, report: Option) -> Duration { + use crate::fee_remit::RemitReport; + let report = report.unwrap_or_else(|| RemitReport { + outcome: Err("the remittance thread ended without reporting (panicked)".to_owned()), + lines: Vec::new(), + }); + let (pacing, next, computed) = { + let mut guard = self.remit_pacing_lock(); + let pacing = guard.observe(&report, now_unix()); + (pacing, guard.next_delay(), guard.computed_delay()) + }; + log_remit_outcome("retry", &report, &pacing, Some(next), computed); + next } /// Mark a job failed (best-effort; a fail-mark that itself errors is logged, never propagated — @@ -13988,7 +14571,8 @@ mod tests { mint_fee_sats: Some(1), fee_bps: 1000, fee_sats: 10, - received_at_unix: 5000 + received_at_unix: 5000, + remittance_id: None, }] ); assert!( @@ -14026,7 +14610,8 @@ mod tests { mint_fee_sats: Some(1), fee_bps: 200, fee_sats: 2, - received_at_unix: 5000 + received_at_unix: 5000, + remittance_id: None, }] ); let _ = std::fs::remove_dir_all(&root); @@ -14106,6 +14691,215 @@ mod tests { } } + // Stage 2a, addendum 1 rule 1: the remittance follows a receipt journaled NEW and nothing else. + // A replayed wrap (`Duplicate`) already paid the job once and must not pay the fee twice; a + // failed write journaled nothing. Checked against every outcome the collect write can produce. + #[test] + fn remittance_follows_only_a_new_receipt_never_a_duplicate_or_an_error() { + use crate::seller_node::store::{Collected, StoreError}; + assert!(remit_follows_collect(&Ok(Collected::New))); + assert!(!remit_follows_collect(&Ok(Collected::Duplicate))); + assert!(!remit_follows_collect(&Err(StoreError( + "disk full".to_owned() + )))); + } + + // Stage 2a, addendum 1 gate 2b — on the REAL collect write against an awarded job: the + // remittance fails (the LNURL host is unreachable, then the mint refuses the melt), and the + // collect still journaled the receipt, the job is still PAID, and the unremitted balance is + // intact — every failure journaled as an attempt, none of it propagated. This is the property + // that keeps a broken payout from breaking a seller's business. + #[test] + fn a_failed_remittance_leaves_the_receipt_journaled_the_job_paid_and_the_balance_intact() { + use crate::fee_remit::test_support::Fake; + use crate::fee_remit::{RemitOutcome, RemitTrigger, remit_best_effort}; + use crate::seller_node::store::{ + Collected, JobState, RemitAttemptOutcome, RemitAttemptTrigger, RemittanceState, + }; + + let seller = nostr_sdk::prelude::Keys::generate().public_key().to_hex(); + let creq = gateway::creq::build_seller_creq( + &"a".repeat(64), + 100, + "sat", + &["https://testnut.cashudevkit.org".to_owned()], + &seller, + ) + .expect("creq"); + let job = "c".repeat(64); + let (store, root) = store_with_awarded_job(&creq, &job, &"b".repeat(64), 4242); + let (fee_bps, platform_fee) = platform_fee_at_collect(100); + let collected = store.collect_receipt( + &"e".repeat(64), + &job, + 100, + fees(1, fee_bps, platform_fee), + 5000, + ); + assert_eq!(collected, Ok(Collected::New)); + assert!( + remit_follows_collect(&collected), + "a NEW receipt starts the attempt" + ); + assert_eq!( + store.job_state(&job).expect("state"), + Some(JobState::Paid), + "paid BEFORE any remittance runs" + ); + + // Attempt 1: the LNURL host is down. Nothing propagates; nothing moves. + let mut fake = Fake::new(|_| 1); + fake.pay_request_error = Some("agi.cash: dns failure".to_owned()); + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 5001); + assert_eq!(report.outcome, Err("agi.cash: dns failure".to_owned())); + assert!(fake.melts.is_empty()); + + // Attempt 2: the mint refuses the melt after the plan is journaled and the fence admitted + // it. The row stays SPENDING (addendum 4 §1) until the mint's verdict on its quote. + let mut fake = Fake::new(|_| 1); + fake.melt_results = vec![Err("insufficient funds for melt".to_owned())]; + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 5002); + assert!( + matches!(report.outcome, Ok(RemitOutcome::MeltFailed { .. })), + "{report:?}" + ); + assert_eq!(fake.melts.len(), 1); + + // The collect is untouched by either failure: receipt present, job paid, fee still owed. + assert!(store.has_receipt(&job).expect("has_receipt")); + assert_eq!(store.job_state(&job).expect("state"), Some(JobState::Paid)); + let accrued = store.accrued_fees().expect("read"); + assert_eq!(accrued.total_fee_sats, 10); + assert_eq!(accrued.remitted_fee_sats, 0, "nothing was paid"); + assert_eq!( + accrued.unremitted_fee_sats + accrued.in_flight_fee_sats, + 10, + "the fee is still owed — pinned to the in-flight row until the next attempt reconciles it" + ); + assert_eq!( + store + .in_flight_remittance() + .expect("query") + .map(|row| row.state), + Some(RemittanceState::Spending), + "the fence admitted the melt before the mint refused it: SPENDING, resolved by the mint's verdict" + ); + + // Both failures are journaled as attempts, newest first, for the operator to read. + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!(attempts.len(), 2); + assert!( + attempts + .iter() + .all(|a| a.trigger == RemitAttemptTrigger::Collect) + ); + assert!( + attempts + .iter() + .all(|a| a.outcome == RemitAttemptOutcome::Failed) + ); + assert_eq!(attempts[1].detail, "agi.cash: dns failure"); + assert!( + attempts[0] + .detail + .starts_with("melt failed: insufficient funds for melt") + ); + + // The next attempt (the next collect, or the operator) reconciles the SPENDING row. While + // the mint says only UNPAID it HOLDS (addendum 4 §1.2: the melt that errored may have + // reached the mint) — nothing paid, nothing released, the job untouched… + fake.status = Ok(Some(crate::wallet_ops::MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: "paid-quote-lnbc-fake-9-2".to_owned(), + state: crate::wallet_ops::MeltQuoteState::Unpaid, + amount_sats: 9, + fee_reserve_sats: 1, + expiry_unix: u64::MAX, + })); + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 5003); + assert!( + matches!( + report.outcome, + Ok(RemitOutcome::Refused( + crate::fee_remit::Refusal::SpendingHeld { .. } + )) + ), + "{report:?}" + ); + assert_eq!(store.job_state(&job).expect("state"), Some(JobState::Paid)); + assert_eq!(store.accrued_fees().expect("read").remitted_fee_sats, 0); + // …and when the mint reports the quote FAILED it STILL holds (addendum 6 §1.2): the mint + // pays a FAILED quote (CDK 0.17.2), so FAILED is not cancellation and releasing on it could + // make the same 10 sats payable twice. No melt, nothing released, the job untouched. A + // payment is scripted so that a release would be caught as a second debit. + fake.status = Ok(Some(crate::wallet_ops::MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: "paid-quote-lnbc-fake-9-2".to_owned(), + state: crate::wallet_ops::MeltQuoteState::Failed, + amount_sats: 9, + fee_reserve_sats: 1, + expiry_unix: u64::MAX, + })); + fake.melt_results = vec![Ok((9, 1))]; + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 5004); + assert!( + matches!( + report.outcome, + Ok(RemitOutcome::Refused( + crate::fee_remit::Refusal::SpendingHeld { .. } + )) + ), + "{report:?}" + ); + assert_eq!(fake.melts.len(), 1, "no second melt on FAILED"); + assert_eq!( + fake.melt_results.len(), + 1, + "the scripted payment was never reached" + ); + assert_eq!(store.job_state(&job).expect("state"), Some(JobState::Paid)); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + (accrued.remitted_fee_sats, accrued.in_flight_fee_sats), + (0, 10), + "held: nothing paid, receipts still pinned" + ); + // The one exit: the mint reports the bound quote PAID — settled by reconciliation, no melt + // by this run, the receipt and the job still exactly as the collect left them. + fake.status = Ok(Some(crate::wallet_ops::MeltQuoteStatus { + mint_url: "https://mint.example".to_owned(), + quote_id: "paid-quote-lnbc-fake-9-2".to_owned(), + state: crate::wallet_ops::MeltQuoteState::Paid, + amount_sats: 9, + fee_reserve_sats: 1, + expiry_unix: u64::MAX, + })); + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 5005); + assert!( + matches!( + report.outcome, + Ok(RemitOutcome::Refused( + crate::fee_remit::Refusal::NothingUnremitted + )) + ), + "settled by reconciliation, then nothing left to remit: {report:?}" + ); + assert_eq!(fake.melts.len(), 1, "settled without a second melt"); + assert_eq!(store.job_state(&job).expect("state"), Some(JobState::Paid)); + assert!(store.has_receipt(&job).expect("has_receipt")); + assert_eq!(store.accrued_fees().expect("read").remitted_fee_sats, 10); + assert_eq!( + store + .remittances() + .expect("rows") + .into_iter() + .map(|row| row.state) + .collect::>(), + vec![RemittanceState::Settled] + ); + let _ = std::fs::remove_dir_all(&root); + } + // ── Resume execution across a process restart (invariant 4, fallback form) ─────────────────── // TOOTH (invariant 4) — the resume selection re-drives only jobs left mid-flight (awarded / @@ -14606,6 +15400,516 @@ mod tests { /// power cut publish nothing at all, and leave the seat's last `accepting=y` standing exactly as /// the issue describes. Consumer-side recency filtering stays the only cover for those. /// + /// Stage 2a, addendum 2, gate 2e: **a requested shutdown ends the platform-fee retries** — after + /// the loop exits, no further attempt is made. This is the property that keeps the retry from + /// outliving the node: the retry is an arm of the loop's own `select!`, and the attempt it + /// starts runs on a thread the node owns through the single-flight permit and drains on exit + /// (addendum 3 RULING 2 — the strengthened case, a payment pending across the request, is the + /// next test). This one drives the real loop against an empty ledger: with the tick's bounds + /// shortened it watches several attempts START, asks the loop to stop, waits for it to RETURN, + /// then waits many more base delays and asserts the started count did not move. + /// + /// Each attempt here runs the real entry point against an empty ledger — zero balance, so it + /// refuses before any network (`Refusal::NothingUnremitted`, the steady state) and touches + /// nothing. What is under test is the clock, not the payment. + /// + /// RED ON REVERT: move the retry into a spawned task that the loop does not own and the count + /// keeps climbing after the join. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn a_requested_shutdown_ends_the_platform_fee_retries() { + use std::sync::atomic::Ordering; + use_fast_backfill_tick(); + let fixture = PGateRelay::start(Duration::from_millis(0)).await; + let root = throwaway_root("remit-retry-stops-with-loop"); + let mut home = crate::home::bootstrap(&root).expect("bootstrap home"); + home.config.relay_url = fixture.url(); + home.config.seller = Some(seller_cfg(1, false)); + assert!( + home.config.platform_fee.auto_remit, + "harness check: the switch is ON by default, so the tick is live" + ); + let runner = SellerNodeRunner::boot(home).await.expect("boot runner"); + let base = Duration::from_millis(25); + let started = runner.remit_retry_bounds_for_test(base, Duration::from_millis(100)); + assert_eq!(started.load(Ordering::SeqCst), 0, "nothing runs at startup"); + let shutdown = runner.shutdown_handle(); + + let local = tokio::task::LocalSet::new(); + let loop_handle = local.spawn_local(async move { runner.run().await }); + let joined = local + .run_until(async { + // Harness check: the tick is live — several attempts start while the loop runs. + let deadline = tokio::time::Instant::now() + FIXTURE_WAIT; + while started.load(Ordering::SeqCst) < 3 { + assert!( + tokio::time::Instant::now() < deadline, + "harness check: the retry tick never started three attempts (saw {})", + started.load(Ordering::SeqCst) + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!( + shutdown.request("test-requested stop"), + "the loop must accept a shutdown request" + ); + tokio::time::timeout(FIXTURE_WAIT, loop_handle).await + }) + .await; + let outcome = joined + .expect("the run loop must RETURN on a shutdown request, not have to be killed") + .expect("the loop task must not panic"); + assert!( + outcome.is_ok(), + "a requested shutdown is a clean exit: {outcome:?}" + ); + + // The loop has returned. An attempt that was already in flight when it did may still be + // finishing on its thread; that is not a NEW attempt. Wait far longer than any delay the + // shortened bounds allow, then the count must not have moved. + let at_exit = started.load(Ordering::SeqCst); + assert!(at_exit >= 3); + tokio::time::sleep(base * 40).await; + assert_eq!( + started.load(Ordering::SeqCst), + at_exit, + "no remittance attempt may START after the loop exited — the retry lives in the loop \ + and ends with it" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// A node whose ledger owes 10 sats, whose remittance attempts run against scripted effects + /// that BLOCK inside the melt until `gate` is released — a payment pending at the mint. + /// Returns the runner, the second connection to its ledger, the tick's started-counter, and + /// the gate. + async fn runner_with_a_pending_payment( + label: &str, + drain_bound: Option, + ) -> ( + SellerNodeRunner, + crate::seller_node::store::SellerStore, + Arc, + Arc, + std::path::PathBuf, + PGateRelay, + ) { + use crate::fee_remit::test_support::{Fake, Gate}; + use crate::seller_node::store::{ReceiptFees, SellerStore}; + use_fast_backfill_tick(); + let fixture = PGateRelay::start(Duration::from_millis(0)).await; + let root = throwaway_root(label); + let mut home = crate::home::bootstrap(&root).expect("bootstrap home"); + home.config.relay_url = fixture.url(); + home.config.seller = Some(seller_cfg(1, false)); + // Seed the node's OWN ledger (the file it opens at boot) through a second connection: one + // collected receipt owing a 10-sat platform fee. + let store = + SellerStore::open(root.join(crate::seller_node::STATE_DB_FILE)).expect("open store"); + store + .collect_receipt( + "receipt-1", + "job-1", + 100, + ReceiptFees { + mint_fee_sats: 1, + fee_bps: 1000, + fee_sats: 10, + }, + 1, + ) + .expect("collect"); + let runner = SellerNodeRunner::boot(home).await.expect("boot runner"); + let started = runner + .remit_retry_bounds_for_test(Duration::from_millis(25), Duration::from_millis(100)); + let gate = Gate::new(); + let gate_for_fake = Arc::clone(&gate); + runner.remit_effects_for_test( + Arc::new(move || { + let mut fake = Fake::new(|_| 1); + fake.melt_results = vec![Ok((9, 1))]; + fake.melt_gate = Some(Arc::clone(&gate_for_fake)); + Box::new(fake) + }), + drain_bound, + ); + (runner, store, started, gate, root, fixture) + } + + /// Stage 2a, addendum 3 RULING 2 — gate 2e STRENGTHENED: a requested shutdown **drains** a + /// payment in flight; it does not race it. The retry tick starts an attempt that journals its + /// planned row and then blocks INSIDE the melt (proofs with the mint, no answer yet). The loop + /// is asked to stop while that payment is pending. It must NOT return while the melt is + /// pending, must start nothing new, and when the mint finally answers the attempt runs to its + /// end — the row is settled by the melt — and only then does `run` return. + /// + /// RED ON REVERT: drop `drain_remit_in_flight` from `run_loop` and the loop returns with the + /// melt still pending (first assertion); drop the `remit_closed` check and a collect-path call + /// could start a second attempt after serving ended. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn a_requested_shutdown_drains_a_pending_remittance_before_the_loop_returns() { + use crate::seller_node::store::{RemittanceState, SettledBy}; + use std::sync::atomic::Ordering; + let base = Duration::from_millis(25); + let (runner, store, started, gate, root, _fixture) = + runner_with_a_pending_payment("remit-shutdown-drains", None).await; + let shutdown = runner.shutdown_handle(); + + let local = tokio::task::LocalSet::new(); + let loop_handle = local.spawn_local(async move { runner.run().await }); + let joined = local + .run_until(async { + // Harness check: an attempt reaches the melt and stops there — a pending payment. + let deadline = tokio::time::Instant::now() + FIXTURE_WAIT; + while !gate.arrived() { + assert!( + tokio::time::Instant::now() < deadline, + "harness check: no remittance attempt reached the melt" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + let in_flight = started.load(Ordering::SeqCst); + assert!( + shutdown.request("test-requested stop"), + "the loop must accept a shutdown request" + ); + // The payment is pending: the loop must stay, draining, well past any tick delay. + tokio::time::sleep(base * 12).await; + assert!( + !loop_handle.is_finished(), + "the loop returned while a melt was pending — RULING 2: shutdown DRAINS the \ + owned attempt, it does not abandon it" + ); + assert_eq!( + started.load(Ordering::SeqCst), + in_flight, + "nothing new may start once serving has ended" + ); + assert_eq!( + store + .remittances() + .expect("remittances") + .last() + .map(|row| row.state), + Some(RemittanceState::Spending), + "the pending payment's row is journaled SPENDING (admitted, melt in progress) while the mint has not answered" + ); + // The mint answers. The attempt finishes; the drain sees the permit drop. + gate.release(); + tokio::time::timeout(FIXTURE_WAIT, loop_handle).await + }) + .await; + let outcome = joined + .expect("run must RETURN once the in-flight attempt finished") + .expect("the loop task must not panic"); + assert!( + outcome.is_ok(), + "a drained shutdown is a clean exit: {outcome:?}" + ); + + // The payment ran to its end — not cancelled, not left Planned: settled by the melt. + let rows = store.remittances().expect("remittances"); + assert_eq!(rows.len(), 1, "exactly one attempt was journaled"); + assert_eq!(rows[0].state, RemittanceState::Settled); + assert_eq!(rows[0].settled_by, Some(SettledBy::Melt)); + assert_eq!(rows[0].melt_fee_sats, Some(1)); + let at_exit = started.load(Ordering::SeqCst); + tokio::time::sleep(base * 40).await; + assert_eq!( + started.load(Ordering::SeqCst), + at_exit, + "no remittance attempt may START after the loop exited" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// Addendum 3 RULING 2, the other half: the drain's wait is BOUNDED. When the pending payment + /// does not finish inside the bound, `run` returns anyway — after the bound, not before — with + /// the attempt still pending (its row Spending, bound to its quote), and the attempt is + /// abandoned by the WAIT only: + /// its thread finishes on its own once the mint answers, and the row settles. + /// + /// RED ON REVERT: drop the bound from `drain_remit_in_flight` and the join times out. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn a_shutdown_drain_gives_up_waiting_at_its_bound_but_never_cancels_the_attempt() { + use crate::seller_node::store::RemittanceState; + let bound = Duration::from_millis(300); + let (runner, store, _started, gate, root, _fixture) = + runner_with_a_pending_payment("remit-shutdown-drain-bound", Some(bound)).await; + let shutdown = runner.shutdown_handle(); + + let local = tokio::task::LocalSet::new(); + let loop_handle = local.spawn_local(async move { runner.run().await }); + let (joined, waited) = local + .run_until(async { + let deadline = tokio::time::Instant::now() + FIXTURE_WAIT; + while !gate.arrived() { + assert!( + tokio::time::Instant::now() < deadline, + "harness check: no remittance attempt reached the melt" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!(shutdown.request("test-requested stop")); + let asked = tokio::time::Instant::now(); + let joined = tokio::time::timeout(FIXTURE_WAIT, loop_handle).await; + (joined, asked.elapsed()) + }) + .await; + let outcome = joined + .expect("run must RETURN at the drain bound even though the melt is still pending") + .expect("the loop task must not panic"); + assert!(outcome.is_ok(), "{outcome:?}"); + assert!( + waited >= bound, + "run returned after {waited:?}, before the {bound:?} drain bound elapsed" + ); + // Abandoned by the wait, not cancelled: still pending, row still in flight (SPENDING: the + // fence admitted the melt before it blocked inside the mint call)… + assert!(gate.arrived(), "harness check: the melt is still blocked"); + let rows = store.remittances().expect("remittances"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, RemittanceState::Spending); + // …and when the mint answers, the thread finishes its attempt on its own. + gate.release(); + let deadline = std::time::Instant::now() + FIXTURE_WAIT; + loop { + let rows = store.remittances().expect("remittances"); + if rows[0].state == RemittanceState::Settled { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the abandoned attempt never settled its row after the mint answered" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let _ = std::fs::remove_dir_all(&root); + } + + /// Addendum 3 §3: a SHARED outcome — the collect thread's attempt moved the backoff — re-arms + /// the loop's LIVE timer. The rule is `rearm_deadline`, proven here against a real `Sleep` + /// under Tokio's paused clock: a success pulls a 15-minute pending sleep back to the drawn + /// base-streak delay and the sleep fires there, not at its old deadline; a failure never + /// shortens a deadline — it extends a too-short one to the drawn delay and keeps a longer one. + #[tokio::test(start_paused = true)] + async fn a_shared_outcome_re_arms_the_live_retry_timer() { + let now = tokio::time::Instant::now(); + // A boot floor already behind us: the re-arm rule alone is under test here. + let floor = now; + let sleep = tokio::time::sleep(Duration::from_secs(900)); + tokio::pin!(sleep); + // Success (streak 0): reset to the drawn delay, however far away the old deadline was. + let deadline = rearm_deadline(sleep.deadline(), now, 0, Duration::from_secs(12), floor); + assert_eq!(deadline, now + Duration::from_secs(12)); + sleep.as_mut().reset(deadline); + assert!( + tokio::time::timeout(Duration::from_secs(11), sleep.as_mut()) + .await + .is_err(), + "the re-armed timer must not fire before its new deadline" + ); + assert!( + tokio::time::timeout(Duration::from_secs(2), sleep.as_mut()) + .await + .is_ok(), + "the re-armed timer fires at its new deadline, not the old 15-minute one" + ); + // Failure (streak 2): a far deadline is kept; a too-short one is pushed out to the draw. + let now = tokio::time::Instant::now(); + assert_eq!( + rearm_deadline( + now + Duration::from_secs(600), + now, + 2, + Duration::from_secs(90), + floor + ), + now + Duration::from_secs(600) + ); + assert_eq!( + rearm_deadline( + now + Duration::from_secs(5), + now, + 2, + Duration::from_secs(90), + floor + ), + now + Duration::from_secs(90) + ); + } + + /// Addendum 4 §2.2 (RULING 1 under re-arm): the node boots, its first retry is armed in + /// [30 s, 60 s]; a collect SUCCEEDS at 5 s and the shared backoff (streak 0) draws 8 s. Without + /// the floor the re-arm would fire the first retry at 13 s after boot. With it, the re-armed + /// `Sleep` — a real one, under Tokio's paused clock — fires at 30 s after boot and not one + /// second sooner. A failure re-arm inside the first 30 s is floored the same way. + #[tokio::test(start_paused = true)] + async fn the_first_retry_never_fires_before_thirty_seconds_after_boot_even_after_a_collect_success() + { + let boot = tokio::time::Instant::now(); + let floor = boot + crate::fee_remit::RETRY_BASE; + assert_eq!(crate::fee_remit::RETRY_BASE, Duration::from_secs(30)); + // Boot draw, as `run_loop` arms it: somewhere in [30 s, 60 s]; take 45 s. + let sleep = tokio::time::sleep(Duration::from_secs(45)); + tokio::pin!(sleep); + // 5 s after boot a collect-path attempt succeeds; the shared backoff draws 8 s. + tokio::time::advance(Duration::from_secs(5)).await; + let now = tokio::time::Instant::now(); + let deadline = rearm_deadline(sleep.deadline(), now, 0, Duration::from_secs(8), floor); + assert_eq!( + deadline, floor, + "8 s after a success at 5 s would be 13 s after boot: clamped to boot + 30 s" + ); + sleep.as_mut().reset(deadline); + assert!( + tokio::time::timeout(Duration::from_secs(24), sleep.as_mut()) + .await + .is_err(), + "the first retry must not fire at 29 s after boot" + ); + assert!( + tokio::time::timeout(Duration::from_secs(1), sleep.as_mut()) + .await + .is_ok(), + "the first retry fires at 30 s after boot" + ); + // A failure re-arm inside the first 30 s is floored too: a 12 s draw at 10 s after boot, + // against a pending 45 s boot deadline, keeps 45 s (never shortens) — and a pending 20 s + // deadline would be pushed to the floor, not to 22 s. + let boot = tokio::time::Instant::now(); + let floor = boot + crate::fee_remit::RETRY_BASE; + let at_ten = boot + Duration::from_secs(10); + assert_eq!( + rearm_deadline( + boot + Duration::from_secs(45), + at_ten, + 1, + Duration::from_secs(12), + floor + ), + boot + Duration::from_secs(45) + ); + assert_eq!( + rearm_deadline( + boot + Duration::from_secs(20), + at_ten, + 1, + Duration::from_secs(12), + floor + ), + floor + ); + // Past the floor the rule is exactly addendum 3's: the floor changes nothing. + let later = boot + Duration::from_secs(600); + assert_eq!( + rearm_deadline( + later + Duration::from_secs(900), + later, + 0, + Duration::from_secs(12), + floor + ), + later + Duration::from_secs(12) + ); + } + + /// Addendum 4 §2.1: every attempt logs at most ONE line, the first failure included. A first + /// DNS failure (the payout host unreachable) used to log the attempt's lines and then a summary; + /// now the detail — destination, the balance it saw, the error — is folded into the one line. + /// Every other pacing outcome is one line too. + #[test] + fn a_first_failure_logs_exactly_one_line_with_its_detail_folded_in() { + use crate::fee_remit::test_support::Fake; + use crate::fee_remit::{Pacing, RemitBackoff, RemitTrigger, remit_best_effort}; + use crate::seller_node::store::{ReceiptFees, SellerStore}; + let root = std::env::temp_dir().join(format!( + "maxplayer-remit-one-line-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("mk root"); + let store = SellerStore::open(root.join(crate::seller_node::STATE_DB_FILE)).expect("open"); + store + .collect_receipt( + "r1", + "job-1", + 100, + ReceiptFees { + mint_fee_sats: 1, + fee_bps: 1000, + fee_sats: 10, + }, + 1, + ) + .expect("collect"); + let mut fake = Fake::new(|_| 1); + fake.pay_request_error = Some("agi.cash: dns failure".to_owned()); + let report = remit_best_effort(&store, &mut fake, RemitTrigger::Collect, 100); + assert_eq!(report.outcome, Err("agi.cash: dns failure".to_owned())); + assert!( + !report.lines.is_empty(), + "the attempt printed its balance before the host failed: {report:?}" + ); + let mut backoff = RemitBackoff::new(); + let pacing = backoff.observe(&report, 100); + assert_eq!(pacing, Pacing::FirstFailure); + let (volume, lines) = remit_outcome_lines( + "remit (after job_id=job-1)", + &report, + &pacing, + None, + backoff.computed_delay(), + ); + assert_eq!(volume, RemitLogVolume::Normal); + assert_eq!(lines.len(), 1, "one line for the first failure: {lines:#?}"); + let line = &lines[0]; + assert!(line.contains("agi.cash: dns failure"), "the error: {line}"); + assert!( + line.contains("destination maxplayer@agi.cash"), + "the destination: {line}" + ); + assert!( + line.contains("10 sats unremitted"), + "the balance it saw: {line}" + ); + assert!(line.contains("streak 1"), "{line}"); + assert!( + line.contains("next attempt on the retry tick, re-armed to within 60s"), + "the backoff this failure started (streak 1 ⇒ base × 2): {line}" + ); + assert!(!line.contains('\n'), "one line means one line: {line:?}"); + // Every other outcome is one line as well. + let second = backoff.observe(&report, 200); + assert!(matches!(second, Pacing::RepeatFailure { streak: 2, .. })); + for (pacing, expected_volume) in [ + (second, RemitLogVolume::Normal), + (Pacing::Paid, RemitLogVolume::Normal), + ( + Pacing::Recovered { + failed_attempts: 2, + owed_for_secs: 100, + }, + RemitLogVolume::Normal, + ), + (Pacing::Idle, RemitLogVolume::Verbose), + ] { + let (volume, lines) = remit_outcome_lines( + "retry", + &report, + &pacing, + Some(Duration::from_secs(41)), + Duration::from_secs(60), + ); + assert_eq!(volume, expected_volume, "{pacing:?}"); + assert_eq!(lines.len(), 1, "{pacing:?}: {lines:#?}"); + } + let _ = std::fs::remove_dir_all(&root); + } + /// RED ON REVERT: drop the `self.publish_retraction().await` from `run_loop` (or the /// `shutdown::next_request` arm from the select, which strands the loop so the join times out) /// and this goes red. diff --git a/crates/maxplayer-core/src/seller_node/store.rs b/crates/maxplayer-core/src/seller_node/store.rs index 92e442358..376f1b590 100644 --- a/crates/maxplayer-core/src/seller_node/store.rs +++ b/crates/maxplayer-core/src/seller_node/store.rs @@ -26,11 +26,32 @@ use crate::gateway::EventDraft; /// Current on-disk schema version. v8 added `receipts.fee_bps` / `receipts.fee_sats` (the platform /// fee, stage 1 — journaled, not remitted). v9 added `receipts.mint_fee_sats` (the mint's own swap /// fee, so a receipt shows every figure between what the buyer paid and what the seller keeps). -pub const SCHEMA_VERSION: i64 = 9; - -/// The platform fee (stage 1) as journaled so far: what is owed on paper, and the figures around -/// it. Returned by [`SellerStore::accrued_fees`]. Nothing in this stage moves the balance it -/// reports. +/// v10 (stage 2a) added the `fee_remittances` table and `receipts.remittance_id` — which +/// remittance, if any, discharged each receipt's platform fee — so the unremitted balance is a +/// query and a paid fee can never be paid twice; and `fee_remit_attempts`, the journal of every +/// attempt to pay (automatic or by command) with its outcome, so a failing payout is visible. +/// v11 (stage 2a, addendum 3) added four nullable columns to `fee_remittances`: `owner` and +/// `lease_until_unix` — the durable ownership of a `planned` row, so reconciliation in another +/// process can never release a live payer's intent — and `melt_fee_reserve_sats` / `settled_by`, +/// so a settlement records the reserve of the quote that paid and how the row was settled (by the +/// melt itself, or by reconciliation against the mint, which can report the quote PAID but not the +/// fee it kept). +/// v12 (stage 2a, addendum 4) added one nullable column, `spending_since_unix`: set by the payer's +/// compare-and-set immediately before the melt, it marks the planned row SPENDING — admitted to the +/// irreversible spend — and a spending row is never released on lease expiry, only on a quote the +/// mint reports terminal. +/// v13 (stage 2a, addendum 5) added one nullable column, `spending_quote_id`: the melt quote the +/// compare-and-set BOUND to the row at admission. The payer pays exactly that quote, by id, and never +/// raises another for the row; reconciliation of a spending row asks the mint about that quote by id +/// and releases the row only on a transition naming it ([`SellerStore::release_remittance`]). +pub const SCHEMA_VERSION: i64 = 13; + +/// The platform fee as journaled so far: what is owed on paper, what has been remitted, and the +/// figures around them. Returned by [`SellerStore::accrued_fees`]. A query and nothing more — the +/// only things that move the unremitted balance are the remittance writes +/// [`SellerStore::plan_remittance`] / [`SellerStore::settle_remittance`] / +/// [`SellerStore::release_remittance`], driven by `crate::fee_remit` (automatically after a collect, +/// or by `maxplayer seller fees remit --confirm`). #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct AccruedFees { /// Sum of `amount_sats` (the offer face — what buyers paid) over every receipt ever collected. @@ -42,8 +63,17 @@ pub struct AccruedFees { /// Receipts collected before the mint fee was journaled (schema < v9). Their mint fee is /// unknown — not zero — and so is what the seller kept of them. pub rows_without_mint_fee: usize, - /// Sum of `fee_sats` (the platform fee) over every receipt ever collected. + /// Sum of `fee_sats` (the platform fee) over every receipt ever collected — accrued all-time, + /// remitted or not. pub total_fee_sats: u64, + /// Sum of `fee_sats` over receipts NOT yet discharged by any remittance (`remittance_id IS + /// NULL`). This is the figure the remit command pays. + pub unremitted_fee_sats: u64, + /// Sum of `fee_sats` over receipts discharged by a SETTLED remittance. + pub remitted_fee_sats: u64, + /// Sum of `fee_sats` over receipts pinned to a remittance that is still `planned` — money that + /// may be in flight at the mint. Non-zero only between a `--confirm` and its settle/fail. + pub in_flight_fee_sats: u64, /// One entry per receipt row, oldest collection first — in practice one per paid job. pub by_job: Vec, } @@ -96,6 +126,10 @@ pub struct JobFeeAccrual { /// `floor(amount_sats × fee_bps / 10_000)`, as written at collection. pub fee_sats: u64, pub received_at_unix: i64, + /// The remittance that discharged this receipt's platform fee (`fee_remittances.remittance_id`), + /// or `None` while it is unremitted. Set when a remittance is planned, cleared if that + /// remittance fails, kept once it settles. + pub remittance_id: Option, } impl JobFeeAccrual { @@ -108,6 +142,478 @@ impl JobFeeAccrual { } } +/// Lifecycle of one remittance attempt. `Planned` and `Spending` are the two states under which +/// money may be moving — together the one in-flight row: at most ONE row may be in flight at a time +/// (enforced by a partial unique index AND by [`SellerStore::plan_remittance`]), which is what makes +/// a second `--confirm` a no-op rather than a second payment. +/// +/// On disk (addendum 4 §1, ledger): `Spending` is the in-flight row (`state = 'planned'`) with +/// `spending_since_unix` set — a nullable column added in v12, never a new value in the `state` +/// column, because SQLite cannot widen an existing table's CHECK additively and a store written by +/// an earlier binary of this branch already carries the three-value CHECK. Every reader derives +/// the state from both columns ([`Self::from_columns`]); every writer of the `state` column writes +/// only the three CHECK values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemittanceState { + /// Journaled before the melt: the receipts it covers are pinned to it and the invoice is known. + /// The melt has NOT been admitted: nothing has been spent against this row. + Planned, + /// The owner's compare-and-set admitted the melt ([`SellerStore::admit_remittance_spend`]) and + /// BOUND the one quote the payer may pay (`spending_quote_id`): the payer may be mid-melt on that + /// quote, so the row is released only when the mint reports THAT quote terminal — never on lease + /// expiry, never on the state of some other quote for the same invoice (addendum 4 §1.2, + /// addendum 5 §1). + Spending, + /// The melt settled; the receipts stay discharged. + Settled, + /// The melt did not happen (mint reports the quote failed or expired, or no quote was ever + /// raised, or the owner refused before spending); the receipts are released back to unremitted. + Failed, +} + +impl RemittanceState { + /// The state's name, for messages. `Spending` is never written to the `state` column. + pub fn as_str(self) -> &'static str { + match self { + Self::Planned => "planned", + Self::Spending => "spending", + Self::Settled => "settled", + Self::Failed => "failed", + } + } + + /// The `state` column's value. Only the three CHECK values exist on disk (see the type's doc). + fn column_value(self) -> &'static str { + match self { + Self::Planned | Self::Spending => "planned", + Self::Settled => "settled", + Self::Failed => "failed", + } + } + + /// The state as read from the `state` column ALONE — `Spending` is indistinguishable from + /// `Planned` here; use [`Self::from_columns`] where the row is at hand. + fn parse(raw: &str) -> Result { + match raw { + "planned" => Ok(Self::Planned), + "settled" => Ok(Self::Settled), + "failed" => Ok(Self::Failed), + other => Err(StoreError(format!("unknown remittance state {other:?}"))), + } + } + + /// The state as the two columns encode it: a `planned` row with `spending_since_unix` set is + /// `Spending`. + fn from_columns(raw: &str, spending_since_unix: Option) -> Result { + match (Self::parse(raw)?, spending_since_unix) { + (Self::Planned, Some(_)) => Ok(Self::Spending), + (state, _) => Ok(state), + } + } + + /// Whether the row is the one in flight — planned or spending — i.e. money may be moving. + pub fn is_in_flight(self) -> bool { + matches!(self, Self::Planned | Self::Spending) + } +} + +/// What the remit command knows BEFORE it pays, journaled by [`SellerStore::plan_remittance`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemittancePlan { + /// The idempotency key: the bolt11 payment hash (hex). One invoice, one row, ever. + pub payment_hash: String, + /// The unremitted platform fee this attempt discharges, in sats. Must equal the store's own + /// unremitted sum at plan time or the plan is refused. + pub gross_sats: u64, + /// The invoice amount — what the platform receives: gross minus the melt fee reserve. + pub net_sats: u64, + /// The melt fee reserve the estimate quoted on the net invoice — the plan's ceiling figure. + /// The spend re-checks the reserve of the quote it actually pays under (addendum 3 §1). + pub melt_fee_reserve_sats: u64, + /// The Lightning address literal being paid, journaled so a later change leaves history. + pub destination: String, + /// The invoice being paid, kept so an interrupted attempt can be reconciled against the mint. + pub bolt11: String, + /// The melt quote id from the estimate, if one was raised. + pub melt_quote_id: Option, +} + +/// The invoice a still-PLANNED row is re-pointed at when the live quote's fee reserve differs from +/// the estimate the row was planned on and the planned invoice would not confirm (addendum 10 +/// §1.4). Same gross, same receipts, same row: only the invoice-side figures move, by +/// [`SellerStore::replan_remittance`], BEFORE any spend is prepared. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemittanceReplan { + /// The re-planned invoice amount; must still fit under the row's gross. + pub net_sats: u64, + /// The new invoice's payment hash (hex). The row's `remittance_id` — its receipts' pin — stays + /// the ORIGINAL hash; `payment_hash` is what the ledger and the mint are reconciled on. + pub payment_hash: String, + pub bolt11: String, + /// The live quote's fee reserve, the figure the re-plan was bounded by. + pub melt_fee_reserve_sats: u64, + /// The melt quote raised on the new invoice — the one the fence will bind. + pub melt_quote_id: Option, +} + +/// How a `settled` remittance row came to be settled — the row says so itself, because the two +/// paths can observe different things (addendum 3 §2.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettledBy { + /// The melt confirmed in this process: net paid, actual melt fee and paying quote all observed. + Melt, + /// Reconciliation: the mint reports the quote PAID. The quote carries amount, fee reserve and + /// id; the fee the mint actually kept is not reported for a quote paid by another run, so the + /// row records the reserve (the fee's ceiling) and leaves the actual fee unobserved — said so, + /// never invented. + Reconciliation, +} + +impl SettledBy { + pub fn as_str(self) -> &'static str { + match self { + Self::Melt => "melt", + Self::Reconciliation => "reconciliation", + } + } + + fn parse(raw: &str) -> Result { + match raw { + "melt" => Ok(Self::Melt), + "reconciliation" => Ok(Self::Reconciliation), + other => Err(StoreError(format!("unknown settled_by {other:?}"))), + } + } +} + +/// What a settlement observed, for [`SellerStore::settle_remittance`]. `None` fields are +/// "not observed", and the row keeps its planned figure (net) or NULL (fee); never a guess. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemitSettlement { + pub net_paid_sats: Option, + pub melt_fee_sats: Option, + /// The fee reserve of the quote that paid — the actual fee's ceiling. Observable on both paths. + pub melt_fee_reserve_sats: Option, + pub melt_quote_id: Option, + pub settled_by: SettledBy, +} + +/// One row of `fee_remittances`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FeeRemittance { + pub remittance_id: String, + pub gross_sats: u64, + /// The melt fee the mint actually took. `None` while planned, and on a row settled by + /// reconciliation (the mint confirms PAID but the fee it kept was not observed). + pub melt_fee_sats: Option, + /// The fee reserve of the quote this row was planned against, replaced at settlement by the + /// reserve of the quote that actually paid. `None` only on rows written before v11. + pub melt_fee_reserve_sats: Option, + pub net_sats: u64, + pub destination: String, + pub melt_quote_id: Option, + pub payment_hash: String, + pub bolt11: String, + pub state: RemittanceState, + pub created_at_unix: i64, + pub settled_at_unix: Option, + /// How the row was settled; `None` while planned or failed, and on settled rows written before + /// v11. + pub settled_by: Option, + /// The process that planned this row and is the only one entitled to pay it (addendum 3 §2): + /// an opaque per-process token. `None` on rows planned before v11. + pub owner: Option, + /// Until when the owner's claim stands. Another process may release a `planned` row on + /// UNPAID / no-quote only once this has passed — or on a quote the mint reports FAILED, which + /// is terminal whoever owns it. `None` on rows planned before v11 (read as expired). + pub lease_until_unix: Option, + /// When the owner's compare-and-set admitted the melt (addendum 4 §1): `Some` exactly on a + /// [`RemittanceState::Spending`] row. `None` on every row written before v12. + pub spending_since_unix: Option, + /// The melt quote the compare-and-set bound to this row at admission (addendum 5 §1, rule 1) — + /// the ONLY quote its owner pays, by id, and the quote reconciliation asks the mint about to + /// resolve a spending row. `Some` exactly on a row admitted by a v13 binary; `None` on every + /// planned row, and on a spending row admitted before v13 (which reconciliation resolves by the + /// invoice's quotes, as before). + pub spending_quote_id: Option, + /// How many receipt rows are pinned to this remittance. + pub receipts: usize, +} + +impl FeeRemittance { + /// Whether `owner`'s claim on this row stands at `now_unix` with MORE than `margin_secs` to + /// spare — the same predicate [`SellerStore::admit_remittance_spend`] evaluates in SQL + /// (`lease_until > now + margin`, addendum 4 §1.1). A row with no lease (pre-v11) is read as + /// expired: fail-closed toward "not yours". + pub fn lease_holds(&self, owner: &str, now_unix: i64, margin_secs: i64) -> bool { + self.owner.as_deref() == Some(owner) + && self + .lease_until_unix + .is_some_and(|until| until > now_unix.saturating_add(margin_secs)) + } + + /// Whether the owner's lease has run out at `now_unix` (a missing lease counts as run out). + pub fn lease_expired(&self, now_unix: i64) -> bool { + self.lease_until_unix.is_none_or(|until| now_unix >= until) + } +} + +/// The REASON a release is being written, which is also its SQL predicate +/// ([`SellerStore::release_remittance`], addendum 5 §1 rule 2): every release is a conditional +/// state transition that changes zero rows if the row is no longer as the reason found it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReleaseOn { + /// A SPENDING row whose BOUND quote (`spending_quote_id`) is named. Names the quote, so the + /// release lands only if that is still the row's bound quote. **Not emitted by reconciliation + /// since addendum 6** (`fee_remit::reconcile_decision` holds a bound spending row on + /// everything but PAID: the mint pays an UNPAID or FAILED quote regardless of expiry, so no + /// observation proves the bound quote cannot still debit); kept as the store's conditional + /// transition with its tests, with no automatic caller. + TerminalBoundQuote { quote_id: String }, + /// A SPENDING row admitted by a v12 binary — before admissions bound a quote — whose invoice's + /// quote(s) the mint reports terminal: the release v12 had, kept only for rows v12 wrote + /// (`spending_quote_id IS NULL`). A v13 admission always binds, so this never applies to a row + /// this binary admitted. + TerminalUnboundSpending, + /// A PLANNED row (never admitted: nothing spent against it) whose invoice's quote the mint + /// reports terminal. + TerminalQuotePlanned, + /// A PLANNED row whose owner's lease has run out at `now_unix` — the owner is provably not + /// spending: its fence refuses inside the margin and, past the lease, changes zero rows. Never + /// applies to a spending row. + LeaseExpired { now_unix: i64 }, + /// This process's own PLANNED row: its earlier attempt is over (a process runs one attempt at + /// a time), or this attempt refused before spending. + OwnPlanned { owner: String }, +} + +impl ReleaseOn { + /// The reason in a phrase, for messages. + pub fn describe(&self) -> String { + match self { + Self::TerminalBoundQuote { quote_id } => { + format!("its bound melt quote {quote_id} is terminal at the mint") + } + Self::TerminalUnboundSpending => { + "spending without a bound quote (admitted before v13) and its invoice's quote is terminal at the mint" + .to_owned() + } + Self::TerminalQuotePlanned => { + "planned, never admitted, and its quote is terminal at the mint".to_owned() + } + Self::LeaseExpired { now_unix } => { + format!("planned, never admitted, and its owner's lease had run out at unix {now_unix}") + } + Self::OwnPlanned { owner } => { + format!("planned, never admitted, and this process's own ({owner})") + } + } + } +} + +/// Why the pre-spend compare-and-set changed zero rows ([`SellerStore::admit_remittance_spend`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OwnershipLost { + /// The row no longer exists. + Missing, + /// The row is no longer `planned`: another process reconciled it while this one paused (or it + /// is already `spending` — admitted once; a second admission is refused). + NotPlanned { state: RemittanceState }, + /// The row is planned but owned by someone else (or by nobody: a pre-v11 row). + OtherOwner { owner: Option }, + /// The row is this caller's and still planned, but too little of its lease remains to start a + /// payment safely: another process is entitled to release a PLANNED row once its lease ends + /// ([`ReleaseOn::LeaseExpired`]), and an admission that landed this close to that instant would + /// race the release. (Once admitted, the row is spending and no lease releases it.) + LeaseTooShort { + lease_until_unix: Option, + now_unix: i64, + margin_secs: i64, + }, +} + +impl std::fmt::Display for OwnershipLost { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Missing => write!(formatter, "the planned row no longer exists"), + Self::NotPlanned { state } => write!( + formatter, + "the row is no longer planned (now {}): {}", + state.as_str(), + if *state == RemittanceState::Spending { + "its melt was already admitted once" + } else { + "another process reconciled it" + } + ), + Self::OtherOwner { owner } => write!( + formatter, + "the planned row is owned by {}", + owner + .as_deref() + .unwrap_or("nobody (planned before ownership was recorded)") + ), + Self::LeaseTooShort { + lease_until_unix, + now_unix, + margin_secs, + } => write!( + formatter, + "the row is ours but its lease {} leaves less than the {margin_secs}s spending margin at unix {now_unix}", + match lease_until_unix { + Some(until) => format!("(until unix {until})"), + None => "(none recorded)".to_owned(), + } + ), + } + } +} + +/// Who attempted a remittance — the three callers of `crate::fee_remit::remit` that pay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemitAttemptTrigger { + /// The seller node, after a receipt was journaled `Collected::New`. + Collect, + /// `maxplayer seller fees remit --confirm`, run by an operator. + Command, + /// The seller node's retry tick (stage 2a, addendum 2): the loop's own backoff clock. + Retry, +} + +impl RemitAttemptTrigger { + pub fn as_str(self) -> &'static str { + match self { + Self::Collect => "collect", + Self::Command => "command", + Self::Retry => "retry", + } + } + + fn parse(raw: &str) -> Result { + match raw { + "collect" => Ok(Self::Collect), + "command" => Ok(Self::Command), + "retry" => Ok(Self::Retry), + other => Err(StoreError(format!( + "unknown remit attempt trigger {other:?}" + ))), + } + } +} + +/// How a remittance attempt ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemitAttemptOutcome { + /// The melt settled; `remittance_id` names the settled row. + Paid, + /// Declined and moved nothing, for a reason other than the threshold (an attempt still in + /// flight, a fee reserve that does not fit, a balance above the destination's maximum). + Refused, + /// An error: the LNURL host, the mint quote, the reconciliation query or the melt itself failed. + /// If a `remittance_id` is named, that row stays `planned` for the next attempt to reconcile. + Failed, +} + +impl RemitAttemptOutcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Paid => "paid", + Self::Refused => "refused", + Self::Failed => "failed", + } + } + + fn parse(raw: &str) -> Result { + match raw { + "paid" => Ok(Self::Paid), + "refused" => Ok(Self::Refused), + "failed" => Ok(Self::Failed), + other => Err(StoreError(format!( + "unknown remit attempt outcome {other:?}" + ))), + } + } +} + +/// One row of `fee_remit_attempts` — one attempt to pay the accrued platform fee and how it ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemitAttempt { + /// Assigned by the store; `0` on a record that has not been written yet. + pub attempt_id: i64, + pub started_at_unix: i64, + pub trigger: RemitAttemptTrigger, + /// The unremitted balance the attempt saw when it read the ledger. + pub unremitted_sats: u64, + pub outcome: RemitAttemptOutcome, + /// The sentence the attempt printed for its outcome (the error text on `Failed`). + pub detail: String, + /// The `fee_remittances` row this attempt planned, if it got as far as journaling one. + pub remittance_id: Option, +} + +/// Why a plan was refused. Typed so the command can print the right sentence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlanRefused { + /// A `planned` row already exists — a payment may be in flight. Following + /// `crossmint_hop`'s `DuplicatePlanned`: refuse, never stack a second attempt. + InFlight(Box), + /// The store's unremitted sum is not what the caller computed — receipts landed (or a + /// remittance settled) between the read and the plan. Re-read and re-plan; never pay a stale + /// figure. + GrossMismatch { + planned: u64, + unremitted: u64, + }, + /// The payment hash was already used by an earlier attempt (any state). + DuplicateInvoice { + payment_hash: String, + }, + /// Nothing is unremitted. + NothingToRemit, + Store(StoreError), +} + +impl std::fmt::Display for PlanRefused { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InFlight(row) => write!( + formatter, + "remittance {} (planned at {}, {} sats to {}) is still in flight; refusing a second \ + attempt while the first is unresolved", + row.remittance_id, row.created_at_unix, row.net_sats, row.destination + ), + Self::GrossMismatch { + planned, + unremitted, + } => write!( + formatter, + "unremitted total moved: planned {planned} sats but the store now holds {unremitted}; \ + re-run to re-plan" + ), + Self::DuplicateInvoice { payment_hash } => write!( + formatter, + "invoice {payment_hash} was already used by an earlier remittance attempt" + ), + Self::NothingToRemit => write!(formatter, "nothing to remit"), + Self::Store(error) => write!(formatter, "{error}"), + } + } +} + +impl std::error::Error for PlanRefused {} + +impl From for PlanRefused { + fn from(value: StoreError) -> Self { + Self::Store(value) + } +} + +impl From for PlanRefused { + fn from(value: rusqlite::Error) -> Self { + Self::Store(value.into()) + } +} + /// Resolve a nullable `payment` column into a [`crate::gateway::PaymentMode`]. /// /// NULL ⇒ [`crate::gateway::PaymentMode::Sat`] — a row written before the column existed, and every @@ -127,7 +633,7 @@ pub struct SellerStore { } /// Store open / query failure. -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct StoreError(pub String); impl std::fmt::Display for StoreError { @@ -461,7 +967,13 @@ impl SellerStore { -- default: a row from before v9 reads NULL, meaning NOT RECORDED — never a -- measured 0. What the seller keeps (face − mint fee − platform fee) is derived at -- read time from these three columns and is deliberately not a fourth column. - mint_fee_sats INTEGER CHECK (mint_fee_sats IS NULL OR mint_fee_sats >= 0) + mint_fee_sats INTEGER CHECK (mint_fee_sats IS NULL OR mint_fee_sats >= 0), + -- v10 (stage 2a): the fee_remittances row that discharged this receipt's platform + -- fee. NULL ⇒ UNREMITTED — the balance `maxplayer seller fees remit` pays. Set in + -- the same transaction that journals a planned remittance, cleared if that + -- remittance fails, kept once it settles. Not a foreign key on purpose: the + -- additive-only migration cannot add one, and the fresh schema must match it. + remittance_id TEXT ); -- Intent-to-receive breadcrumbs, written BEFORE the mint swap (payment ordering, -- invariant 3). A breadcrumb records ONLY that a swap was attempted for a token — it is @@ -513,6 +1025,74 @@ impl SellerStore { env_kind TEXT NOT NULL, env_lock_ref TEXT NOT NULL, captured_at_unix INTEGER NOT NULL + ); + -- v10 (stage 2a): every attempt to remit the accrued platform fee, one row per invoice. + -- `remittance_id` IS the bolt11 payment hash (hex) — the idempotency key: one invoice + -- can be journaled once, ever. `gross_sats` is the unremitted fee the attempt + -- discharges; `net_sats` the invoice amount (gross minus the melt fee reserve — the fee + -- comes OUT of the gross, never on top); `melt_fee_sats` what the mint actually took, + -- NULL until settled. `destination` is the address LITERAL paid, so a later change of + -- the constant leaves history. `state` moves planned → settled | failed; the receipts + -- pinned to a planned row (receipts.remittance_id) are released on failed and kept on + -- settled. The partial unique index below lets at most ONE row be planned at a time. + -- v11 (addendum 3): `owner` / `lease_until_unix` are the planned row's durable + -- ownership — only the owner pays it, and another process may release it on + -- UNPAID/no-quote only after the lease, or on a quote the mint reports FAILED; + -- `melt_fee_reserve_sats` is the reserve of the quote planned against, replaced at + -- settlement by the reserve of the quote that paid; `settled_by` says whether the melt + -- itself or reconciliation settled the row. All four nullable, reaching existing stores + -- through `migrate` as ALTER TABLE ADD COLUMN — never a rebuild. + -- v12 (addendum 4): `spending_since_unix` marks a planned row SPENDING — the owner's + -- compare-and-set set it immediately before the melt, with a fresh clock. A spending + -- row is released only on a quote the mint reports terminal, never on lease expiry. + -- A column rather than a fourth `state` value because SQLite cannot widen this + -- table's CHECK on an existing store; the one-in-flight index is unchanged, since a + -- spending row is still the one `planned` row. Nullable, additive, ALTER-added below. + -- v13 (addendum 5): `spending_quote_id` is the melt quote the compare-and-set BOUND to + -- the row at admission — the only quote the owner pays (by id, never re-quoting), and + -- the quote reconciliation checks by id to resolve a spending row. Every release is a + -- conditional UPDATE carrying its reason's predicate (`release_remittance`); a release + -- of a spending row must name this quote. Nullable, additive, ALTER-added below. + CREATE TABLE IF NOT EXISTS fee_remittances ( + remittance_id TEXT PRIMARY KEY, + gross_sats INTEGER NOT NULL CHECK (gross_sats >= 0), + melt_fee_sats INTEGER CHECK (melt_fee_sats IS NULL OR melt_fee_sats >= 0), + net_sats INTEGER NOT NULL CHECK (net_sats >= 0 AND net_sats <= gross_sats), + destination TEXT NOT NULL, + melt_quote_id TEXT, + payment_hash TEXT NOT NULL UNIQUE, + bolt11 TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('planned','settled','failed')), + created_at_unix INTEGER NOT NULL, + settled_at_unix INTEGER, + owner TEXT, + lease_until_unix INTEGER, + melt_fee_reserve_sats INTEGER CHECK (melt_fee_reserve_sats IS NULL OR melt_fee_reserve_sats >= 0), + settled_by TEXT CHECK (settled_by IS NULL OR settled_by IN ('melt','reconciliation')), + spending_since_unix INTEGER, + spending_quote_id TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS fee_remittances_one_planned + ON fee_remittances (state) WHERE state = 'planned'; + -- v10 (stage 2a, addendum 1): every ATTEMPT to pay the accrued platform fee, whether it + -- paid, was refused, or failed — the record an operator reads when the automatic payout + -- is not landing. `trigger` says who attempted ('collect' = the seller node after a + -- receipt was journaled New; 'retry' = the seller node's backoff tick, addendum 2; + -- 'command' = `maxplayer seller fees remit --confirm`). The table is new in v10, which + -- has not shipped, so widening the CHECK here is the table's first definition on every + -- store that will ever have it — no existing table is rebuilt; + -- `unremitted_sats` is the balance the attempt saw; `remittance_id` names the + -- fee_remittances row it planned, if it got that far. Attempts that stop at the threshold + -- (nothing unremitted, or below the destination's minimum) are the expected steady state + -- for small sellers and are NOT journaled here. Additive: older stores simply have no rows. + CREATE TABLE IF NOT EXISTS fee_remit_attempts ( + attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at_unix INTEGER NOT NULL, + trigger TEXT NOT NULL CHECK (trigger IN ('collect','command','retry')), + unremitted_sats INTEGER NOT NULL CHECK (unremitted_sats >= 0), + outcome TEXT NOT NULL CHECK (outcome IN ('paid','refused','failed')), + detail TEXT NOT NULL, + remittance_id TEXT );", )?; Self::migrate(conn)?; @@ -591,6 +1171,55 @@ impl SellerStore { CHECK (mint_fee_sats IS NULL OR mint_fee_sats >= 0);", )?; } + // v10 — which remittance discharged each receipt's platform fee. Nullable, no default: every + // pre-existing row reads NULL, i.e. UNREMITTED, which is the truth of a store that has never + // remitted. The `fee_remittances` table and its index are created by `CREATE ... IF NOT + // EXISTS` in the schema above, which runs on every open. Additive + idempotent. + if !Self::column_exists(conn, "receipts", "remittance_id")? { + conn.execute_batch("ALTER TABLE receipts ADD COLUMN remittance_id TEXT;")?; + } + // v11 — ownership and settlement provenance on the remittance row (addendum 3). Nullable, no + // default: a v10 row reads `owner = NULL, lease_until_unix = NULL`, which every reader treats + // as an EXPIRED claim by nobody — fail-closed toward "not yours to pay", releasable by + // reconciliation once its quote is known terminal or unpaid. `melt_fee_reserve_sats` and + // `settled_by` read NULL: not recorded, never a guess. Additive + idempotent. + if !Self::column_exists(conn, "fee_remittances", "owner")? { + conn.execute_batch("ALTER TABLE fee_remittances ADD COLUMN owner TEXT;")?; + } + if !Self::column_exists(conn, "fee_remittances", "lease_until_unix")? { + conn.execute_batch("ALTER TABLE fee_remittances ADD COLUMN lease_until_unix INTEGER;")?; + } + if !Self::column_exists(conn, "fee_remittances", "melt_fee_reserve_sats")? { + conn.execute_batch( + "ALTER TABLE fee_remittances ADD COLUMN melt_fee_reserve_sats INTEGER + CHECK (melt_fee_reserve_sats IS NULL OR melt_fee_reserve_sats >= 0);", + )?; + } + if !Self::column_exists(conn, "fee_remittances", "settled_by")? { + conn.execute_batch( + "ALTER TABLE fee_remittances ADD COLUMN settled_by TEXT + CHECK (settled_by IS NULL OR settled_by IN ('melt','reconciliation'));", + )?; + } + // v12 — the SPENDING mark on the in-flight remittance row (addendum 4 §1). Nullable, no + // default: every pre-existing planned row reads NULL, i.e. PLANNED — its melt was never + // admitted by a compare-and-set, so the pre-v12 release rules (owner gone or quote terminal) + // still apply to it, which is the truth of a row written before the fence existed. Old rows + // are otherwise untouched; the `state` CHECK and the one-in-flight index are unchanged. + // Additive + idempotent. + if !Self::column_exists(conn, "fee_remittances", "spending_since_unix")? { + conn.execute_batch( + "ALTER TABLE fee_remittances ADD COLUMN spending_since_unix INTEGER;", + )?; + } + // v13 — the quote BOUND to the in-flight row at admission (addendum 5 §1). Nullable, no + // default: every pre-existing row reads NULL — a planned row has no bound quote yet (the + // fence sets it), and a spending row admitted by a v12 binary was admitted without one, so + // reconciliation resolves it by the invoice's quotes as v12 did. Nothing rewritten, the + // `state` CHECK and the one-in-flight index unchanged. Additive + idempotent. + if !Self::column_exists(conn, "fee_remittances", "spending_quote_id")? { + conn.execute_batch("ALTER TABLE fee_remittances ADD COLUMN spending_quote_id TEXT;")?; + } Ok(()) } @@ -1276,38 +1905,48 @@ impl SellerStore { Ok(found) } - /// What the platform fee (stage 1) has come to: the all-time total and the receipt rows behind - /// it, oldest collection first. This is the read-out a later remit stage settles against; it is - /// a query and nothing more — no call here or anywhere in this stage moves the balance it reports. + /// What the platform fee has come to: the all-time total, how much of it is remitted / + /// unremitted / in flight, and the receipt rows behind it, oldest collection first. This is the + /// read-out the remit command settles against; it is a query and nothing more — nothing here + /// moves the balance it reports. /// /// Rows collected before the fee existed report `fee_bps = 0, fee_sats = 0` (the migration /// default), which is what they owed. Rows collected before v9 report `mint_fee_sats = None`: /// the mint fee was not recorded, and the totals say how many such rows there are rather than - /// counting them as zero. `by_job` carries one entry per receipt row; the collect path receipts - /// a job at most once (`has_receipt` guards the redeem), so that is one per job. + /// counting them as zero. Rows collected before v10 report `remittance_id = None`: unremitted, + /// which is the truth of a store that has never remitted. `by_job` carries one entry per receipt + /// row; the collect path receipts a job at most once (`has_receipt` guards the redeem), so that + /// is one per job. pub fn accrued_fees(&self) -> Result { let conn = self.lock()?; let mut statement = conn.prepare( - "SELECT job_id, amount_sats, mint_fee_sats, fee_bps, fee_sats, received_at_unix - FROM receipts - ORDER BY received_at_unix ASC, receipt_id ASC", + "SELECT r.job_id, r.amount_sats, r.mint_fee_sats, r.fee_bps, r.fee_sats, + r.received_at_unix, r.remittance_id, f.state + FROM receipts r + LEFT JOIN fee_remittances f ON f.remittance_id = r.remittance_id + ORDER BY r.received_at_unix ASC, r.receipt_id ASC", )?; - let by_job = statement + let rows = statement .query_map([], |row| { - Ok(JobFeeAccrual { - job_id: row.get(0)?, - amount_sats: u64::try_from(row.get::<_, i64>(1)?).unwrap_or(0), - mint_fee_sats: row - .get::<_, Option>(2)? - .map(|fee| u64::try_from(fee).unwrap_or(0)), - fee_bps: u32::try_from(row.get::<_, i64>(3)?).unwrap_or(0), - fee_sats: u64::try_from(row.get::<_, i64>(4)?).unwrap_or(0), - received_at_unix: row.get(5)?, - }) + Ok(( + JobFeeAccrual { + job_id: row.get(0)?, + amount_sats: u64::try_from(row.get::<_, i64>(1)?).unwrap_or(0), + mint_fee_sats: row + .get::<_, Option>(2)? + .map(|fee| u64::try_from(fee).unwrap_or(0)), + fee_bps: u32::try_from(row.get::<_, i64>(3)?).unwrap_or(0), + fee_sats: u64::try_from(row.get::<_, i64>(4)?).unwrap_or(0), + received_at_unix: row.get(5)?, + remittance_id: row.get(6)?, + }, + row.get::<_, Option>(7)?, + )) })? .collect::, _>>()?; let mut totals = AccruedFees::default(); - for row in &by_job { + let mut by_job = Vec::with_capacity(rows.len()); + for (row, remittance_state) in rows { totals.total_amount_sats = totals.total_amount_sats.saturating_add(row.amount_sats); totals.total_fee_sats = totals.total_fee_sats.saturating_add(row.fee_sats); match row.mint_fee_sats { @@ -1317,113 +1956,711 @@ impl SellerStore { } None => totals.rows_without_mint_fee += 1, } + // A receipt pinned to a row that no longer exists, or to one in an unknown state, is + // read as unremitted: fail-closed toward "still owed", never toward "already paid". + match remittance_state.as_deref().map(RemittanceState::parse) { + Some(Ok(RemittanceState::Settled)) => { + totals.remitted_fee_sats = + totals.remitted_fee_sats.saturating_add(row.fee_sats); + } + Some(Ok(RemittanceState::Planned)) => { + totals.in_flight_fee_sats = + totals.in_flight_fee_sats.saturating_add(row.fee_sats); + } + _ => { + totals.unremitted_fee_sats = + totals.unremitted_fee_sats.saturating_add(row.fee_sats); + } + } + by_job.push(row); } totals.by_job = by_job; Ok(totals) } - /// Whether a delivery has been journaled for `job_id` (#552). A delivery row is written only by - /// [`Self::deliver_and_enqueue`], atomically with the `delivered` state advance — so this is the - /// durable proof the result was already produced and enqueued, independent of the `state` column - /// (belt-and-braces against a lagged state). - pub fn has_delivery(&self, job_id: &str) -> Result { - let conn = self.lock()?; - let found = conn - .query_row( - "SELECT 1 FROM deliveries WHERE job_id = ?1 LIMIT 1", - [job_id], - |_| Ok(()), - ) - .optional()? - .is_some(); - Ok(found) - } + // ---- Platform fee remittance (stage 2a) ------------------------------------------------------ - /// The delivery commit oid journaled at push time for `job_id`, if any (#552). `Some` on a - /// still-`awarded`/`executing` row means the delivery was pushed but the enqueue was interrupted - /// — resume finalizes from it instead of re-running the agent. `None` ⇒ never pushed. - pub fn pushed_commit(&self, job_id: &str) -> Result, StoreError> { - let conn = self.lock()?; - let commit: Option = conn - .query_row( - "SELECT pushed_commit FROM jobs WHERE job_id = ?1", - [job_id], - |row| row.get(0), - ) - .optional()? - .flatten(); - Ok(commit) + /// Sum of `fee_sats` over receipts pinned to no remittance — computed inside the caller's + /// transaction so the plan checks the figure it is about to pin. + fn unremitted_fee_sats_in(conn: &Connection) -> Result { + let sum: i64 = conn.query_row( + "SELECT COALESCE(SUM(fee_sats), 0) FROM receipts WHERE remittance_id IS NULL", + [], + |row| row.get(0), + )?; + Ok(u64::try_from(sum).unwrap_or(0)) } - /// #563 — mark a job RELAY-DERIVED as settled elsewhere: a resume refine fetched POSITIVE - /// settlement evidence for its offer from the relay (our own already-published result, or a buyer - /// receipt — settled with us or another seat). Written ONLY after that evidence is in hand - /// (arm-after-the-event), never speculatively on the way into the query, so a crash between issuing - /// the derive and getting evidence leaves the row re-checkable next restart. Idempotent — last - /// write wins; does NOT change `state`. Provenance-honest: relay-DERIVED, distinct from a local - /// `deliveries` row (which only [`Self::deliver_and_enqueue`] writes). - pub fn mark_settled_elsewhere(&self, job_id: &str, now_unix: i64) -> Result<(), StoreError> { - let conn = self.lock()?; - conn.execute( - "UPDATE jobs SET settled_elsewhere_at_unix = ?2, updated_at_unix = ?2 WHERE job_id = ?1", - params![job_id, now_unix], - )?; - Ok(()) + fn read_remittance(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let state_raw: String = row.get(8)?; + let spending_since_unix: Option = row.get(16)?; + let state = + RemittanceState::from_columns(&state_raw, spending_since_unix).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 8, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + let settled_by = row + .get::<_, Option>(15)? + .map(|raw| { + SettledBy::parse(&raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 15, + rusqlite::types::Type::Text, + Box::new(error), + ) + }) + }) + .transpose()?; + Ok(FeeRemittance { + remittance_id: row.get(0)?, + gross_sats: u64::try_from(row.get::<_, i64>(1)?).unwrap_or(0), + melt_fee_sats: row + .get::<_, Option>(2)? + .map(|fee| u64::try_from(fee).unwrap_or(0)), + net_sats: u64::try_from(row.get::<_, i64>(3)?).unwrap_or(0), + destination: row.get(4)?, + melt_quote_id: row.get(5)?, + payment_hash: row.get(6)?, + bolt11: row.get(7)?, + state, + created_at_unix: row.get(9)?, + settled_at_unix: row.get(10)?, + receipts: usize::try_from(row.get::<_, i64>(11)?).unwrap_or(0), + owner: row.get(12)?, + lease_until_unix: row.get(13)?, + melt_fee_reserve_sats: row + .get::<_, Option>(14)? + .map(|reserve| u64::try_from(reserve).unwrap_or(0)), + settled_by, + spending_since_unix, + spending_quote_id: row.get(17)?, + }) } - /// Whether `job_id` was relay-derived as settled elsewhere (see [`Self::mark_settled_elsewhere`]). - /// A resume refine consults this FIRST and short-circuits — a durable marker means it need never - /// re-query the relay. - pub fn has_settled_elsewhere(&self, job_id: &str) -> Result { - let conn = self.lock()?; + const REMITTANCE_COLUMNS: &'static str = + "f.remittance_id, f.gross_sats, f.melt_fee_sats, f.net_sats, f.destination, f.melt_quote_id, + f.payment_hash, f.bolt11, f.state, f.created_at_unix, f.settled_at_unix, + (SELECT COUNT(*) FROM receipts r WHERE r.remittance_id = f.remittance_id), + f.owner, f.lease_until_unix, f.melt_fee_reserve_sats, f.settled_by, f.spending_since_unix, + f.spending_quote_id"; + + fn in_flight_remittance_in(conn: &Connection) -> Result, StoreError> { let found = conn .query_row( - "SELECT 1 FROM jobs WHERE job_id = ?1 AND settled_elsewhere_at_unix IS NOT NULL", - [job_id], - |_| Ok(()), + &format!( + "SELECT {} FROM fee_remittances f WHERE f.state = 'planned' LIMIT 1", + Self::REMITTANCE_COLUMNS + ), + [], + Self::read_remittance, ) - .optional()? - .is_some(); + .optional()?; Ok(found) } - // ---- Outbox --------------------------------------------------------------------------------- - - /// Every still-`pending` outbox row that has not yet expired (`expires_at_unix > now`), - /// oldest first — the batch the publisher must send. - pub fn pending_outbox(&self, now_unix: i64) -> Result, StoreError> { + /// The single `planned` remittance, if one exists — a payment that may be in flight at the mint + /// and MUST be reconciled (settled or failed) before another may be planned. + pub fn in_flight_remittance(&self) -> Result, StoreError> { let conn = self.lock()?; - let mut stmt = conn.prepare( - "SELECT id, dedup_key, draft_json, created_at_unix, attempts, expires_at_unix - FROM nostr_event_outbox - WHERE state = 'pending' AND expires_at_unix > ?1 - ORDER BY id", - )?; - let rows = stmt.query_map([now_unix], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, i64>(3)?, - row.get::<_, i64>(4)?, - row.get::<_, i64>(5)?, - )) - })?; - let mut items = Vec::new(); - for row in rows { - let (id, dedup_key, draft_json, created_at_unix, attempts, expires_at_unix) = row?; - let draft: EventDraft = serde_json::from_str(&draft_json) - .map_err(|error| StoreError(format!("outbox draft decode: {error}")))?; - items.push(OutboxItem { - id, - dedup_key, - draft, - created_at_unix, - attempts, - expires_at_unix, - }); - } - Ok(items) + Self::in_flight_remittance_in(&conn) + } + + /// Every remittance attempt, oldest first. + pub fn remittances(&self) -> Result, StoreError> { + let conn = self.lock()?; + let mut statement = conn.prepare(&format!( + "SELECT {} FROM fee_remittances f ORDER BY f.created_at_unix ASC, f.remittance_id ASC", + Self::REMITTANCE_COLUMNS + ))?; + let rows = statement + .query_map([], Self::read_remittance)? + .collect::, _>>()?; + Ok(rows) + } + + /// Journal a remittance BEFORE paying it, and pin every currently-unremitted receipt to it, in + /// one `IMMEDIATE` transaction. This is the durable intent record the payment is made against: + /// a crash after it leaves a `planned` row the next run reconciles, never a payment nobody + /// journaled. + /// + /// Refused, with nothing written, when: a `planned` row already exists ([`PlanRefused::InFlight`] + /// — the precedent is `crossmint_hop`'s refusal of a duplicate Planned record); the store's + /// unremitted sum differs from `plan.gross_sats` ([`PlanRefused::GrossMismatch`] — the ledger + /// moved under the caller); the payment hash was already journaled + /// ([`PlanRefused::DuplicateInvoice`]); or there is nothing unremitted. + /// + /// `owner` is the planning process's token and `lease_until_unix` how long its claim stands + /// (addendum 3 §2): only the owner pays this row — its pre-spend fence + /// [`Self::admit_remittance_spend`] advances it to spending and binds the quote it pays — and, + /// while the row is still PLANNED, another process may release it on UNPAID / no-quote only once + /// the lease has passed. Once admitted (spending, quote bound) the lease no longer matters: the + /// row is held until the mint reports that quote PAID (addendum 6 §1.2). + pub fn plan_remittance( + &self, + plan: &RemittancePlan, + owner: &str, + lease_until_unix: i64, + now_unix: i64, + ) -> Result { + if plan.net_sats > plan.gross_sats { + return Err(PlanRefused::Store(StoreError(format!( + "net {} exceeds gross {}: the melt fee must come out of the gross, never on top", + plan.net_sats, plan.gross_sats + )))); + } + if owner.trim().is_empty() { + return Err(PlanRefused::Store(StoreError( + "a remittance plan needs an owner token".to_owned(), + ))); + } + let mut conn = self.lock()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(active) = Self::in_flight_remittance_in(&tx)? { + return Err(PlanRefused::InFlight(Box::new(active))); + } + let unremitted = Self::unremitted_fee_sats_in(&tx)?; + if unremitted == 0 { + return Err(PlanRefused::NothingToRemit); + } + if unremitted != plan.gross_sats { + return Err(PlanRefused::GrossMismatch { + planned: plan.gross_sats, + unremitted, + }); + } + let inserted = tx.execute( + "INSERT OR IGNORE INTO fee_remittances + (remittance_id, gross_sats, melt_fee_sats, net_sats, destination, melt_quote_id, + payment_hash, bolt11, state, created_at_unix, settled_at_unix, + owner, lease_until_unix, melt_fee_reserve_sats, settled_by) + VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?1, ?6, ?8, ?7, NULL, ?9, ?10, ?11, NULL)", + params![ + plan.payment_hash, + plan.gross_sats as i64, + plan.net_sats as i64, + plan.destination, + plan.melt_quote_id, + plan.bolt11, + now_unix, + RemittanceState::Planned.column_value(), + owner, + lease_until_unix, + plan.melt_fee_reserve_sats as i64, + ], + )?; + if inserted == 0 { + return Err(PlanRefused::DuplicateInvoice { + payment_hash: plan.payment_hash.clone(), + }); + } + tx.execute( + "UPDATE receipts SET remittance_id = ?1 WHERE remittance_id IS NULL", + params![plan.payment_hash], + )?; + let row = tx.query_row( + &format!( + "SELECT {} FROM fee_remittances f WHERE f.remittance_id = ?1", + Self::REMITTANCE_COLUMNS + ), + params![plan.payment_hash], + Self::read_remittance, + )?; + tx.commit()?; + Ok(row) + } + + /// **The pre-spend fence** (addendum 4 §1.1, addendum 5 §1 rule 1): advance the row + /// `planned → spending` and BIND the quote the payer will pay, by ONE conditional update, + /// immediately before the irreversible spend — + /// + /// ```sql + /// UPDATE fee_remittances SET spending_since_unix = :now, spending_quote_id = :quote + /// WHERE remittance_id = :id AND state = 'planned' AND spending_since_unix IS NULL + /// AND owner = :owner AND lease_until_unix > :now + :margin + /// ``` + /// + /// `:now` is read from `clock` INSIDE this call, after the store's lock is held and the + /// `IMMEDIATE` transaction has begun — never a value the caller sampled earlier, however + /// recently: a payer descheduled between sampling and the lock would otherwise be admitted on a + /// clock that is no longer now (addendum 5 §1, B2). `clock` is called exactly once; a caller + /// that wants the instant used reads it from the admitted row's `spending_since_unix` or from + /// [`OwnershipLost::LeaseTooShort`]. `quote_id` is the melt quote raised for the row's invoice + /// before this call and checked against the ceiling; from here on the owner pays THAT quote by + /// id and never raises another for this row. + /// + /// **Zero rows changed ⇒ `Err(OwnershipLost)`**, diagnosed from the row as it stands: it is + /// gone, no longer planned (another process reconciled it, or it is already spending), owned by + /// someone else, or ours with `margin_secs` or less of lease left — another process is entitled + /// to release a PLANNED row once its lease ends, and an admission that close would race the + /// release. `Ok(row)` is the admitted row, now [`RemittanceState::Spending`] with the quote + /// bound. Once admitted the row is HELD until the mint reports the bound quote PAID + /// (`fee_remit.rs`, the PAID branch of reconciliation settles it): no terminal-state release, + /// no clock release (§1.2; addendum 10 §6 item 8) — an UNPAID or FAILED verdict on a bound + /// quote holds the row too, since the mint can still pay a quote it once reported unpaid. + /// + /// One `IMMEDIATE` transaction, so two processes cannot both pass: the second sees the first's + /// mark and changes zero rows. + pub fn admit_remittance_spend( + &self, + remittance_id: &str, + owner: &str, + quote_id: &str, + margin_secs: i64, + clock: &mut dyn FnMut() -> i64, + ) -> Result, StoreError> { + if quote_id.trim().is_empty() { + return Err(StoreError( + "a remittance is admitted to spend only against a named melt quote".to_owned(), + )); + } + let mut conn = self.lock()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + // The clock, read now: the lock is held and the write transaction has begun, so nothing can + // change the row between this instant and the UPDATE below. + let now_unix = clock(); + let changed = tx.execute( + "UPDATE fee_remittances SET spending_since_unix = ?3, spending_quote_id = ?6 + WHERE remittance_id = ?1 AND state = ?5 AND spending_since_unix IS NULL + AND owner = ?2 AND lease_until_unix > ?3 + ?4", + params![ + remittance_id, + owner, + now_unix, + margin_secs, + RemittanceState::Planned.column_value(), + quote_id, + ], + )?; + let row = tx + .query_row( + &format!( + "SELECT {} FROM fee_remittances f WHERE f.remittance_id = ?1", + Self::REMITTANCE_COLUMNS + ), + params![remittance_id], + Self::read_remittance, + ) + .optional()?; + tx.commit()?; + let Some(row) = row else { + return Ok(Err(OwnershipLost::Missing)); + }; + if changed == 1 { + debug_assert_eq!(row.state, RemittanceState::Spending); + return Ok(Ok(row)); + } + // Zero rows changed: say which condition failed, from the row as it stands now. + if row.state != RemittanceState::Planned { + return Ok(Err(OwnershipLost::NotPlanned { state: row.state })); + } + if row.owner.as_deref() != Some(owner) { + return Ok(Err(OwnershipLost::OtherOwner { + owner: row.owner.clone(), + })); + } + Ok(Err(OwnershipLost::LeaseTooShort { + lease_until_unix: row.lease_until_unix, + now_unix, + margin_secs, + })) + } + + /// **Re-plan** a still-PLANNED, still-UNBOUND row of ours onto a new invoice (addendum 10 §1.4): + /// the live quote's fee reserve differs from the estimate the row was planned on and the + /// planned invoice would not confirm, so the SAME attempt raises a new (re-planned) invoice — + /// smaller or larger, whatever fits at the live reserve — BEFORE it prepares any spend. ONE + /// conditional update — + /// + /// ```sql + /// UPDATE fee_remittances SET net_sats, payment_hash, bolt11, melt_fee_reserve_sats, melt_quote_id + /// WHERE remittance_id = :id AND state = 'planned' AND spending_since_unix IS NULL AND owner = :owner + /// ``` + /// + /// — so a row that was admitted (spending, quote bound), resolved by another process, or never + /// ours changes ZERO rows ⇒ `Ok(None)`: the caller refuses before the fence and prints that the + /// row changed under it. `gross_sats` is untouched, the receipts stay pinned to the row's + /// `remittance_id` (the ORIGINAL payment hash), the owner and lease stand; one row per attempt. + /// The new `payment_hash` must be unused by any earlier row (the column is UNIQUE) and `net` + /// must fit under the gross. + pub fn replan_remittance( + &self, + remittance_id: &str, + owner: &str, + replan: &RemittanceReplan, + ) -> Result, StoreError> { + if replan.payment_hash.trim().is_empty() || replan.bolt11.trim().is_empty() { + return Err(StoreError( + "a re-plan names the new invoice: payment hash and bolt11".to_owned(), + )); + } + let mut conn = self.lock()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let gross: Option = tx + .query_row( + "SELECT gross_sats FROM fee_remittances WHERE remittance_id = ?1", + params![remittance_id], + |row| row.get(0), + ) + .optional()?; + let Some(gross) = gross else { + tx.commit()?; + return Ok(None); + }; + if replan.net_sats as i64 > gross { + return Err(StoreError(format!( + "re-planned net {} exceeds gross {gross}: the melt fee must come out of the gross, never on top", + replan.net_sats + ))); + } + let changed = tx.execute( + "UPDATE fee_remittances + SET net_sats = ?3, payment_hash = ?4, bolt11 = ?5, + melt_fee_reserve_sats = ?6, melt_quote_id = ?7 + WHERE remittance_id = ?1 AND state = ?8 AND spending_since_unix IS NULL AND owner = ?2", + params![ + remittance_id, + owner, + replan.net_sats as i64, + replan.payment_hash, + replan.bolt11, + replan.melt_fee_reserve_sats as i64, + replan.melt_quote_id, + RemittanceState::Planned.column_value(), + ], + )?; + if changed == 0 { + tx.commit()?; + return Ok(None); + } + let row = tx.query_row( + &format!( + "SELECT {} FROM fee_remittances f WHERE f.remittance_id = ?1", + Self::REMITTANCE_COLUMNS + ), + params![remittance_id], + Self::read_remittance, + )?; + tx.commit()?; + debug_assert_eq!(row.state, RemittanceState::Planned); + Ok(Some(row)) + } + + /// Mark a `planned` remittance settled: the melt confirmed (or the mint reports the quote PAID + /// on reconciliation). The [`RemitSettlement`] carries what was observed — `None` where it could + /// not be — and says which path settled the row. Pinned receipts stay discharged. Refused if the + /// row is not `planned` — a settled or failed row never moves again. + pub fn settle_remittance( + &self, + remittance_id: &str, + settlement: &RemitSettlement, + now_unix: i64, + ) -> Result { + let mut conn = self.lock()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed = tx.execute( + "UPDATE fee_remittances + SET state = ?6, + settled_at_unix = ?2, + melt_fee_sats = ?3, + net_sats = COALESCE(?4, net_sats), + melt_quote_id = COALESCE(?5, melt_quote_id), + melt_fee_reserve_sats = COALESCE(?8, melt_fee_reserve_sats), + settled_by = ?9 + WHERE remittance_id = ?1 AND state = ?7", + params![ + remittance_id, + now_unix, + settlement.melt_fee_sats.map(|fee| fee as i64), + settlement.net_paid_sats.map(|net| net as i64), + settlement.melt_quote_id, + RemittanceState::Settled.column_value(), + RemittanceState::Planned.column_value(), + settlement + .melt_fee_reserve_sats + .map(|reserve| reserve as i64), + settlement.settled_by.as_str(), + ], + )?; + if changed == 0 { + return Err(StoreError(format!( + "remittance {remittance_id} is not planned; refusing to settle it" + ))); + } + let row = tx.query_row( + &format!( + "SELECT {} FROM fee_remittances f WHERE f.remittance_id = ?1", + Self::REMITTANCE_COLUMNS + ), + params![remittance_id], + Self::read_remittance, + )?; + tx.commit()?; + Ok(row) + } + + /// **Release** the in-flight remittance — mark it failed and return its receipts to unremitted + /// so the next attempt pays them — by ONE conditional update whose predicate is the REASON for + /// the release ([`ReleaseOn`]), in one `IMMEDIATE` transaction (addendum 5 §1, rule 2). The + /// decision to release is taken on a snapshot of the row and the mint's answer; by the time + /// the UPDATE runs, the row may have moved — its owner may have been admitted (it is now + /// spending, bound to a quote), or another process may have resolved it. Each predicate + /// requires the row to still be in the state the reason was decided on, so a stale decision + /// changes ZERO rows rather than revoking a newer admission. **Zero rows changed ⇒ `Ok(None)`: + /// HOLD** — nothing written, and the caller prints that the row changed under it; never an + /// error that aborts the run. + /// + /// The predicates, each on top of `remittance_id = :id AND state = 'planned'`: + /// - [`ReleaseOn::TerminalBoundQuote`] — a SPENDING row, by its bound quote: + /// `AND spending_since_unix IS NOT NULL AND spending_quote_id = :quote`. It names the quote, + /// so a release decided on some other quote's state changes nothing. **No automatic caller** + /// since addendum 6: `fee_remit::reconcile_decision` holds a bound spending row on everything + /// but PAID (the mint pays an UNPAID or FAILED quote regardless of expiry, so no observation + /// proves the bound quote cannot still debit); the transition and its tests are retained as + /// the store's conditional primitive only. A bound spending row is released by nobody in this + /// round. + /// - [`ReleaseOn::TerminalUnboundSpending`] — a spending row a v12 binary admitted without + /// binding a quote: `AND spending_since_unix IS NOT NULL AND spending_quote_id IS NULL`. + /// - [`ReleaseOn::TerminalQuotePlanned`] — a PLANNED row (never admitted) whose invoice's quote + /// the mint reports terminal: `AND spending_since_unix IS NULL`. + /// - [`ReleaseOn::LeaseExpired`] — a PLANNED row whose owner's lease has run out: + /// `AND spending_since_unix IS NULL AND lease_until_unix <= :now`. Lease expiry never touches + /// a spending row (addendum 4 §1.2), and the clock is compared IN the predicate, so an + /// admission that landed first (fresh clock, inside its own lock) is not revoked by a release + /// decided on a snapshot taken before it. + /// - [`ReleaseOn::OwnPlanned`] — this process's own PLANNED row (its earlier attempt is over, + /// or it refused before spending): `AND spending_since_unix IS NULL AND owner = :owner`. + /// + /// A missing lease (pre-v11 row) is read as expired by [`ReleaseOn::LeaseExpired`] + /// (`lease_until_unix IS NULL` counts), matching [`FeeRemittance::lease_expired`]. + pub fn release_remittance( + &self, + remittance_id: &str, + on: &ReleaseOn, + now_unix: i64, + ) -> Result, StoreError> { + let mut conn = self.lock()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let failed = RemittanceState::Failed.column_value(); + let planned = RemittanceState::Planned.column_value(); + let changed = match on { + ReleaseOn::TerminalBoundQuote { quote_id } => tx.execute( + "UPDATE fee_remittances SET state = ?3, settled_at_unix = ?2 + WHERE remittance_id = ?1 AND state = ?4 + AND spending_since_unix IS NOT NULL AND spending_quote_id = ?5", + params![remittance_id, now_unix, failed, planned, quote_id], + )?, + ReleaseOn::TerminalUnboundSpending => tx.execute( + "UPDATE fee_remittances SET state = ?3, settled_at_unix = ?2 + WHERE remittance_id = ?1 AND state = ?4 + AND spending_since_unix IS NOT NULL AND spending_quote_id IS NULL", + params![remittance_id, now_unix, failed, planned], + )?, + ReleaseOn::TerminalQuotePlanned => tx.execute( + "UPDATE fee_remittances SET state = ?3, settled_at_unix = ?2 + WHERE remittance_id = ?1 AND state = ?4 AND spending_since_unix IS NULL", + params![remittance_id, now_unix, failed, planned], + )?, + ReleaseOn::LeaseExpired { now_unix: at } => tx.execute( + "UPDATE fee_remittances SET state = ?3, settled_at_unix = ?2 + WHERE remittance_id = ?1 AND state = ?4 AND spending_since_unix IS NULL + AND (lease_until_unix IS NULL OR lease_until_unix <= ?5)", + params![remittance_id, now_unix, failed, planned, at], + )?, + ReleaseOn::OwnPlanned { owner } => tx.execute( + "UPDATE fee_remittances SET state = ?3, settled_at_unix = ?2 + WHERE remittance_id = ?1 AND state = ?4 AND spending_since_unix IS NULL + AND owner = ?5", + params![remittance_id, now_unix, failed, planned, owner], + )?, + }; + if changed == 0 { + // The row is not as the reason found it: HOLD, touch nothing (not even the receipts). + return Ok(None); + } + tx.execute( + "UPDATE receipts SET remittance_id = NULL WHERE remittance_id = ?1", + params![remittance_id], + )?; + let row = tx.query_row( + &format!( + "SELECT {} FROM fee_remittances f WHERE f.remittance_id = ?1", + Self::REMITTANCE_COLUMNS + ), + params![remittance_id], + Self::read_remittance, + )?; + tx.commit()?; + Ok(Some(row)) + } + + /// Journal one remittance attempt and its outcome (see `fee_remit_attempts`). Returns the + /// assigned `attempt_id`. Append-only: nothing here is ever updated or deleted. + pub fn record_remit_attempt(&self, attempt: &RemitAttempt) -> Result { + let conn = self.lock()?; + conn.execute( + "INSERT INTO fee_remit_attempts + (started_at_unix, trigger, unremitted_sats, outcome, detail, remittance_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + attempt.started_at_unix, + attempt.trigger.as_str(), + attempt.unremitted_sats as i64, + attempt.outcome.as_str(), + attempt.detail, + attempt.remittance_id, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// The most recent `limit` remittance attempts, NEWEST first — what `maxplayer seller fees + /// remit` prints so an operator can see whether the automatic payout has been landing. + pub fn recent_remit_attempts(&self, limit: usize) -> Result, StoreError> { + let conn = self.lock()?; + let mut statement = conn.prepare( + "SELECT attempt_id, started_at_unix, trigger, unremitted_sats, outcome, detail, + remittance_id + FROM fee_remit_attempts + ORDER BY attempt_id DESC + LIMIT ?1", + )?; + let rows = statement + .query_map([i64::try_from(limit).unwrap_or(i64::MAX)], |row| { + let trigger_raw: String = row.get(2)?; + let outcome_raw: String = row.get(4)?; + let trigger = RemitAttemptTrigger::parse(&trigger_raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + let outcome = RemitAttemptOutcome::parse(&outcome_raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + Ok(RemitAttempt { + attempt_id: row.get(0)?, + started_at_unix: row.get(1)?, + trigger, + unremitted_sats: u64::try_from(row.get::<_, i64>(3)?).unwrap_or(0), + outcome, + detail: row.get(5)?, + remittance_id: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + + /// Whether a delivery has been journaled for `job_id` (#552). A delivery row is written only by + /// [`Self::deliver_and_enqueue`], atomically with the `delivered` state advance — so this is the + /// durable proof the result was already produced and enqueued, independent of the `state` column + /// (belt-and-braces against a lagged state). + pub fn has_delivery(&self, job_id: &str) -> Result { + let conn = self.lock()?; + let found = conn + .query_row( + "SELECT 1 FROM deliveries WHERE job_id = ?1 LIMIT 1", + [job_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + Ok(found) + } + + /// The delivery commit oid journaled at push time for `job_id`, if any (#552). `Some` on a + /// still-`awarded`/`executing` row means the delivery was pushed but the enqueue was interrupted + /// — resume finalizes from it instead of re-running the agent. `None` ⇒ never pushed. + pub fn pushed_commit(&self, job_id: &str) -> Result, StoreError> { + let conn = self.lock()?; + let commit: Option = conn + .query_row( + "SELECT pushed_commit FROM jobs WHERE job_id = ?1", + [job_id], + |row| row.get(0), + ) + .optional()? + .flatten(); + Ok(commit) + } + + /// #563 — mark a job RELAY-DERIVED as settled elsewhere: a resume refine fetched POSITIVE + /// settlement evidence for its offer from the relay (our own already-published result, or a buyer + /// receipt — settled with us or another seat). Written ONLY after that evidence is in hand + /// (arm-after-the-event), never speculatively on the way into the query, so a crash between issuing + /// the derive and getting evidence leaves the row re-checkable next restart. Idempotent — last + /// write wins; does NOT change `state`. Provenance-honest: relay-DERIVED, distinct from a local + /// `deliveries` row (which only [`Self::deliver_and_enqueue`] writes). + pub fn mark_settled_elsewhere(&self, job_id: &str, now_unix: i64) -> Result<(), StoreError> { + let conn = self.lock()?; + conn.execute( + "UPDATE jobs SET settled_elsewhere_at_unix = ?2, updated_at_unix = ?2 WHERE job_id = ?1", + params![job_id, now_unix], + )?; + Ok(()) + } + + /// Whether `job_id` was relay-derived as settled elsewhere (see [`Self::mark_settled_elsewhere`]). + /// A resume refine consults this FIRST and short-circuits — a durable marker means it need never + /// re-query the relay. + pub fn has_settled_elsewhere(&self, job_id: &str) -> Result { + let conn = self.lock()?; + let found = conn + .query_row( + "SELECT 1 FROM jobs WHERE job_id = ?1 AND settled_elsewhere_at_unix IS NOT NULL", + [job_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + Ok(found) + } + + // ---- Outbox --------------------------------------------------------------------------------- + + /// Every still-`pending` outbox row that has not yet expired (`expires_at_unix > now`), + /// oldest first — the batch the publisher must send. + pub fn pending_outbox(&self, now_unix: i64) -> Result, StoreError> { + let conn = self.lock()?; + let mut stmt = conn.prepare( + "SELECT id, dedup_key, draft_json, created_at_unix, attempts, expires_at_unix + FROM nostr_event_outbox + WHERE state = 'pending' AND expires_at_unix > ?1 + ORDER BY id", + )?; + let rows = stmt.query_map([now_unix], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + )) + })?; + let mut items = Vec::new(); + for row in rows { + let (id, dedup_key, draft_json, created_at_unix, attempts, expires_at_unix) = row?; + let draft: EventDraft = serde_json::from_str(&draft_json) + .map_err(|error| StoreError(format!("outbox draft decode: {error}")))?; + items.push(OutboxItem { + id, + dedup_key, + draft, + created_at_unix, + attempts, + expires_at_unix, + }); + } + Ok(items) } /// Mark an outbox row confirmed by the relay, recording the published event id. @@ -2535,7 +3772,8 @@ mod tests { mint_fee_sats: Some(1), fee_bps: 200, fee_sats: 2, - received_at_unix: 3 + received_at_unix: 3, + remittance_id: None, }, JobFeeAccrual { job_id: job_b.clone(), @@ -2543,7 +3781,8 @@ mod tests { mint_fee_sats: Some(3), fee_bps: 250, fee_sats: 25, - received_at_unix: 4 + received_at_unix: 4, + remittance_id: None, }, ], "one row per receipt, oldest first, each carrying the rate in force and what it came to" @@ -2613,6 +3852,7 @@ mod tests { fee_bps: 0, fee_sats: 0, received_at_unix: 7, + remittance_id: None, }], "the pre-existing receipt is untouched, reads a fee of 0/0, and has NO mint fee (not a zero one)" ); @@ -2694,6 +3934,7 @@ mod tests { fee_bps: 1000, fee_sats: 10, received_at_unix: 7, + remittance_id: None, }], "the v8 row keeps its fee and carries NO mint fee" ); @@ -2736,6 +3977,1215 @@ mod tests { let _ = std::fs::remove_file(&path); } + // ---- Stage 2a: the fee remittance ledger ---- + + fn plan(hash: &str, gross: u64, net: u64) -> RemittancePlan { + RemittancePlan { + payment_hash: hash.to_owned(), + gross_sats: gross, + net_sats: net, + melt_fee_reserve_sats: gross.saturating_sub(net), + destination: "maxplayer@agi.cash".to_owned(), + bolt11: format!("lnbc-test-{hash}"), + melt_quote_id: Some(format!("quote-{hash}")), + } + } + + /// The test process's owner token and a lease far in the future: these tests are about the + /// ledger, not the lease; `ownership_*` below are about the lease. + const OWNER: &str = "test-owner"; + const LEASE: i64 = 1_000_000; + + fn by_melt(net: u64, fee: u64, quote: Option<&str>) -> RemitSettlement { + RemitSettlement { + net_paid_sats: Some(net), + melt_fee_sats: Some(fee), + melt_fee_reserve_sats: Some(fee.saturating_add(1)), + melt_quote_id: quote.map(str::to_owned), + settled_by: SettledBy::Melt, + } + } + + fn by_reconciliation(quote: Option<&str>, reserve: Option) -> RemitSettlement { + RemitSettlement { + net_paid_sats: None, + melt_fee_sats: None, + melt_fee_reserve_sats: reserve, + melt_quote_id: quote.map(str::to_owned), + settled_by: SettledBy::Reconciliation, + } + } + + // A store written by a v9 binary (mint_fee_sats present, NO remittance_id column, no + // fee_remittances table) opens under v10: the column and table are added additively, the + // existing receipt reads as UNREMITTED (remittance_id None), the totals put its fee in + // `unremitted_fee_sats`, and a second open is a no-op. + #[test] + fn a_v9_store_migrates_to_v10_and_reads_its_receipts_as_unremitted() { + let path = temp_db("pre-remittance"); + let _ = std::fs::remove_file(&path); + { + let conn = Connection::open(&path).expect("create v9 store"); + conn.execute_batch( + "CREATE TABLE seller_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO seller_meta VALUES ('schema_version', '9'); + CREATE TABLE receipts ( + receipt_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + amount_sats INTEGER NOT NULL CHECK (amount_sats >= 0), + received_at_unix INTEGER NOT NULL, + fee_bps INTEGER NOT NULL DEFAULT 0 CHECK (fee_bps >= 0 AND fee_bps <= 10000), + fee_sats INTEGER NOT NULL DEFAULT 0 CHECK (fee_sats >= 0), + mint_fee_sats INTEGER CHECK (mint_fee_sats IS NULL OR mint_fee_sats >= 0) + ); + INSERT INTO receipts VALUES ('v9-receipt', 'v9-job', 100, 7, 1000, 10, 1);", + ) + .expect("v9 schema"); + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .expect("prepare") + .query_map([], |row| row.get(0)) + .expect("query") + .collect::>() + .expect("names"); + assert!( + !tables.iter().any(|name| name == "fee_remittances"), + "fixture must predate the remittance table: {tables:?}" + ); + } + + let store = SellerStore::open(&path).expect("a v9 store opens clean under v10"); + assert_eq!( + store.health().expect("health").schema_version, + SCHEMA_VERSION + ); + let accrued = store.accrued_fees().expect("read-out on a migrated store"); + assert_eq!( + accrued.by_job, + vec![JobFeeAccrual { + job_id: "v9-job".to_owned(), + amount_sats: 100, + mint_fee_sats: Some(1), + fee_bps: 1000, + fee_sats: 10, + received_at_unix: 7, + remittance_id: None, + }], + "the v9 row keeps every figure and is UNREMITTED" + ); + assert_eq!(accrued.total_fee_sats, 10); + assert_eq!(accrued.unremitted_fee_sats, 10); + assert_eq!(accrued.remitted_fee_sats, 0); + assert_eq!(accrued.in_flight_fee_sats, 0); + assert!(store.remittances().expect("table exists").is_empty()); + assert_eq!(store.in_flight_remittance().expect("query"), None); + + // The migrated store can plan against its old row: the column is writable. + let row = store + .plan_remittance(&plan("h1", 10, 9), OWNER, LEASE, 100) + .expect("plan on migrated store"); + assert_eq!(row.receipts, 1); + assert_eq!( + store.accrued_fees().expect("read-out").by_job[0].remittance_id, + Some("h1".to_owned()) + ); + + drop(store); + let store = SellerStore::open(&path).expect("second open is a no-op"); + assert_eq!( + store.health().expect("health").schema_version, + SCHEMA_VERSION + ); + assert_eq!(store.remittances().expect("rows").len(), 1); + let _ = std::fs::remove_file(&path); + } + + // The load-bearing property (brief §3.4): plan pins exactly the unremitted receipts; settle keeps + // them discharged so the unremitted balance is ZERO afterwards and a second plan finds nothing to + // remit; a receipt collected AFTER the plan is not swept into it. + #[test] + fn plan_then_settle_discharges_the_receipts_and_a_second_plan_finds_nothing() { + let (store, path) = fresh_store("remit-settle"); + store + .collect_receipt("r1", "job-1", 100, fees(1, 1000, 10), 1) + .expect("collect 1"); + store + .collect_receipt("r2", "job-2", 50, fees(1, 1000, 5), 2) + .expect("collect 2"); + // A zero-fee receipt (a 9-sat job at 10% owes 0) is discharged too — it owes nothing. + store + .collect_receipt("r0", "job-0", 9, fees(0, 1000, 0), 3) + .expect("collect 0"); + let accrued = store.accrued_fees().expect("read-out"); + assert_eq!(accrued.unremitted_fee_sats, 15); + + let planned = store + .plan_remittance(&plan("h1", 15, 14), OWNER, LEASE, 10) + .expect("plan"); + assert_eq!(planned.state, RemittanceState::Planned); + assert_eq!(planned.remittance_id, "h1"); + assert_eq!(planned.payment_hash, "h1"); + assert_eq!((planned.gross_sats, planned.net_sats), (15, 14)); + assert_eq!(planned.melt_fee_sats, None); + assert_eq!(planned.destination, "maxplayer@agi.cash"); + assert_eq!(planned.melt_quote_id, Some("quote-h1".to_owned())); + assert_eq!(planned.bolt11, "lnbc-test-h1"); + assert_eq!(planned.receipts, 3); + assert_eq!(planned.owner.as_deref(), Some(OWNER)); + assert_eq!(planned.lease_until_unix, Some(LEASE)); + assert_eq!(planned.melt_fee_reserve_sats, Some(1)); + assert_eq!(planned.settled_by, None); + assert_eq!( + (planned.created_at_unix, planned.settled_at_unix), + (10, None) + ); + let accrued = store.accrued_fees().expect("read-out"); + assert_eq!( + accrued.unremitted_fee_sats, 0, + "pinned receipts are no longer unremitted" + ); + assert_eq!(accrued.in_flight_fee_sats, 15, "…they are in flight"); + assert_eq!(accrued.remitted_fee_sats, 0); + assert_eq!( + accrued.total_fee_sats, 15, + "the all-time total does not move" + ); + assert!( + accrued + .by_job + .iter() + .all(|row| row.remittance_id.as_deref() == Some("h1")) + ); + assert_eq!( + store.in_flight_remittance().expect("query"), + Some(planned.clone()) + ); + + // A receipt collected while the payment is in flight is NOT swept into it. + store + .collect_receipt("r3", "job-3", 200, fees(2, 1000, 20), 11) + .expect("collect 3"); + let accrued = store.accrued_fees().expect("read-out"); + assert_eq!(accrued.unremitted_fee_sats, 20); + assert_eq!(accrued.in_flight_fee_sats, 15); + + // Settle with what the mint reported: paid 14, fee 1, quote id from the payment. + let settled = store + .settle_remittance("h1", &by_melt(14, 1, Some("quote-pay-h1")), 12) + .expect("settle"); + assert_eq!(settled.state, RemittanceState::Settled); + assert_eq!(settled.melt_fee_sats, Some(1)); + assert_eq!(settled.net_sats, 14); + assert_eq!(settled.melt_quote_id, Some("quote-pay-h1".to_owned())); + assert_eq!(settled.settled_by, Some(SettledBy::Melt)); + assert_eq!( + settled.melt_fee_reserve_sats, + Some(2), + "replaced by the reserve of the quote that paid" + ); + assert_eq!(settled.settled_at_unix, Some(12)); + assert_eq!(settled.receipts, 3); + let accrued = store.accrued_fees().expect("read-out"); + assert_eq!(accrued.remitted_fee_sats, 15); + assert_eq!(accrued.in_flight_fee_sats, 0); + assert_eq!( + accrued.unremitted_fee_sats, 20, + "only the post-plan receipt is owed" + ); + assert_eq!(store.in_flight_remittance().expect("query"), None); + + // A settled row never moves again. + assert!( + store + .settle_remittance("h1", &by_reconciliation(None, None), 13) + .is_err() + ); + assert_eq!( + store + .release_remittance( + "h1", + &ReleaseOn::OwnPlanned { + owner: OWNER.to_owned() + }, + 13 + ) + .expect("query"), + None, + "a settled row is not released: zero rows, hold" + ); + + // The next plan covers exactly the new receipt; planning the OLD figure is a mismatch. + assert_eq!( + store.plan_remittance(&plan("h2", 15, 15), OWNER, LEASE, 14), + Err(PlanRefused::GrossMismatch { + planned: 15, + unremitted: 20 + }) + ); + let second = store + .plan_remittance(&plan("h2", 20, 19), OWNER, LEASE, 14) + .expect("second plan"); + assert_eq!(second.receipts, 1); + store + .settle_remittance("h2", &by_melt(19, 1, None), 15) + .expect("settle 2"); + let accrued = store.accrued_fees().expect("read-out"); + assert_eq!(accrued.unremitted_fee_sats, 0); + assert_eq!(accrued.remitted_fee_sats, 35); + // Everything is discharged: a third plan has nothing to remit, whatever figure it claims. + assert_eq!( + store.plan_remittance(&plan("h3", 0, 0), OWNER, LEASE, 16), + Err(PlanRefused::NothingToRemit) + ); + assert_eq!( + store.plan_remittance(&plan("h3", 35, 35), OWNER, LEASE, 16), + Err(PlanRefused::NothingToRemit) + ); + assert_eq!(store.remittances().expect("rows").len(), 2); + let _ = std::fs::remove_file(&path); + } + + // The crossmint_hop precedent: while a planned row exists, a second plan is REFUSED — at the + // API and, belt-and-braces, by the partial unique index — so no second payment can be journaled + // against receipts that may already be paid for. + #[test] + fn a_second_plan_while_one_is_in_flight_is_refused_by_api_and_by_index() { + let (store, path) = fresh_store("remit-duplicate"); + store + .collect_receipt("r1", "job-1", 100, fees(1, 1000, 10), 1) + .expect("collect"); + let first = store + .plan_remittance(&plan("h1", 10, 9), OWNER, LEASE, 2) + .expect("first plan"); + match store.plan_remittance(&plan("h2", 10, 9), OWNER, LEASE, 3) { + Err(PlanRefused::InFlight(active)) => assert_eq!(*active, first), + other => panic!("expected InFlight, got {other:?}"), + } + // Even a plan for a fresh receipt is refused while the first is unresolved. + store + .collect_receipt("r2", "job-2", 100, fees(1, 1000, 10), 4) + .expect("collect 2"); + assert!(matches!( + store.plan_remittance(&plan("h3", 10, 9), OWNER, LEASE, 5), + Err(PlanRefused::InFlight(_)) + )); + // The index refuses a raw second planned row too. + { + let conn = store.lock().expect("lock"); + let raw = conn.execute( + "INSERT INTO fee_remittances + (remittance_id, gross_sats, net_sats, destination, payment_hash, bolt11, state, created_at_unix) + VALUES ('raw', 1, 1, 'x@y', 'raw', 'ln', 'planned', 6)", + [], + ); + assert!( + raw.is_err(), + "the partial unique index must refuse a second planned row" + ); + } + // Nothing was written by the refusals. + assert_eq!(store.remittances().expect("rows").len(), 1); + assert_eq!( + store.accrued_fees().expect("read-out").unremitted_fee_sats, + 10 + ); + let _ = std::fs::remove_file(&path); + } + + // Recovery: a planned row whose melt never happened is FAILED, which releases its receipts back + // to unremitted so the next attempt pays them — and the failed row keeps its history. The same + // invoice can never be journaled twice, in any state. + #[test] + fn fail_releases_the_receipts_and_the_invoice_stays_used() { + let (store, path) = fresh_store("remit-fail"); + store + .collect_receipt("r1", "job-1", 100, fees(1, 1000, 10), 1) + .expect("collect"); + store + .plan_remittance(&plan("h1", 10, 9), OWNER, LEASE, 2) + .expect("plan"); + // A planned row is released on the reasons that apply to a PLANNED row — and each release + // is conditional: the wrong reason (this row is not spending, has no bound quote, its lease + // stands, and it is OWNER's) changes zero rows and touches nothing. + assert_eq!( + store + .release_remittance( + "h1", + &ReleaseOn::TerminalBoundQuote { + quote_id: "q-any".to_owned() + }, + 3 + ) + .expect("query"), + None, + "a planned row has no bound quote: the spending release changes zero rows" + ); + assert_eq!( + store + .release_remittance( + "h1", + &ReleaseOn::LeaseExpired { + now_unix: LEASE - 1 + }, + 3 + ) + .expect("query"), + None, + "the lease stands: zero rows" + ); + assert_eq!( + store + .release_remittance( + "h1", + &ReleaseOn::OwnPlanned { + owner: "someone-else".to_owned() + }, + 3 + ) + .expect("query"), + None, + "not that process's row: zero rows" + ); + assert_eq!( + store.accrued_fees().expect("read-out").in_flight_fee_sats, + 10, + "three held releases touched nothing" + ); + let failed = store + .release_remittance( + "h1", + &ReleaseOn::OwnPlanned { + owner: OWNER.to_owned(), + }, + 3, + ) + .expect("query") + .expect("released"); + assert_eq!(failed.state, RemittanceState::Failed); + assert_eq!(failed.settled_at_unix, Some(3)); + assert_eq!(failed.receipts, 0, "its receipts were released"); + let accrued = store.accrued_fees().expect("read-out"); + assert_eq!(accrued.unremitted_fee_sats, 10); + assert_eq!(accrued.in_flight_fee_sats, 0); + assert_eq!(accrued.by_job[0].remittance_id, None); + assert_eq!(store.in_flight_remittance().expect("query"), None); + // A failed row never moves again. + assert!( + store + .settle_remittance("h1", &by_reconciliation(None, None), 4) + .is_err() + ); + assert_eq!( + store + .release_remittance( + "h1", + &ReleaseOn::OwnPlanned { + owner: OWNER.to_owned() + }, + 4 + ) + .expect("query"), + None + ); + // The same invoice cannot be re-planned; a fresh one can. + assert_eq!( + store.plan_remittance(&plan("h1", 10, 9), OWNER, LEASE, 5), + Err(PlanRefused::DuplicateInvoice { + payment_hash: "h1".to_owned() + }) + ); + assert_eq!( + store.accrued_fees().expect("read-out").unremitted_fee_sats, + 10, + "a refused plan pins nothing" + ); + let second = store + .plan_remittance(&plan("h2", 10, 9), OWNER, LEASE, 6) + .expect("re-plan"); + assert_eq!(second.receipts, 1); + // Settling by reconciliation (mint says PAID, fee unobserved) records None for the fee. + let settled = store + .settle_remittance("h2", &by_reconciliation(Some("quote-seen"), Some(2)), 7) + .expect("settle by reconciliation"); + assert_eq!(settled.melt_fee_sats, None); + assert_eq!( + settled.net_sats, 9, + "the planned net stands when the mint's figure is unobserved" + ); + assert_eq!(settled.melt_quote_id, Some("quote-seen".to_owned())); + assert_eq!( + settled.settled_by, + Some(SettledBy::Reconciliation), + "the row says HOW it was settled, which is why its fee is unobserved" + ); + assert_eq!( + settled.melt_fee_reserve_sats, + Some(2), + "the paying quote's reserve — the fee's ceiling — IS observable and is recorded" + ); + let history = store.remittances().expect("rows"); + assert_eq!( + history + .iter() + .map(|row| (row.remittance_id.as_str(), row.state)) + .collect::>(), + vec![ + ("h1", RemittanceState::Failed), + ("h2", RemittanceState::Settled) + ] + ); + let _ = std::fs::remove_file(&path); + } + + // The amount rule (brief §3.3) at the store boundary: a net above the gross is refused before any + // transaction, and the schema refuses it too. + #[test] + fn a_plan_whose_net_exceeds_its_gross_is_refused() { + let (store, path) = fresh_store("remit-net-gt-gross"); + store + .collect_receipt("r1", "job-1", 100, fees(1, 1000, 10), 1) + .expect("collect"); + assert!(matches!( + store.plan_remittance(&plan("h1", 10, 11), OWNER, LEASE, 2), + Err(PlanRefused::Store(_)) + )); + assert!(store.remittances().expect("rows").is_empty()); + let conn = store.lock().expect("lock"); + assert!( + conn.execute( + "INSERT INTO fee_remittances + (remittance_id, gross_sats, net_sats, destination, payment_hash, bolt11, state, created_at_unix) + VALUES ('raw', 10, 11, 'x@y', 'raw', 'ln', 'planned', 3)", + [], + ) + .is_err(), + "CHECK (net_sats <= gross_sats) must refuse" + ); + drop(conn); + let _ = std::fs::remove_file(&path); + } + + // Addendum 3 §2.4: a store written by a v10 binary (the remittance table WITHOUT owner / lease / + // reserve / settled_by) opens under v11 additively — the four columns are added, its planned row + // survives and reads as owned by NOBODY with an EXPIRED lease (fail-closed: not yours to pay, + // releasable by reconciliation), its settled row reads `settled_by = None` (not recorded, not + // invented) — and a second open is a no-op. + #[test] + fn a_v10_store_migrates_to_v11_additively_and_its_rows_read_as_unowned() { + let path = temp_db("v10-to-v11"); + let _ = std::fs::remove_file(&path); + { + let conn = Connection::open(&path).expect("create v10 store"); + conn.execute_batch( + "CREATE TABLE seller_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO seller_meta VALUES ('schema_version', '10'); + CREATE TABLE receipts ( + receipt_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + amount_sats INTEGER NOT NULL CHECK (amount_sats >= 0), + received_at_unix INTEGER NOT NULL, + fee_bps INTEGER NOT NULL DEFAULT 0, + fee_sats INTEGER NOT NULL DEFAULT 0, + mint_fee_sats INTEGER, + remittance_id TEXT + ); + INSERT INTO receipts VALUES ('r-settled', 'job-s', 100, 1, 1000, 10, 1, 'v10-settled'); + INSERT INTO receipts VALUES ('r-planned', 'job-p', 50, 2, 1000, 5, 1, 'v10-planned'); + CREATE TABLE fee_remittances ( + remittance_id TEXT PRIMARY KEY, + gross_sats INTEGER NOT NULL CHECK (gross_sats >= 0), + melt_fee_sats INTEGER, + net_sats INTEGER NOT NULL CHECK (net_sats >= 0 AND net_sats <= gross_sats), + destination TEXT NOT NULL, + melt_quote_id TEXT, + payment_hash TEXT NOT NULL UNIQUE, + bolt11 TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('planned','settled','failed')), + created_at_unix INTEGER NOT NULL, + settled_at_unix INTEGER + ); + CREATE UNIQUE INDEX fee_remittances_one_planned ON fee_remittances (state) WHERE state = 'planned'; + INSERT INTO fee_remittances VALUES ('v10-settled', 10, 1, 9, 'maxplayer@agi.cash', 'q1', 'v10-settled', 'ln1', 'settled', 3, 4); + INSERT INTO fee_remittances VALUES ('v10-planned', 5, NULL, 4, 'maxplayer@agi.cash', 'q2', 'v10-planned', 'ln2', 'planned', 5, NULL);", + ) + .expect("v10 schema"); + } + + let store = SellerStore::open(&path).expect("a v10 store opens clean under v11"); + assert_eq!( + store.health().expect("health").schema_version, + SCHEMA_VERSION + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 2, "both v10 rows survive"); + let settled = rows + .iter() + .find(|r| r.remittance_id == "v10-settled") + .expect("settled"); + assert_eq!(settled.state, RemittanceState::Settled); + assert_eq!( + (settled.gross_sats, settled.melt_fee_sats, settled.net_sats), + (10, Some(1), 9) + ); + assert_eq!( + settled.settled_by, None, + "not recorded by v10, not invented by v11" + ); + assert_eq!(settled.melt_fee_reserve_sats, None); + let planned = rows + .iter() + .find(|r| r.remittance_id == "v10-planned") + .expect("planned"); + assert_eq!(planned.state, RemittanceState::Planned); + assert_eq!(planned.owner, None); + assert_eq!(planned.lease_until_unix, None); + assert!( + planned.lease_expired(0), + "a row planned before ownership was recorded reads as an expired claim by nobody" + ); + assert!(!planned.lease_holds("anyone", 0, 0)); + assert_eq!( + planned.spending_since_unix, None, + "a v10 row's melt was never admitted by a fence: PLANNED, not spending" + ); + assert_eq!( + store + .admit_remittance_spend("v10-planned", "anyone", "q-any", 60, &mut || 6) + .expect("query"), + Err(OwnershipLost::OtherOwner { owner: None }), + "nobody may PAY a pre-v11 planned row; reconciliation releases or settles it" + ); + let accrued = store.accrued_fees().expect("read-out"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (10, 5, 0) + ); + // Reconciliation can still release it, and the release reads back through the new columns. + // Its missing lease reads as run out, so the lease-expiry release applies to it. + let released = store + .release_remittance("v10-planned", &ReleaseOn::LeaseExpired { now_unix: 7 }, 7) + .expect("query") + .expect("released"); + assert_eq!(released.state, RemittanceState::Failed); + assert_eq!( + store.accrued_fees().expect("read-out").unremitted_fee_sats, + 5 + ); + + drop(store); + let store = SellerStore::open(&path).expect("second open is a no-op"); + assert_eq!( + store.health().expect("health").schema_version, + SCHEMA_VERSION + ); + assert_eq!(store.remittances().expect("rows").len(), 2); + let _ = std::fs::remove_file(&path); + } + + // Addendum 4 §1.1: the pre-spend fence is ONE compare-and-set in the store — planned → spending + // only for the owner, only while the row is planned, and only while the lease ends MORE than the + // margin after the clock the caller reads at that instant. Zero rows changed is diagnosed from + // the row as it stands; a row admitted once is not admitted twice. + #[test] + fn the_spend_fence_admits_only_the_owner_of_a_planned_row_with_lease_to_spare_and_only_once() { + let (store, path) = fresh_store("remit-fence"); + store + .collect_receipt("r1", "job-1", 100, fees(1, 1000, 10), 1) + .expect("collect"); + assert!( + matches!( + store.plan_remittance(&plan("h0", 10, 9), " ", 500, 2), + Err(PlanRefused::Store(_)) + ), + "an empty owner token is refused" + ); + let planned = store + .plan_remittance(&plan("h1", 10, 9), "proc-a", 500, 2) + .expect("plan"); + assert_eq!(planned.owner.as_deref(), Some("proc-a")); + assert_eq!(planned.lease_until_unix, Some(500)); + assert_eq!(planned.state, RemittanceState::Planned); + assert_eq!(planned.spending_since_unix, None); + + // Another process, however early: zero rows — even a live lease is not ITS lease. + assert_eq!( + store + .admit_remittance_spend("h1", "proc-b", "q-b", 60, &mut || 3) + .expect("query"), + Err(OwnershipLost::OtherOwner { + owner: Some("proc-a".to_owned()), + }) + ); + // The owner with EXACTLY the margin left: zero rows — `lease_until > now + margin` is + // strict (500 > 440 + 60 is false). The row is untouched: still planned. + assert_eq!( + store + .admit_remittance_spend("h1", "proc-a", "q-a", 60, &mut || 440) + .expect("query"), + Err(OwnershipLost::LeaseTooShort { + lease_until_unix: Some(500), + now_unix: 440, + margin_secs: 60, + }) + ); + assert_eq!( + store + .in_flight_remittance() + .expect("row") + .expect("planned") + .state, + RemittanceState::Planned + ); + assert_eq!( + store + .in_flight_remittance() + .expect("row") + .expect("planned") + .spending_quote_id, + None, + "a refused admission binds no quote" + ); + // One second more to spare: admitted — the row is now SPENDING, stamped with the clock + // read INSIDE the call (the closure runs once, after the lock), bound to the quote named, + // and it is still the one row in flight. + let mut clock_reads = 0; + let admitted = store + .admit_remittance_spend("h1", "proc-a", "q-a", 60, &mut || { + clock_reads += 1; + 439 + }) + .expect("query") + .expect("admitted"); + assert_eq!( + clock_reads, 1, + "the clock is read exactly once, inside the fence" + ); + assert_eq!(admitted.state, RemittanceState::Spending); + assert_eq!(admitted.spending_since_unix, Some(439)); + assert_eq!(admitted.spending_quote_id.as_deref(), Some("q-a")); + assert!(admitted.state.is_in_flight()); + assert_eq!( + store + .in_flight_remittance() + .expect("row") + .expect("spending") + .state, + RemittanceState::Spending + ); + assert_eq!( + store.accrued_fees().expect("read-out").in_flight_fee_sats, + 10, + "a spending row's fee is in flight, not unremitted and not remitted" + ); + // Admitted once is admitted once: the same owner, the same instant, zero rows — and the + // bound quote is not rebound. + assert_eq!( + store + .admit_remittance_spend("h1", "proc-a", "q-a2", 60, &mut || 439) + .expect("query"), + Err(OwnershipLost::NotPlanned { + state: RemittanceState::Spending, + }) + ); + assert_eq!( + store + .in_flight_remittance() + .expect("row") + .expect("spending") + .spending_quote_id + .as_deref(), + Some("q-a") + ); + // A SPENDING row is released by exactly one transition: its BOUND quote terminal. Not on + // its lease (however far past), not as "planned" (it is not), not on some other quote. + for (wrong, why) in [ + ( + ReleaseOn::LeaseExpired { now_unix: 10_000 }, + "lease expiry never touches a spending row", + ), + ( + ReleaseOn::TerminalQuotePlanned, + "the planned-row release requires no admission mark", + ), + ( + ReleaseOn::OwnPlanned { + owner: "proc-a".to_owned(), + }, + "even the owner's own planned-row release: the row is spending", + ), + ( + ReleaseOn::TerminalBoundQuote { + quote_id: "q-a2".to_owned(), + }, + "a terminal verdict on a quote that is not the bound one", + ), + ] { + assert_eq!( + store.release_remittance("h1", &wrong, 10).expect("query"), + None, + "{why}" + ); + } + assert_eq!( + store.accrued_fees().expect("read-out").in_flight_fee_sats, + 10, + "four held releases touched nothing" + ); + let released = store + .release_remittance( + "h1", + &ReleaseOn::TerminalBoundQuote { + quote_id: "q-a".to_owned(), + }, + 10, + ) + .expect("query") + .expect("released on the bound quote"); + assert_eq!(released.state, RemittanceState::Failed); + assert_eq!( + released.spending_quote_id.as_deref(), + Some("q-a"), + "history kept" + ); + // A row that is no longer in flight: zero rows, whoever asks; a missing row says so. + assert_eq!( + store + .admit_remittance_spend("h1", "proc-a", "q-a", 60, &mut || 11) + .expect("query"), + Err(OwnershipLost::NotPlanned { + state: RemittanceState::Failed, + }) + ); + assert_eq!( + store + .admit_remittance_spend("nope", "proc-a", "q-a", 60, &mut || 11) + .expect("query"), + Err(OwnershipLost::Missing) + ); + assert!( + store + .admit_remittance_spend("h1", "proc-a", " ", 60, &mut || 11) + .is_err(), + "no admission without a named quote" + ); + // The pure helper states the same strict predicate the SQL evaluates. + assert!(planned.lease_holds("proc-a", 439, 60)); + assert!(!planned.lease_holds("proc-a", 440, 60)); + assert!(!planned.lease_holds("proc-b", 3, 60)); + assert!(!planned.lease_expired(499)); + assert!(planned.lease_expired(500)); + let _ = std::fs::remove_file(&path); + } + + // Addendum 10 §1.4 (ledger): a re-plan moves ONLY the invoice-side figures of OUR still-planned, + // still-unbound row — net, payment hash, bolt11, reserve, quote — and nothing else: gross, + // remittance_id (the receipts' pin), owner, lease and state are as planned. Another owner, and a + // row already admitted by the fence, change zero rows (`Ok(None)`) and are left exactly as they + // were; a net over the gross is refused before any write. + #[test] + fn replan_remittance_updates_only_our_own_planned_unbound_row_and_keeps_receipts_pinned() { + let (store, path) = fresh_store("remit-replan"); + store + .collect_receipt("r1", "job-1", 100, fees(1, 1000, 10), 1) + .expect("collect"); + store + .collect_receipt("r2", "job-2", 100, fees(1, 1000, 10), 1) + .expect("collect"); + let planned = store + .plan_remittance(&plan("h1", 20, 17), "proc-a", 500, 2) + .expect("plan"); + assert_eq!(planned.net_sats, 17); + assert_eq!(planned.melt_fee_reserve_sats, Some(3)); + let replan = RemittanceReplan { + net_sats: 15, + payment_hash: "h1-replan".to_owned(), + bolt11: "lnbc-test-h1-replan".to_owned(), + melt_fee_reserve_sats: 0, + melt_quote_id: Some("quote-h1-replan".to_owned()), + }; + + // Another owner: zero rows, and the row is untouched. + assert_eq!( + store + .replan_remittance("h1", "proc-b", &replan) + .expect("query"), + None + ); + assert_eq!( + store.in_flight_remittance().expect("row").expect("planned"), + planned, + "a refused re-plan writes nothing" + ); + // A row that does not exist: zero rows. + assert_eq!( + store + .replan_remittance("h-none", "proc-a", &replan) + .expect("query"), + None + ); + // Net over the gross: refused before any write. + assert!( + store + .replan_remittance( + "h1", + "proc-a", + &RemittanceReplan { + net_sats: 21, + ..replan.clone() + }, + ) + .is_err() + ); + assert!( + store + .replan_remittance( + "h1", + "proc-a", + &RemittanceReplan { + payment_hash: " ".to_owned(), + ..replan.clone() + }, + ) + .is_err() + ); + assert_eq!( + store.in_flight_remittance().expect("row").expect("planned"), + planned + ); + + // The owner, on its planned unbound row: ONE row changed; only the invoice-side figures moved. + let replanned = store + .replan_remittance("h1", "proc-a", &replan) + .expect("query") + .expect("re-planned"); + assert_eq!( + replanned.remittance_id, "h1", + "the receipts' pin does not move" + ); + assert_eq!(replanned.gross_sats, 20, "gross is untouched"); + assert_eq!(replanned.net_sats, 15); + assert_eq!(replanned.payment_hash, "h1-replan"); + assert_eq!(replanned.bolt11, "lnbc-test-h1-replan"); + assert_eq!(replanned.melt_fee_reserve_sats, Some(0)); + assert_eq!(replanned.melt_quote_id.as_deref(), Some("quote-h1-replan")); + assert_eq!(replanned.state, RemittanceState::Planned); + assert_eq!(replanned.owner.as_deref(), Some("proc-a")); + assert_eq!(replanned.lease_until_unix, Some(500)); + assert_eq!(replanned.spending_since_unix, None); + assert_eq!(replanned.spending_quote_id, None); + assert_eq!(replanned.melt_fee_sats, None); + assert_eq!(replanned.settled_at_unix, None); + assert_eq!(replanned.created_at_unix, planned.created_at_unix); + let accrued = store.accrued_fees().expect("read-out"); + assert_eq!( + (accrued.in_flight_fee_sats, accrued.unremitted_fee_sats), + (20, 0), + "both receipts stay pinned to the re-planned row" + ); + // A second plan is still refused: the re-planned row is the one in flight. + assert!(matches!( + store.plan_remittance(&plan("h2", 20, 17), "proc-a", 500, 3), + Err(PlanRefused::InFlight(_)) + )); + // The original hash is the row id, so the NEW hash cannot be reused by a later plan. + // (Exercised once the row is terminal; here the fence binds the re-planned quote.) + let admitted = store + .admit_remittance_spend("h1", "proc-a", "quote-h1-replan", 60, &mut || 3) + .expect("query") + .expect("admitted"); + assert_eq!(admitted.state, RemittanceState::Spending); + assert_eq!(admitted.net_sats, 15); + // Once admitted (spending, quote bound): zero rows — a re-plan never moves a spend in + // progress. + assert_eq!( + store + .replan_remittance( + "h1", + "proc-a", + &RemittanceReplan { + net_sats: 14, + payment_hash: "h1-again".to_owned(), + ..replan.clone() + }, + ) + .expect("query"), + None + ); + let held = store + .in_flight_remittance() + .expect("row") + .expect("spending"); + assert_eq!(held.state, RemittanceState::Spending); + assert_eq!(held.net_sats, 15); + assert_eq!(held.payment_hash, "h1-replan"); + assert_eq!(held.spending_quote_id.as_deref(), Some("quote-h1-replan")); + let _ = std::fs::remove_file(&path); + } + + // Addendum 4 §1 (ledger): a store written by a v11 binary — the remittance table WITH owner / + // lease / reserve / settled_by but WITHOUT `spending_since_unix` — opens under v12 additively: + // the one column is added, its planned row survives and reads PLANNED (its melt was never + // admitted by a fence, which is the truth of a row written before the fence existed), its owner + // and lease are exactly as written, the fence then works on it, and a second open is a no-op. + #[test] + fn a_v11_store_migrates_to_v12_additively_and_its_planned_row_reads_as_not_spending() { + let path = temp_db("v11-to-v12"); + let _ = std::fs::remove_file(&path); + { + let conn = Connection::open(&path).expect("create v11 store"); + conn.execute_batch( + "CREATE TABLE seller_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO seller_meta VALUES ('schema_version', '11'); + CREATE TABLE receipts ( + receipt_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + amount_sats INTEGER NOT NULL CHECK (amount_sats >= 0), + received_at_unix INTEGER NOT NULL, + fee_bps INTEGER NOT NULL DEFAULT 0, + fee_sats INTEGER NOT NULL DEFAULT 0, + mint_fee_sats INTEGER, + remittance_id TEXT + ); + INSERT INTO receipts VALUES ('r-planned', 'job-p', 50, 2, 1000, 5, 1, 'v11-planned'); + CREATE TABLE fee_remittances ( + remittance_id TEXT PRIMARY KEY, + gross_sats INTEGER NOT NULL CHECK (gross_sats >= 0), + melt_fee_sats INTEGER, + net_sats INTEGER NOT NULL CHECK (net_sats >= 0 AND net_sats <= gross_sats), + destination TEXT NOT NULL, + melt_quote_id TEXT, + payment_hash TEXT NOT NULL UNIQUE, + bolt11 TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('planned','settled','failed')), + created_at_unix INTEGER NOT NULL, + settled_at_unix INTEGER, + owner TEXT, + lease_until_unix INTEGER, + melt_fee_reserve_sats INTEGER, + settled_by TEXT + ); + CREATE UNIQUE INDEX fee_remittances_one_planned ON fee_remittances (state) WHERE state = 'planned'; + INSERT INTO fee_remittances VALUES ('v11-planned', 5, NULL, 4, 'maxplayer@agi.cash', 'q2', 'v11-planned', 'ln2', 'planned', 100, NULL, 'proc-old', 400, 1, NULL);", + ) + .expect("v11 schema"); + } + + let store = SellerStore::open(&path).expect("a v11 store opens clean under v12"); + assert_eq!( + store.health().expect("health").schema_version, + SCHEMA_VERSION + ); + let planned = store + .in_flight_remittance() + .expect("row") + .expect("the v11 planned row is the row in flight"); + assert_eq!(planned.state, RemittanceState::Planned); + assert_eq!(planned.spending_since_unix, None); + assert_eq!(planned.owner.as_deref(), Some("proc-old")); + assert_eq!(planned.lease_until_unix, Some(400)); + assert_eq!(planned.melt_fee_reserve_sats, Some(1)); + // The fence works on the migrated row: its owner, inside the lease, is admitted; the state + // CHECK is untouched because SPENDING lives in the new column, not in `state`. + let admitted = store + .admit_remittance_spend("v11-planned", "proc-old", "q-pay", 60, &mut || 300) + .expect("query") + .expect("admitted"); + assert_eq!(admitted.state, RemittanceState::Spending); + assert_eq!(admitted.spending_since_unix, Some(300)); + assert_eq!(admitted.spending_quote_id.as_deref(), Some("q-pay")); + let raw_state: String = { + let conn = store.lock().expect("lock"); + conn.query_row( + "SELECT state FROM fee_remittances WHERE remittance_id = 'v11-planned'", + [], + |row| row.get(0), + ) + .expect("raw state") + }; + assert_eq!( + raw_state, "planned", + "on disk a spending row is a planned row with a mark" + ); + + drop(store); + let store = SellerStore::open(&path).expect("second open is a no-op"); + assert_eq!( + store.health().expect("health").schema_version, + SCHEMA_VERSION + ); + assert_eq!( + store.remittances().expect("rows")[0].state, + RemittanceState::Spending, + "the mark survives a reopen" + ); + let _ = std::fs::remove_file(&path); + } + + // Addendum 5 §1 (ledger): a store written by a v12 binary — the remittance table WITH + // `spending_since_unix` but WITHOUT `spending_quote_id` — opens under v13 additively: the one + // column is added, its SPENDING row survives and reads SPENDING with NO bound quote (the truth of + // a row admitted before admissions bound a quote), its owner / lease / mark are exactly as + // written, and its release goes the way v12's did — on its invoice's quote being terminal, + // never on time, and never through the bound-quote release (it has none). A second open is a + // no-op, and a row THIS binary admits on the migrated store is bound. + #[test] + fn a_v12_store_migrates_to_v13_additively_and_its_spending_row_reads_as_unbound() { + let path = temp_db("v12-to-v13"); + let _ = std::fs::remove_file(&path); + { + let conn = Connection::open(&path).expect("create v12 store"); + conn.execute_batch( + "CREATE TABLE seller_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO seller_meta VALUES ('schema_version', '12'); + CREATE TABLE receipts ( + receipt_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + amount_sats INTEGER NOT NULL CHECK (amount_sats >= 0), + received_at_unix INTEGER NOT NULL, + fee_bps INTEGER NOT NULL DEFAULT 0, + fee_sats INTEGER NOT NULL DEFAULT 0, + mint_fee_sats INTEGER, + remittance_id TEXT + ); + INSERT INTO receipts VALUES ('r-spending', 'job-s', 50, 2, 1000, 5, 1, 'v12-spending'); + INSERT INTO receipts VALUES ('r-free', 'job-f', 30, 3, 1000, 3, 1, NULL); + CREATE TABLE fee_remittances ( + remittance_id TEXT PRIMARY KEY, + gross_sats INTEGER NOT NULL CHECK (gross_sats >= 0), + melt_fee_sats INTEGER, + net_sats INTEGER NOT NULL CHECK (net_sats >= 0 AND net_sats <= gross_sats), + destination TEXT NOT NULL, + melt_quote_id TEXT, + payment_hash TEXT NOT NULL UNIQUE, + bolt11 TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('planned','settled','failed')), + created_at_unix INTEGER NOT NULL, + settled_at_unix INTEGER, + owner TEXT, + lease_until_unix INTEGER, + melt_fee_reserve_sats INTEGER, + settled_by TEXT, + spending_since_unix INTEGER + ); + CREATE UNIQUE INDEX fee_remittances_one_planned ON fee_remittances (state) WHERE state = 'planned'; + INSERT INTO fee_remittances VALUES ('v12-spending', 5, NULL, 4, 'maxplayer@agi.cash', 'q-est', 'v12-spending', 'ln3', 'planned', 100, NULL, 'proc-v12', 400, 1, NULL, 150);", + ) + .expect("v12 schema"); + } + + let store = SellerStore::open(&path).expect("a v12 store opens clean under v13"); + assert_eq!( + store.health().expect("health").schema_version, + SCHEMA_VERSION + ); + assert_eq!(SCHEMA_VERSION, 13); + let spending = store + .in_flight_remittance() + .expect("row") + .expect("the v12 spending row is the row in flight"); + assert_eq!(spending.state, RemittanceState::Spending); + assert_eq!(spending.spending_since_unix, Some(150)); + assert_eq!( + spending.spending_quote_id, None, + "admitted before quotes were bound" + ); + assert_eq!(spending.owner.as_deref(), Some("proc-v12")); + assert_eq!(spending.lease_until_unix, Some(400)); + assert_eq!(spending.melt_quote_id.as_deref(), Some("q-est")); + assert_eq!( + store.accrued_fees().expect("read-out").in_flight_fee_sats, + 5 + ); + // No release on time, and none through the bound-quote transition (nothing is bound). + for (wrong, why) in [ + ( + ReleaseOn::LeaseExpired { now_unix: 10_000 }, + "lease expiry never touches a spending row, migrated or not", + ), + ( + ReleaseOn::TerminalBoundQuote { + quote_id: "q-est".to_owned(), + }, + "the estimate quote was never BOUND: the bound-quote release changes zero rows", + ), + ( + ReleaseOn::OwnPlanned { + owner: "proc-v12".to_owned(), + }, + "not a planned row", + ), + ] { + assert_eq!( + store + .release_remittance("v12-spending", &wrong, 500) + .expect("query"), + None, + "{why}" + ); + } + // The one release an unbound spending row has: its invoice's quote terminal, as v12 did it. + let released = store + .release_remittance("v12-spending", &ReleaseOn::TerminalUnboundSpending, 500) + .expect("query") + .expect("released"); + assert_eq!(released.state, RemittanceState::Failed); + assert_eq!(released.receipts, 0); + assert_eq!( + store.accrued_fees().expect("read-out").unremitted_fee_sats, + 8 + ); + // A row THIS binary admits on the migrated store is bound, and its unbound release then + // changes zero rows: the v12 path is for v12 rows only. + let planned = store + .plan_remittance(&plan("h-new", 8, 7), "proc-new", 900, 501) + .expect("plan"); + assert_eq!(planned.spending_quote_id, None); + let admitted = store + .admit_remittance_spend("h-new", "proc-new", "q-bound", 60, &mut || 502) + .expect("query") + .expect("admitted"); + assert_eq!(admitted.spending_quote_id.as_deref(), Some("q-bound")); + assert_eq!( + store + .release_remittance("h-new", &ReleaseOn::TerminalUnboundSpending, 503) + .expect("query"), + None + ); + let raw: (String, Option, Option) = { + let conn = store.lock().expect("lock"); + conn.query_row( + "SELECT state, spending_since_unix, spending_quote_id FROM fee_remittances WHERE remittance_id = 'h-new'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("raw") + }; + assert_eq!( + raw, + ("planned".to_owned(), Some(502), Some("q-bound".to_owned())), + "on disk a bound spending row is a planned row with a mark and a quote" + ); + + drop(store); + let store = SellerStore::open(&path).expect("second open is a no-op"); + assert_eq!( + store.health().expect("health").schema_version, + SCHEMA_VERSION + ); + let rows = store.remittances().expect("rows"); + assert_eq!(rows.len(), 2); + assert_eq!( + rows[1].spending_quote_id.as_deref(), + Some("q-bound"), + "the binding survives a reopen" + ); + let _ = std::fs::remove_file(&path); + } + #[test] fn expire_outbox_stops_the_publisher_from_sending() { let (store, path) = fresh_store("expire"); @@ -2902,8 +5352,8 @@ mod free_lane_tests { SCHEMA_VERSION ); assert_eq!( - SCHEMA_VERSION, 9, - "v7 was the free lane; v8 added the receipt fee columns; v9 the mint fee" + SCHEMA_VERSION, 13, + "v7 was the free lane; v8 added the receipt fee columns; v9 the mint fee; v10 the fee remittance ledger; v11 its ownership and settlement provenance; v12 the spending mark; v13 the quote bound at admission" ); // The legacy rows SURVIVE and read as PAID — correct by construction, because every job diff --git a/crates/maxplayer-core/src/wallet_ops.rs b/crates/maxplayer-core/src/wallet_ops.rs index e0b7950f1..e717f5880 100644 --- a/crates/maxplayer-core/src/wallet_ops.rs +++ b/crates/maxplayer-core/src/wallet_ops.rs @@ -14,15 +14,15 @@ use std::sync::Arc; use std::time::Duration; use cashu::{MintUrl, Token}; -use sha2::{Digest, Sha256}; -use cdk::cdk_database::WalletDatabase; -use cdk::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod}; -use cdk::wallet::{ReceiveOptions, SendOptions, Wallet}; use cdk::Amount; +use cdk::cdk_database::WalletDatabase; +use cdk::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod, ProofsMethods}; +use cdk::wallet::{KeysetFilter, ReceiveOptions, SendOptions, Wallet}; use cdk_sqlite::wallet::WalletSqliteDatabase; +use sha2::{Digest, Sha256}; use crate::buyer_fund::seed_from_secret_hex; -use crate::home::{self, HomeError, MaxplayerHome, DEFAULT_MINT_URL}; +use crate::home::{self, DEFAULT_MINT_URL, HomeError, MaxplayerHome}; #[derive(Debug)] pub enum WalletOpsError { @@ -31,16 +31,72 @@ pub enum WalletOpsError { /// MEMBERSHIP miss, cleared by `maxplayer wallet mints add`. `default_mint` carries the home's /// ACTUAL default (`config.default_mint()`) so the Display names it rather than the pinned /// testnut constant — on a real-minibits home the latter is a money-relevant lie (#506). - MintNotAllowed { mint_url: String, default_mint: String }, + MintNotAllowed { + mint_url: String, + default_mint: String, + }, /// The mint IS configured but is a real mint refused by the real-mint fence (issue #49): /// `allow_real_mints` is off. A POLICY block — `mints add` cannot clear it, so it must NOT /// borrow [`Self::MintNotAllowed`]'s remedy; the control is `MAXPLAYER_ALLOW_REAL_MINTS` (#465). - RealMintDisallowed { mint_url: String }, + RealMintDisallowed { + mint_url: String, + }, /// `remove_mint` refuses to remove the home's pinned default mint. `mint_url` carries that /// actual default (`config.default_mint()`) so the message names the real pinned mint rather /// than a hardcoded constant — on a real-minibits home the constant would be a false-default /// lie (#579). - MintPinnedDefault { mint_url: String }, + MintPinnedDefault { + mint_url: String, + }, + /// A melt run under a [`MeltCeiling`] was REFUSED before any proof was selected, prepared or + /// spent: the quote the mint raised at payment time would take more out of the wallet than the + /// caller's hard maximum, or quoted a different invoice amount than the caller planned. The + /// seller fee remittance's money hold (stage 2a, addendum 3 §1): the seller never pays more than + /// the fee it accrued, enforced at the moment of spending. Nothing left the wallet. + MeltExceedsCeiling { + mint_url: String, + quote_id: String, + invoice_sats: u64, + fee_reserve_sats: u64, + planned_invoice_sats: u64, + max_debit_sats: u64, + }, + /// A melt under a [`MeltCeiling`] was REFUSED after `prepare_melt` and before `confirm`: the + /// TOTAL the wallet would lose — invoice + fee reserve + the proof-input fee the mint charges on + /// the selected proofs + the fee of the pre-melt swap the wallet would perform when its proofs do + /// not fit — exceeds the caller's hard maximum (addendum 8 §1, verdict B4). The four figures are + /// the SDK's own, read off the prepared melt (CDK 0.17.2 `PreparedMelt::input_fee` / + /// `swap_fee`), not an estimate. The prepared melt was CANCELLED: its proofs are released in the + /// local store and nothing was ever posted to the mint — `prepare_melt` only reads the mint's + /// keysets and writes the wallet's own database (pinned `melt/saga/mod.rs:286–460`); the swap and + /// the melt request both live inside `confirm` (`:687–697`, `:907–911`). Nothing left the wallet. + MeltTotalExceedsCeiling { + mint_url: String, + quote_id: String, + invoice_sats: u64, + fee_reserve_sats: u64, + input_fee_sats: u64, + swap_fee_sats: u64, + total_sats: u64, + max_debit_sats: u64, + }, + /// A melt under a [`MeltCeiling`] was REFUSED after `prepare_melt` and before `confirm` because + /// `confirm` would NOT succeed (addendum 10 §1.1, [`ConfirmShortfall::TargetShort`]): the swap + /// would yield `target_sats`, the SDK recomputes the input fee on those proofs as + /// `actual_input_fee_sats` (its prepared estimate was `input_fee_sats`), and target < invoice + + /// reserve + actual — pinned CDK refuses AFTER paying the swap fee (`melt/saga/mod.rs:704–712`). + /// Caught here instead: the prepared melt was CANCELLED, no fee-bearing request was posted. + MeltWouldNotConfirm { + mint_url: String, + quote_id: String, + invoice_sats: u64, + fee_reserve_sats: u64, + input_fee_sats: u64, + actual_input_fee_sats: u64, + target_sats: u64, + swap_fee_sats: u64, + input_fee_ppk: u64, + }, Wallet(String), } @@ -48,7 +104,10 @@ impl std::fmt::Display for WalletOpsError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Home(error) => write!(formatter, "{error}"), - Self::MintNotAllowed { mint_url, default_mint } => write!( + Self::MintNotAllowed { + mint_url, + default_mint, + } => write!( formatter, "mint {mint_url} is not configured; add it with `maxplayer wallet mints add` (default stays {default_mint})" ), @@ -62,6 +121,59 @@ impl std::fmt::Display for WalletOpsError { formatter, "cannot remove the default mint ({mint_url}); only extra_mints are removable" ), + Self::MeltExceedsCeiling { + mint_url, + quote_id, + invoice_sats, + fee_reserve_sats, + planned_invoice_sats, + max_debit_sats, + } => write!( + formatter, + "melt refused before spending: mint {mint_url} quote {quote_id} would debit {} sats \ + ({invoice_sats} sats invoice + {fee_reserve_sats} sats fee reserve; planned invoice \ + {planned_invoice_sats} sats) against a ceiling of {max_debit_sats} sats; nothing left the wallet", + invoice_sats.saturating_add(*fee_reserve_sats) + ), + Self::MeltTotalExceedsCeiling { + mint_url, + quote_id, + invoice_sats, + fee_reserve_sats, + input_fee_sats, + swap_fee_sats, + total_sats, + max_debit_sats, + } => write!( + formatter, + "melt refused before spending: mint {mint_url} quote {quote_id} would debit {total_sats} sats in total \ + ({invoice_sats} sats invoice + {fee_reserve_sats} sats fee reserve + {input_fee_sats} sats proof input fee \ + + {swap_fee_sats} sats swap fee) against a ceiling of {max_debit_sats} sats; the prepared melt was \ + cancelled and its proofs released; nothing was posted to the mint" + ), + Self::MeltWouldNotConfirm { + mint_url, + quote_id, + invoice_sats, + fee_reserve_sats, + input_fee_sats, + actual_input_fee_sats, + target_sats, + swap_fee_sats, + input_fee_ppk, + } => write!( + formatter, + "melt refused before spending: the wallet would swap to {target_sats} sats ({:?}) for mint {mint_url} \ + quote {quote_id} and the mint's actual proof input fee on those proofs is {actual_input_fee_sats} sats \ + (prepared estimate {input_fee_sats} sats at {input_fee_ppk} ppk), so {invoice_sats} sats invoice + \ + {fee_reserve_sats} sats fee reserve + {actual_input_fee_sats} sats would need {} sats and the SDK would \ + refuse AFTER paying the {swap_fee_sats} sats swap fee; the prepared melt was cancelled before any \ + fee-bearing request", + binary_split(*target_sats), + invoice_sats + .saturating_add(*fee_reserve_sats) + .saturating_add(*actual_input_fee_sats) + ), Self::Wallet(message) => write!(formatter, "wallet error: {message}"), } } @@ -162,8 +274,443 @@ pub struct ReceiveOutcome { pub struct MeltOutcome { pub mint_url: String, pub paid_sats: u64, + /// CDK `FinalizedMelt::fee_paid` (pinned `melt/saga/mod.rs:139–148`): proofs sent − invoice − + /// change returned = the Lightning fee the mint took PLUS the ACTUAL proof input fee on the + /// proofs the melt sent. Inclusive; it does not contain the pre-melt swap's fee. Never add + /// `input_fee_sats` to it — that fee is already in here, at its actual value. pub fee_sats: u64, + /// Best-effort balance after the payment: the observational read when it succeeded, else + /// `before − actual debit`. Legacy field kept for the operator paths; the seller fee remittance + /// prints [`Self::balance_after_sats`] instead and says "unknown" rather than a computed number. pub balance_sats: u64, + /// The observational post-payment balance read: `None` when the read failed (the payment still + /// happened; the caller prints "unknown", never a computed figure). + pub balance_after_sats: Option, + /// The mint's melt quote id the payment settled under — journaled by the seller fee remittance + /// so a settled row names the quote the mint can be asked about. + pub quote_id: String, + /// The mint's fee RESERVE on the paying quote: the ceiling on the LIGHTNING fee the mint may + /// keep (NUT-05), checked against the caller's [`MeltCeiling`] before anything was spent. + /// `fee_sats` (Lightning + actual proof input fee, CDK's inclusive `fee_paid`) can exceed it by + /// that input fee; the reserve bounds the Lightning component only. Journaled beside the fee. + pub fee_reserve_sats: u64, + /// The proof-input fee the SDK's prepared melt carried (CDK `PreparedMelt::input_fee`): an + /// ESTIMATE on the split of invoice + reserve (`melt/saga/mod.rs:383–387`), bounded under the + /// ceiling before the fence. Informational after payment: `confirm` recomputes the actual fee on + /// the swapped proofs and that actual fee is inside `fee_sats`. `0` where the path did not + /// prepare through [`prepare_melt_payment_blocking`]. + pub input_fee_sats: u64, + /// The fee of the pre-melt swap the wallet performed inside `confirm` because its proofs did not + /// fit the amount (CDK `PreparedMelt::swap_fee`), charged at the swap; `0` when no swap was + /// needed. Not part of `fee_sats`. Actual debit = `paid_sats` + `fee_sats` + `swap_fee_sats`. + pub swap_fee_sats: u64, +} + +/// NUT-02 (pinned `cdk/src/fees.rs:35–48`, reached from `wallet/mod.rs:319–352` and `:356`): +/// fee = ceil(ppk × count / 1000). +pub(crate) fn fee_for(input_fee_ppk: u64, count: usize) -> u64 { + (input_fee_ppk * count as u64).div_ceil(1000) +} + +/// The denominations a power-of-two keyset hands back for `amount` under `SplitTarget::None` +/// (CDK `Amount::split`, the split the swap uses for the melt's proofs at `swap/saga/mod.rs: +/// 285–301` and for the change) — one proof per set bit, largest first. +pub(crate) fn binary_split(amount: u64) -> Vec { + (0..64) + .rev() + .map(|bit| 1u64 << bit) + .filter(|denomination| amount & denomination != 0) + .collect() +} + +/// CDK's post-swap figures for a melt of `need` = invoice + reserve on a swap layout: the target the +/// wallet swaps to (`need` + the PREPARED input fee, `melt/saga/mod.rs:678`) and the ACTUAL input +/// fee the SDK recomputes on that target's binary split (`:704`). `prepared_input_fee_sats` is what +/// `prepare_melt` estimated (the fee on the split of `need`, `:383–387`); when `None` it is computed +/// the same way here (planning, before any quote's figures exist). +pub(crate) fn post_swap_figures( + need_sats: u64, + prepared_input_fee_sats: Option, + input_fee_ppk: u64, +) -> (u64, u64) { + let prepared = prepared_input_fee_sats + .unwrap_or_else(|| fee_for(input_fee_ppk, binary_split(need_sats).len())); + let target_sats = need_sats.saturating_add(prepared); + let actual = fee_for(input_fee_ppk, binary_split(target_sats).len()); + (target_sats, actual) +} + +/// What pinned CDK 0.17.2's `confirm` will act on for a melt of `invoice + reserve`, and the most it +/// can debit — the ONE arithmetic the seller fee remittance's planner, the wallet's prepared-melt +/// gate ([`MeltCeiling::admits_confirmable`]) and its pre-fence check all decide on (addendum 10 +/// §1.1). Computed by [`confirm_bound`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfirmBound { + /// invoice + fee reserve. + pub need_sats: u64, + /// The SDK's PREPARED input fee: the fee on the binary split of `need` (`melt/saga/mod.rs: + /// 383–387`) — an estimate on a swap layout; the fee on the selected proofs on an exact fit. + pub prepared_input_fee_sats: u64, + /// The amount the pre-melt swap yields (`need` + prepared input fee, `:678`); `need` itself when + /// no swap is needed. + pub target_sats: u64, + /// The input fee `confirm` RECOMPUTES on the proofs the melt sends (`:704`): on the target's + /// binary split after a swap, the prepared figure on an exact fit. + pub actual_input_fee_sats: u64, + /// The fee of the pre-melt swap, charged at the swap; `0` without one. + pub swap_fee_sats: u64, + /// The most that can leave the wallet: invoice + reserve + ACTUAL input fee + swap fee. The + /// Lightning fee the mint keeps is at most the reserve, so the debit is at most this. + pub worst_debit_sats: u64, +} + +/// Why [`confirm_bound`] refused — each names the figures a refusal line prints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfirmShortfall { + /// The paying quote is not for the invoice the caller planned. + DifferentInvoice { + invoice_sats: u64, + planned_invoice_sats: u64, + }, + /// After the swap the target would not cover invoice + reserve + the recomputed input fee: the + /// SDK would refuse AFTER paying the swap fee (`melt/saga/mod.rs:704–712`). + TargetShort { + bound: ConfirmBound, + needed_after_swap_sats: u64, + }, + /// `confirm` would succeed but the worst-case debit exceeds the ceiling. + OverCeiling { + bound: ConfirmBound, + max_debit_sats: u64, + }, +} + +/// **Addendum 10 §1.1, the actual-confirmability bound:** for `invoice + reserve` under the given +/// fee metadata, `confirm` succeeds iff on a swap layout `target ≥ need + actual_input_fee(target +/// split)`, and the payment fits iff `need + actual_input_fee + swap_fee ≤ max_debit_sats`. On an +/// exact-fit layout (`requires_swap == false`) the selected proofs already cover the prepared fee, so +/// only the debit bound applies with `actual = prepared`. `prepared_input_fee_sats` is the SDK's +/// figure when a preparation exists, `None` when planning (computed the same way, on the split of +/// `need`). Pure; the same function answers the planner (`Ok` ⇒ this invoice is confirmable), the +/// wallet's gate and the pre-fence check, so they cannot disagree on fixed metadata. +pub fn confirm_bound( + invoice_sats: u64, + fee_reserve_sats: u64, + prepared_input_fee_sats: Option, + swap_fee_sats: u64, + input_fee_ppk: u64, + requires_swap: bool, + max_debit_sats: u64, +) -> Result { + let need_sats = invoice_sats.saturating_add(fee_reserve_sats); + let (prepared_input_fee_sats, target_sats, actual_input_fee_sats) = if requires_swap { + let (target_sats, actual) = + post_swap_figures(need_sats, prepared_input_fee_sats, input_fee_ppk); + (target_sats.saturating_sub(need_sats), target_sats, actual) + } else { + let prepared = prepared_input_fee_sats.unwrap_or(0); + (prepared, need_sats, prepared) + }; + let worst_debit_sats = need_sats + .saturating_add(actual_input_fee_sats) + .saturating_add(swap_fee_sats); + let bound = ConfirmBound { + need_sats, + prepared_input_fee_sats, + target_sats, + actual_input_fee_sats, + swap_fee_sats, + worst_debit_sats, + }; + let needed_after_swap_sats = need_sats.saturating_add(actual_input_fee_sats); + if requires_swap && target_sats < needed_after_swap_sats { + return Err(ConfirmShortfall::TargetShort { + bound, + needed_after_swap_sats, + }); + } + if worst_debit_sats > max_debit_sats { + return Err(ConfirmShortfall::OverCeiling { + bound, + max_debit_sats, + }); + } + Ok(bound) +} + +/// A hard bound a caller places on a melt. Two checks share it: [`Self::admits`] bounds the two +/// figures a QUOTE carries (invoice + fee reserve) and is taken before any proof is selected — the +/// operator's [`melt_within_async`] takes only this one (its reserve-only ceiling is kept as is, +/// addendum 8 §6); [`Self::admits_confirmable`] is the actual-confirmability bound on a PREPARED +/// melt (invoice + fee reserve + the input fee the SDK will RECOMPUTE on the proofs it sends + its +/// pre-melt swap fee, addendum 10 §1.1) and is taken between `prepare_melt` and `confirm` by +/// [`prepare_melt_payment_blocking`] — the seller fee remittance's path, whose ceiling therefore +/// bounds the ENTIRE wallet debit (addendum 8 §1, verdict B4). The remittance's money hold (stage +/// 2a, addendum 3 §1): the plan's estimate is not the quote the spend runs under — the mint quotes +/// again when the payment is made, and its fee reserve can differ — so the ceiling is enforced at +/// the moment of spending, not estimated beforehand or regretted afterwards. A quote that does not +/// fit is refused as [`WalletOpsError::MeltExceedsCeiling`], a clean failure with nothing moved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeltCeiling { + /// The most that may leave the wallet for this melt — invoice amount and fee reserve together. + /// For the remittance this is the unremitted accrued gross being discharged. + pub max_debit_sats: u64, + /// The invoice amount the caller planned; the paying quote must quote exactly this. + pub invoice_sats: u64, + /// The melt quote the plan was journaled against, for the record. The spend re-validates the + /// quote it actually pays under and reports it in [`MeltOutcome::quote_id`]. + pub planned_quote_id: Option, +} + +impl MeltCeiling { + /// Whether a quote of `invoice_sats` with `fee_reserve_sats` fits under this ceiling. Pure, so + /// the bound is unit-tested without a mint. + pub fn admits(&self, invoice_sats: u64, fee_reserve_sats: u64) -> bool { + invoice_sats == self.invoice_sats + && invoice_sats.saturating_add(fee_reserve_sats) <= self.max_debit_sats + } + + /// **The one gate on a PREPARED melt (addendum 10 §1.1).** The paying quote must be for the + /// planned invoice, and [`confirm_bound`] must hold for the SDK's prepared figures under + /// `max_debit_sats`: `confirm` will succeed and its worst-case debit (invoice + reserve + the + /// input fee it RECOMPUTES on the proofs it sends + swap fee) fits. Replaces round 8's bound on + /// the PREPARED total — an estimate that can exceed the final debit and refused fitting + /// remittances (verdict 4714623 §3.2). Pure, so unit-tested without a mint. + pub fn admits_confirmable( + &self, + invoice_sats: u64, + fee_reserve_sats: u64, + prepared_input_fee_sats: Option, + swap_fee_sats: u64, + input_fee_ppk: u64, + requires_swap: bool, + ) -> Result { + if invoice_sats != self.invoice_sats { + return Err(ConfirmShortfall::DifferentInvoice { + invoice_sats, + planned_invoice_sats: self.invoice_sats, + }); + } + confirm_bound( + invoice_sats, + fee_reserve_sats, + prepared_input_fee_sats, + swap_fee_sats, + input_fee_ppk, + requires_swap, + self.max_debit_sats, + ) + } + + /// The four parts summed, saturating — the figure a refusal names, with the input fee at the + /// value the caller has (the ACTUAL one where [`confirm_bound`] computed it). + pub fn total_debit( + invoice_sats: u64, + fee_reserve_sats: u64, + input_fee_sats: u64, + swap_fee_sats: u64, + ) -> u64 { + invoice_sats + .saturating_add(fee_reserve_sats) + .saturating_add(input_fee_sats) + .saturating_add(swap_fee_sats) + } +} + +/// A melt quote and nothing more: what the mint would charge to pay `bolt11`, read without paying +/// it. The dry-run half of the seller fee remittance; see [`melt_quote_async`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeltEstimate { + pub mint_url: String, + pub quote_id: String, + /// The invoice amount the mint quoted, in sats. + pub amount_sats: u64, + /// The mint's fee RESERVE for this melt: the ceiling on the LIGHTNING fee it may keep (NUT-05); + /// the unused part returns as change. It does not bound the proof input fee: the inclusive + /// melt fee (Lightning + actual proof input fee, CDK's `fee_paid`) can exceed the reserve by + /// that input fee, which [`confirm_bound`] accounts for separately. + pub fee_reserve_sats: u64, + /// When the quote expires at the mint (unix seconds), as the quote states it. A quote is paid + /// by id ([`prepare_melt_payment_blocking`] → confirm; the retained [`pay_melt_quote_async`] + /// likewise) only while it is live; the seller fee remittance binds its + /// admission to this quote and refuses to pay it inside its spending margin of expiry + /// (addendum 5 §1, rule 1). + pub expiry_unix: u64, + /// The proof fees the SDK would charge on top of amount + reserve if THIS wallet paid this quote + /// now, computed from the wallet's own unspent proofs and the mint's keyset `input_fee_ppk` the + /// way `prepare_melt` computes them (addendum 8 §1.3) — the proof-input fee on an exact-fit + /// selection, or the estimated input fee plus the swap fee on a layout that needs a pre-melt + /// swap — WITHOUT reserving anything. Sizes the plan so a payment that can fit is planned and one + /// that never can is refused at planning; the hard bound is still taken on the prepared melt's + /// own figures at payment ([`prepare_melt_payment_blocking`]). `0` when the estimate could not + /// be made (e.g. the wallet cannot cover amount + reserve at estimate time); the reason is in + /// [`Self::expected_fees_note`]. + pub expected_fees_sats: u64, + /// The part of `expected_fees_sats` that is the pre-melt SWAP's fee (`0` on an exact-fit + /// layout); the rest is the SDK's ESTIMATED melt-input fee. Planning needs them apart: the + /// input fee is recomputed by `confirm` on the swapped proofs, the swap fee is not. + pub expected_swap_fee_sats: u64, + /// The active keyset's `input_fee_ppk` (NUT-02), so the planner can run the SDK's post-swap + /// input-fee recomputation ahead of time (addendum 9 §1.2). `0` when it could not be read; the + /// reason is in [`Self::expected_fees_note`]. + pub input_fee_ppk: u64, + /// Why `expected_fees_sats` is `0` by default rather than measured, when it is; `None` when the + /// estimate was made. + pub expected_fees_note: Option, +} + +/// The SDK's figures for ONE prepared melt — read off CDK 0.17.2's `PreparedMelt` after +/// `prepare_melt` selected and reserved proofs in the LOCAL store and before `confirm` performs any +/// swap or posts the melt request. The bound in [`MeltCeiling::admits_confirmable`] is taken on these. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeltPreparation { + pub mint_url: String, + pub quote_id: String, + pub invoice_sats: u64, + pub fee_reserve_sats: u64, + /// `PreparedMelt::input_fee` — on the swap layout this is the SDK's estimate for the proofs the + /// swap will yield (pinned `melt/saga/mod.rs:384–399`); on the exact-fit layout it is the fee on + /// the selected proofs (`:359`). + pub input_fee_sats: u64, + /// `PreparedMelt::swap_fee` — the fee on the proofs the pre-melt swap consumes; `0` when no swap. + pub swap_fee_sats: u64, + /// `PreparedMelt::requires_swap` — whether `confirm` will perform a pre-melt swap first. + pub requires_swap: bool, + /// The active keyset's `input_fee_ppk` (NUT-02) at preparation — what `confirm` will charge per + /// proof when it RECOMPUTES the input fee on the swapped proofs (pinned `melt/saga/mod.rs:704`); + /// read as the fee on 1000 proofs, which is exactly ppk (`fees.rs:35–48`). + pub input_fee_ppk: u64, + /// The four parts summed (saturating) — what `admits_confirmable` compared against the ceiling: invoice + reserve + ACTUAL input fee + swap fee. + pub total_debit_sats: u64, + pub expiry_unix: u64, +} + +enum PreparedCommand { + Confirm, + Cancel, +} + +/// A melt that is PREPARED — proofs selected and reserved in the wallet's own database, fees known, +/// nothing posted to the mint — and waits for the caller to [`Self::confirm`] or [`Self::cancel`]. +/// Returned by [`prepare_melt_payment_blocking`] after the caller's ceiling admitted the total. The +/// seller fee remittance holds one across its store fence (addendum 8 §1.2: bound → fence → +/// confirm), so that a fee refusal happens before the row is ever bound and a fence refusal cancels +/// a melt that has cost nothing. +/// +/// Lives on a thread of its own: CDK's `PreparedMelt<'a>` borrows the `Wallet`, and every wallet +/// call here runs on a fresh current-thread Tokio runtime, so a dedicated OS thread owns the +/// runtime, the wallet and the prepared melt together and waits on a channel for the verdict. +/// Dropping this without a verdict CANCELS (the thread sees the channel close and runs +/// `PreparedMelt::cancel`, which reverts the reservation and releases the quote locally — pinned +/// `melt/saga/mod.rs:817–831`). +pub struct PreparedMeltPayment { + pub preparation: MeltPreparation, + command: Option>, + reply: std::sync::mpsc::Receiver, WalletOpsError>>, + thread: Option>, +} + +impl std::fmt::Debug for PreparedMeltPayment { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PreparedMeltPayment") + .field("preparation", &self.preparation) + .field("decided", &self.command.is_none()) + .finish() + } +} + +impl PreparedMeltPayment { + /// **The payment.** `PreparedMelt::confirm` on the thread that holds it: the pre-melt swap if + /// one is required, then the melt request; funds leave the wallet here and nowhere else on this + /// path. An `Err` is opaque as to how far it got — on the failure paths it recognises the SDK + /// runs its own compensations (best-effort, local; e.g. pinned `melt/saga/mod.rs:709–712` after + /// an insufficient post-swap total) and the caller reconciles by quote id. + pub fn confirm(mut self) -> Result { + self.decide(PreparedCommand::Confirm)?.ok_or_else(|| { + WalletOpsError::Wallet( + "the prepared melt's thread reported no outcome for a confirm; reconcile the quote by id" + .to_owned(), + ) + }) + } + + /// Release the prepared melt: the SDK's compensations — proofs back to Unspent, quote released, + /// saga row deleted — in the wallet's own database. **Best-effort**: pinned CDK `melt/mod.rs:673` + /// → `melt/saga/mod.rs:828–830` catches and logs a compensation's own DB error (`:817–824`) and + /// still returns `Ok`, so `Ok` means "no fee-bearing request was ever posted, so there is + /// nothing to undo at the mint", not "every local reservation is proven released". The + /// Drop → Cancel → join below is this wrapper's, not SDK RAII: a bare `PreparedMelt` dropped + /// without a decision cancels nothing. + pub fn cancel(mut self) -> Result<(), WalletOpsError> { + self.decide(PreparedCommand::Cancel).map(|_| ()) + } + + fn decide(&mut self, command: PreparedCommand) -> Result, WalletOpsError> { + let Some(sender) = self.command.take() else { + return Err(WalletOpsError::Wallet( + "the prepared melt was already decided".to_owned(), + )); + }; + let what = match command { + PreparedCommand::Confirm => "confirm", + PreparedCommand::Cancel => "cancel", + }; + sender.send(command).map_err(|_| { + WalletOpsError::Wallet(format!( + "the prepared melt's thread is gone before the {what}; no fee-bearing request was posted by this call; a local proof reservation may remain — opening the wallet does not run CDK recover_incomplete_sagas on this path; a supported recovery path is owed" + )) + })?; + let reply = self.reply.recv().map_err(|_| { + WalletOpsError::Wallet(format!( + "the prepared melt's thread ended without reporting the {what}; reconcile the quote by id" + )) + }); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + reply? + } +} + +impl Drop for PreparedMeltPayment { + fn drop(&mut self) { + if let Some(sender) = self.command.take() { + // Undecided: cancel. A send failure means the thread is already gone (it cancels on a + // closed channel too); either way nothing was posted. + let _ = sender.send(PreparedCommand::Cancel); + let _ = self.reply.recv(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } + } +} + +/// The mint's melt-quote lifecycle, re-exported so a CLI caller can match on it without depending +/// on `cdk` directly. +pub use cdk::nuts::MeltQuoteState; + +/// The mint's answer about a melt quote raised earlier for a given invoice. Used to reconcile an +/// interrupted remittance without paying again; see [`melt_status_for_invoice_async`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeltQuoteStatus { + pub mint_url: String, + pub quote_id: String, + pub state: MeltQuoteState, + pub amount_sats: u64, + pub fee_reserve_sats: u64, + /// When the quote expires at the mint (unix seconds), as the quote itself states. An UNPAID + /// quote past this is one the mint will never pay — terminal, like FAILED. + pub expiry_unix: u64, +} + +impl MeltQuoteStatus { + /// Whether the quote's expiry is behind `now_unix` (a clock before the epoch never expires + /// anything: fail-closed toward "still live"). + pub fn expired_at(&self, now_unix: i64) -> bool { + u64::try_from(now_unix).is_ok_and(|now| now > self.expiry_unix) + } } fn sqlite_path(wallet_dir: &Path) -> std::path::PathBuf { @@ -182,10 +729,7 @@ pub fn normalize_mint_url(raw: &str) -> Result { } fn is_autopay_mint(mint_url: &str) -> bool { - normalize_mint_url(mint_url) - .ok() - .as_deref() - == Some(DEFAULT_MINT_URL) + normalize_mint_url(mint_url).ok().as_deref() == Some(DEFAULT_MINT_URL) } /// Money class a mint moves, derived purely from the mint URL. The pinned testnut host @@ -294,7 +838,10 @@ fn post_receive_balance(read: Result, before: u64, received_sats: u } } -fn resolve_mint(home: &MaxplayerHome, mint_override: Option<&str>) -> Result { +fn resolve_mint( + home: &MaxplayerHome, + mint_override: Option<&str>, +) -> Result { match mint_override { Some(url) => mint_is_allowed(home, url), None => normalize_mint_url(home.config.default_mint()), @@ -308,7 +855,8 @@ pub async fn open_wallet_async( ) -> Result { let mint_url = mint_is_allowed(home, mint_url)?; let secret = home::read_secret_key_hex(home)?; - let seed = seed_from_secret_hex(&secret).map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + let seed = + seed_from_secret_hex(&secret).map_err(|error| WalletOpsError::Wallet(error.to_string()))?; let path = sqlite_path(&home.wallet_dir); let store = WalletSqliteDatabase::new(path) .await @@ -731,10 +1279,33 @@ pub async fn receive_async( /// Pay a lightning invoice from ecash (fail-closed on insufficient / unpaid). /// `confirm` is the effect boundary; the post-confirm balance read is observational and never /// discards the settled outcome (finding U — see [`post_confirm_balance`]). +/// +/// The unbounded form `maxplayer wallet melt` uses: [`melt_within_async`] with no ceiling. There is +/// one melt implementation; this is a name for calling it without a bound. pub async fn melt_async( home: &MaxplayerHome, bolt11: &str, mint_override: Option<&str>, +) -> Result { + melt_within_async(home, bolt11, mint_override, None).await +} + +/// [`melt_async`] with an optional hard [`MeltCeiling`], checked against the quote the mint raises +/// here — at payment time — and BEFORE `prepare_melt` selects a single proof: quote, then the same +/// pay step [`pay_melt_quote_async`] uses. The operator's `maxplayer wallet melt` composes it. The +/// seller fee remittance does NOT pay through this (it did in stage 2a rounds 2–3): since +/// addendum 5 it raises its payment quote with [`melt_quote_async`] and binds the id in the store, +/// and since addendum 8 it prepares and confirms that quote with [`prepare_melt_payment_blocking`]. +/// A fee reserve that does not fit the ceiling is refused as [`WalletOpsError::MeltExceedsCeiling`] +/// and nothing leaves the wallet. **This operator path keeps its RESERVE-ONLY ceiling** (addendum 8 +/// §6, out of scope): it does not take the total bound on the prepared melt's proof-input and swap +/// fees — those are reported in the [`MeltOutcome`], not bounded. Same mint resolution, same +/// `allow_real_mints` gate, same effect boundary as the unbounded form. +pub async fn melt_within_async( + home: &MaxplayerHome, + bolt11: &str, + mint_override: Option<&str>, + ceiling: Option<&MeltCeiling>, ) -> Result { let bolt11 = bolt11.trim(); if bolt11.is_empty() { @@ -748,11 +1319,92 @@ pub async fn melt_async( return Err(WalletOpsError::RealMintDisallowed { mint_url }); } let wallet = open_wallet_async(home, &mint_url).await?; + // The quote step: raise the payment quote (spends nothing) … let quote = wallet .melt_quote(PaymentMethod::BOLT11, bolt11, None, None) .await .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; - let need = quote.amount.to_u64().saturating_add(quote.fee_reserve.to_u64()); + // … then the pay step, on THAT quote by id, under the ceiling. The two steps are one call here + // (the operator's melt has nothing to fence between them); the seller fee remittance calls them + // separately — [`melt_quote_async`], then [`prepare_melt_payment_blocking`] → + // [`PreparedMeltPayment::confirm`] (since addendum 8; [`pay_melt_quote_async`] is retained but + // no longer called on that path, removal owed) — with its store fence in between, so that it + // only ever pays the quote its admission bound (addendum 5 §1, rule 1). + pay_quote_on_wallet(&wallet, mint_url, "e, ceiling).await +} + +/// **Pay a melt quote the wallet already holds, by id, and never raise another.** The seller fee +/// remittance's spending call in stage 2a rounds 4–6 (addendum 5 §1, rule 1); since addendum 8 the +/// remittance uses the two-phase [`prepare_melt_payment_blocking`] instead, so that the ceiling is +/// taken on the prepared melt's TOTAL before the fence — this one-shot form bounds invoice + fee +/// reserve only and is kept for [`melt_within_async`]. The quote was raised by [`melt_quote_async`] +/// AFTER the plan was journaled (the estimate quote predates the plan; this payment quote does +/// not), its id was bound to the row by the store fence, and this pays exactly that quote — +/// `prepare_melt(quote_id)` / `confirm` — after re-checking the ceiling against the quote's STORED +/// amount and fee reserve immediately before `prepare_melt`. An unknown quote id is refused before +/// the wallet touches a proof. `prepare_melt` refuses a quote whose expiry has passed on THIS +/// wallet's clock; past that check nothing here re-checks expiry or state, and the inspected CDK +/// 0.17.2 mint implementation (checksum-pinned source; no deployed mint was measured) pays an +/// UNPAID or FAILED quote regardless of its expiry — which is why the caller never treats +/// "expired" or "FAILED" as proof that a prepared payment cannot still land, never re-quotes, and +/// holds its row until the mint reports PAID (addendum 6 §1.2). +/// Same mint resolution and `allow_real_mints` gate as every melt here. +pub async fn pay_melt_quote_async( + home: &MaxplayerHome, + quote_id: &str, + mint_override: Option<&str>, + ceiling: &MeltCeiling, +) -> Result { + let quote_id = quote_id.trim(); + if quote_id.is_empty() { + return Err(WalletOpsError::Wallet("melt quote id is empty".into())); + } + let mint_url = resolve_mint(home, mint_override)?; + if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { + return Err(WalletOpsError::RealMintDisallowed { mint_url }); + } + let wallet = open_wallet_async(home, &mint_url).await?; + let quote = wallet + .localstore + .get_melt_quote(quote_id) + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))? + .ok_or_else(|| { + WalletOpsError::Wallet(format!( + "melt quote {quote_id} is not in this wallet; refusing to pay a quote this wallet did not raise" + )) + })?; + pay_quote_on_wallet(&wallet, mint_url, "e, Some(ceiling)).await +} + +/// The pay step shared by [`melt_within_async`] (quote raised a moment ago) and +/// [`pay_melt_quote_async`] (quote bound earlier): ceiling check against THIS quote's amount and +/// reserve, balance check, `prepare_melt` on this quote's id, `confirm`. +async fn pay_quote_on_wallet( + wallet: &Wallet, + mint_url: String, + quote: &cdk::wallet::MeltQuote, + ceiling: Option<&MeltCeiling>, +) -> Result { + let invoice_sats = quote.amount.to_u64(); + let fee_reserve_sats = quote.fee_reserve.to_u64(); + // The money hold, at the moment of spending: THIS quote — not the plan's estimate — is what the + // wallet would pay under, so THIS quote's amount + reserve is what the ceiling bounds. Refused + // here, no proof has been selected, prepared or sent; the caller journals a failed attempt and + // the balance it meant to discharge is intact. + if let Some(ceiling) = ceiling + && !ceiling.admits(invoice_sats, fee_reserve_sats) + { + return Err(WalletOpsError::MeltExceedsCeiling { + mint_url, + quote_id: quote.id.clone(), + invoice_sats, + fee_reserve_sats, + planned_invoice_sats: ceiling.invoice_sats, + max_debit_sats: ceiling.max_debit_sats, + }); + } + let need = invoice_sats.saturating_add(fee_reserve_sats); let before = wallet .total_balance() .await @@ -767,6 +1419,10 @@ pub async fn melt_async( .prepare_melt("e.id, HashMap::new()) .await .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + // Reported, not bounded: this operator path keeps its reserve-only ceiling (addendum 8 §6); the + // seller fee remittance pays through [`prepare_melt_payment_blocking`], which bounds the total. + let input_fee_sats = prepared.input_fee().to_u64(); + let swap_fee_sats = prepared.swap_fee().to_u64(); let confirmed = prepared .confirm() .await @@ -781,15 +1437,596 @@ pub async fn melt_async( .await .map(|balance| balance.to_u64()) .map_err(|error| error.to_string()); - let balance_sats = post_confirm_balance(read, before, paid_sats.saturating_add(fee_sats), "melt"); + let balance_after_sats = read.as_ref().ok().copied(); + let balance_sats = + post_confirm_balance(read, before, paid_sats.saturating_add(fee_sats), "melt"); Ok(MeltOutcome { mint_url, paid_sats, fee_sats, balance_sats, + balance_after_sats, + quote_id: quote.id.clone(), + fee_reserve_sats, + input_fee_sats, + swap_fee_sats, }) } +/// The proof fees THIS wallet would pay on top of `inputs_needed` (amount + fee reserve) for a melt +/// prepared now, computed exactly as pinned CDK 0.17.2 `MeltSaga::prepare` computes them +/// (`melt/saga/mod.rs:301–318` exact fit, `:377–403` swap layout) but WITHOUT reserving a proof or +/// writing a saga: keysets and unspent proofs are read, `Wallet::select_proofs` is a pure function, +/// and the fee lookups read the mint's keyset metadata (cached; a GET at most). Returns the +/// proof-input fee on an exact-fit selection, or estimated input fee + swap fee on a swap layout. +async fn expected_melt_fees(wallet: &Wallet, inputs_needed: Amount) -> Result<(u64, u64), String> { + let active_keyset_ids: Vec<_> = wallet + .get_mint_keysets(KeysetFilter::Active) + .await + .map_err(|error| error.to_string())? + .into_iter() + .map(|keyset| keyset.id) + .collect(); + let keyset_fees_and_amounts = wallet + .get_keyset_fees_and_amounts() + .await + .map_err(|error| error.to_string())?; + let available = wallet + .get_unspent_proofs() + .await + .map_err(|error| error.to_string())?; + let exact = Wallet::select_proofs( + inputs_needed, + available.clone(), + &active_keyset_ids, + &keyset_fees_and_amounts, + true, + ) + .map_err(|error| error.to_string())?; + if exact.total_amount().map_err(|error| error.to_string())? == inputs_needed { + return Ok(( + wallet + .get_proofs_fee(&exact) + .await + .map_err(|error| error.to_string())? + .total + .to_u64(), + 0, + )); + } + let active_keyset_id = wallet + .get_active_keyset() + .await + .map_err(|error| error.to_string())? + .id; + let fee_and_amounts = wallet + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await + .map_err(|error| error.to_string())?; + let estimated_output_count = inputs_needed + .split(&fee_and_amounts) + .map_err(|error| error.to_string())? + .len(); + let input_fee = wallet + .get_keyset_count_fee(&active_keyset_id, estimated_output_count as u64) + .await + .map_err(|error| error.to_string())?; + let to_swap = Wallet::select_proofs( + inputs_needed + input_fee, + available, + &active_keyset_ids, + &keyset_fees_and_amounts, + true, + ) + .map_err(|error| error.to_string())?; + let swap_fee = wallet + .get_proofs_fee(&to_swap) + .await + .map_err(|error| error.to_string())? + .total; + Ok((input_fee.to_u64(), swap_fee.to_u64())) +} + +/// The active keyset's NUT-02 `input_fee_ppk`, read through the SDK's fee function: the fee on +/// 1000 proofs is ceil(ppk × 1000 / 1000) = ppk (pinned `fees.rs:35–48`, `wallet/mod.rs:356`). +/// A cached metadata read — a GET at most, never a proof-bearing request. +async fn active_keyset_input_fee_ppk(wallet: &Wallet) -> Result { + let active_keyset_id = wallet + .get_active_keyset() + .await + .map_err(|error| error.to_string())? + .id; + Ok(wallet + .get_keyset_count_fee(&active_keyset_id, 1000) + .await + .map_err(|error| error.to_string())? + .to_u64()) +} + +/// **Prepare a melt quote this wallet already holds, by id, bound on its TOTAL cost, and hand it +/// back undecided.** The seller fee remittance's spending call since addendum 8 (§1.1–1.2): the +/// quote was raised by [`melt_quote_async`] and its id is about to be bound to the row by the store +/// fence; this +/// 1. refuses an unknown quote id before the wallet touches a proof, re-checks the ceiling against +/// the quote's STORED amount and reserve, and checks the balance covers them — as +/// [`pay_melt_quote_async`] does; +/// 2. `prepare_melt(quote_id)`: the SDK selects and RESERVES proofs in the wallet's own database and +/// computes the proof-input fee and, when the proofs do not fit, the pre-melt swap and its fee +/// (pinned `melt/saga/mod.rs:286–460`; writes only the wallet's own database; it may FETCH mint +/// metadata/keysets — `:303` → keysets → `metadata_cache.load` — a GET, never a proof-bearing or +/// fee-bearing request); +/// 3. takes the actual-confirmability bound on the SDK's figures — [`MeltCeiling::admits_confirmable`]. Refused +/// ⇒ `PreparedMelt::cancel` (best-effort local compensation: proofs back to Unspent, quote +/// released; `:817–831` logs its own DB errors and still returns Ok) and +/// [`WalletOpsError::MeltTotalExceedsCeiling`]: no fee-bearing request was posted, nothing left +/// the wallet; +/// 4. under it ⇒ returns a [`PreparedMeltPayment`] whose [`PreparedMeltPayment::confirm`] performs +/// the swap (if any) and the melt request — the only spend on this path — and whose +/// [`PreparedMeltPayment::cancel`] (or drop) releases it. +/// +/// The four figures are the SDK's PREPARED ones. `input_fee` is an estimate: `confirm` swaps to +/// invoice + reserve + that estimate, recomputes the input fee on the proofs it receives and refuses +/// after the swap when they do not cover it (pinned `melt/saga/mod.rs:678`, `:704–712`). The +/// preparation therefore also carries the keyset's [`MeltPreparation::input_fee_ppk`], and the +/// caller runs `fee_remit::confirm_would_succeed` on it before its fence (addendum 9 §1.1) — this +/// function does not. Bound (§1.5): fee metadata can change between prepare and confirm and the +/// SDK takes no caller maximum; the seller fee remittance's bound-Spending hold covers that failure. +/// +/// Same mint resolution and `allow_real_mints` gate as every melt here. The operator's +/// `melt_within_*` path does NOT use this: it keeps its reserve-only ceiling (addendum 8 §6). +pub fn prepare_melt_payment_blocking( + home: &MaxplayerHome, + quote_id: &str, + mint_override: Option<&str>, + ceiling: &MeltCeiling, +) -> Result { + crate::runtime_guard::refuse_nested_block_on("prepare_melt_payment_blocking") + .map_err(WalletOpsError::Wallet)?; + let quote_id = quote_id.trim().to_owned(); + if quote_id.is_empty() { + return Err(WalletOpsError::Wallet("melt quote id is empty".into())); + } + let mint_url = resolve_mint(home, mint_override)?; + if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { + return Err(WalletOpsError::RealMintDisallowed { mint_url }); + } + let home = home.clone(); + let ceiling = ceiling.clone(); + let (prepared_tx, prepared_rx) = + std::sync::mpsc::channel::>(); + let (command_tx, command_rx) = std::sync::mpsc::channel::(); + let (reply_tx, reply_rx) = + std::sync::mpsc::channel::, WalletOpsError>>(); + let thread = std::thread::Builder::new() + .name("melt-prepared".to_owned()) + .spawn(move || { + prepared_melt_thread( + home, + mint_url, + quote_id, + ceiling, + prepared_tx, + command_rx, + reply_tx, + ); + }) + .map_err(|error| WalletOpsError::Wallet(format!("spawn melt thread: {error}")))?; + match prepared_rx.recv() { + Ok(Ok(preparation)) => Ok(PreparedMeltPayment { + preparation, + command: Some(command_tx), + reply: reply_rx, + thread: Some(thread), + }), + Ok(Err(error)) => { + let _ = thread.join(); + Err(error) + } + Err(_) => { + let _ = thread.join(); + Err(WalletOpsError::Wallet( + "the melt thread ended before reporting its preparation; nothing was posted" + .to_owned(), + )) + } + } +} + +/// The body of the thread that owns a prepared melt: runtime, wallet and `PreparedMelt` live here +/// together; the caller's verdict arrives on `command`. +fn prepared_melt_thread( + home: MaxplayerHome, + mint_url: String, + quote_id: String, + ceiling: MeltCeiling, + prepared_tx: std::sync::mpsc::Sender>, + command: std::sync::mpsc::Receiver, + reply: std::sync::mpsc::Sender, WalletOpsError>>, +) { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = prepared_tx.send(Err(WalletOpsError::Wallet(error.to_string()))); + return; + } + }; + let wallet = match runtime.block_on(open_wallet_async(&home, &mint_url)) { + Ok(wallet) => wallet, + Err(error) => { + let _ = prepared_tx.send(Err(error)); + return; + } + }; + // Steps 1–3 as one future so an early refusal is one `Err` and the prepared melt (which borrows + // the wallet) stays on this thread's stack for the verdict. + let staged = runtime.block_on(async { + let quote = wallet + .localstore + .get_melt_quote("e_id) + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))? + .ok_or_else(|| { + WalletOpsError::Wallet(format!( + "melt quote {quote_id} is not in this wallet; refusing to pay a quote this wallet did not raise" + )) + })?; + let invoice_sats = quote.amount.to_u64(); + let fee_reserve_sats = quote.fee_reserve.to_u64(); + if !ceiling.admits(invoice_sats, fee_reserve_sats) { + return Err(WalletOpsError::MeltExceedsCeiling { + mint_url: mint_url.clone(), + quote_id: quote.id.clone(), + invoice_sats, + fee_reserve_sats, + planned_invoice_sats: ceiling.invoice_sats, + max_debit_sats: ceiling.max_debit_sats, + }); + } + let need = invoice_sats.saturating_add(fee_reserve_sats); + let before = wallet + .total_balance() + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))? + .to_u64(); + if before < need { + return Err(WalletOpsError::Wallet(format!( + "insufficient funds for melt: balance={before} need={need} (amount+fee_reserve)" + ))); + } + let prepared = wallet + .prepare_melt("e.id, HashMap::new()) + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + let input_fee_sats = prepared.input_fee().to_u64(); + let swap_fee_sats = prepared.swap_fee().to_u64(); + let requires_swap = prepared.requires_swap(); + // The keyset's ppk, for the confirmability arithmetic (addendum 9 §1.1 / addendum 10 §1.1): + // the fee on 1000 proofs is exactly `input_fee_ppk`. Read BEFORE the gate, which needs it; + // a failed read cancels the preparation. + let input_fee_ppk = match active_keyset_input_fee_ppk(&wallet).await { + Ok(ppk) => ppk, + Err(error) => { + let refusal = WalletOpsError::Wallet(format!( + "could not read the active keyset's input fee after preparing melt quote {}: {error}", + quote.id + )); + return match prepared.cancel().await { + Ok(()) => Err(refusal), + Err(cancel_error) => Err(WalletOpsError::Wallet(format!( + "{refusal}; AND cancelling the prepared melt failed: {cancel_error} (no fee-bearing request was posted)" + ))), + }; + } + }; + // The one gate (addendum 10 §1.1): will `confirm` succeed, and does its worst-case debit — + // invoice + reserve + the input fee the SDK RECOMPUTES on the proofs it sends + swap fee — + // fit the ceiling? On a refusal, cancel FIRST — the SDK's compensations: proofs back to + // Unspent, quote released, saga deleted, all local — then refuse, typed. Cancel is + // best-effort (CDK logs a compensation's own DB error and still returns Ok); an Err here is + // reported for what it is: no fee-bearing request was posted, but a local proof reservation + // may remain — `open_wallet_async` only constructs the wallet and does not run CDK + // `recover_incomplete_sagas` on this path (only `crossmint_hop` calls it); a supported + // recovery path is owed, not wired here. + let bound = match ceiling.admits_confirmable( + invoice_sats, + fee_reserve_sats, + Some(input_fee_sats), + swap_fee_sats, + input_fee_ppk, + requires_swap, + ) { + Ok(bound) => bound, + Err(shortfall) => { + let refusal = match shortfall { + ConfirmShortfall::DifferentInvoice { + invoice_sats, + planned_invoice_sats, + } => WalletOpsError::MeltExceedsCeiling { + mint_url: mint_url.clone(), + quote_id: quote.id.clone(), + invoice_sats, + fee_reserve_sats, + planned_invoice_sats, + max_debit_sats: ceiling.max_debit_sats, + }, + ConfirmShortfall::TargetShort { bound, .. } => { + WalletOpsError::MeltWouldNotConfirm { + mint_url: mint_url.clone(), + quote_id: quote.id.clone(), + invoice_sats, + fee_reserve_sats, + input_fee_sats, + actual_input_fee_sats: bound.actual_input_fee_sats, + target_sats: bound.target_sats, + swap_fee_sats, + input_fee_ppk, + } + } + ConfirmShortfall::OverCeiling { + bound, + max_debit_sats, + } => WalletOpsError::MeltTotalExceedsCeiling { + mint_url: mint_url.clone(), + quote_id: quote.id.clone(), + invoice_sats, + fee_reserve_sats, + input_fee_sats: bound.actual_input_fee_sats, + swap_fee_sats, + total_sats: bound.worst_debit_sats, + max_debit_sats, + }, + }; + return match prepared.cancel().await { + Ok(()) => Err(refusal), + Err(error) => Err(WalletOpsError::Wallet(format!( + "{refusal}; AND cancelling the prepared melt failed: {error} (no fee-bearing request was posted; a local proof reservation may remain until a supported recovery path — owed — releases it)" + ))), + }; + } + }; + let total_debit_sats = bound.worst_debit_sats; + let preparation = MeltPreparation { + mint_url: mint_url.clone(), + quote_id: quote.id.clone(), + invoice_sats, + fee_reserve_sats, + input_fee_sats, + swap_fee_sats, + requires_swap, + input_fee_ppk, + total_debit_sats, + expiry_unix: quote.expiry, + }; + Ok((prepared, preparation, before)) + }); + let (prepared, preparation, before) = match staged { + Ok(staged) => staged, + Err(error) => { + let _ = prepared_tx.send(Err(error)); + return; + } + }; + if prepared_tx.send(Ok(preparation.clone())).is_err() { + // Nobody is listening: release and leave. + let _ = runtime.block_on(prepared.cancel()); + return; + } + // Plain blocking wait, on a thread with no runtime driving anything else. + let verdict = command.recv(); + let outcome = match verdict { + Ok(PreparedCommand::Confirm) => runtime.block_on(async { + let confirmed = prepared + .confirm() + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + // `confirm` is the effect boundary — funds have left the wallet, so the outcome MUST be + // returned; the post-confirm balance read is observational (finding U). + let paid_sats = confirmed.amount().to_u64(); + let fee_sats = confirmed.fee_paid().to_u64(); + let read = wallet + .total_balance() + .await + .map(|balance| balance.to_u64()) + .map_err(|error| error.to_string()); + // Actual debit (addendum 9 §2.1): invoice + `fee_paid` (Lightning fee + ACTUAL proof + // input fee, inclusive) + the swap fee charged at the swap. The PREPARED input fee is + // an estimate the SDK replaced inside `fee_paid`; adding it again double-counts. + let spent = paid_sats + .saturating_add(fee_sats) + .saturating_add(preparation.swap_fee_sats); + let balance_after_sats = read.as_ref().ok().copied(); + let balance_sats = post_confirm_balance(read, before, spent, "melt"); + Ok(Some(MeltOutcome { + mint_url: preparation.mint_url.clone(), + paid_sats, + fee_sats, + balance_sats, + balance_after_sats, + quote_id: preparation.quote_id.clone(), + fee_reserve_sats: preparation.fee_reserve_sats, + input_fee_sats: preparation.input_fee_sats, + swap_fee_sats: preparation.swap_fee_sats, + })) + }), + Ok(PreparedCommand::Cancel) | Err(_) => runtime + .block_on(prepared.cancel()) + .map(|()| None) + .map_err(|error| WalletOpsError::Wallet(format!("cancel prepared melt: {error}"))), + }; + let _ = reply.send(outcome); +} + +/// Raise a melt quote for `bolt11` and return it WITHOUT paying. Same mint resolution and real-mint +/// gate as [`melt_async`]; no proofs are selected, prepared or spent. A quote is the only honest +/// estimate of the melt fee, so the seller fee remittance's dry run calls this and prints it. +pub async fn melt_quote_async( + home: &MaxplayerHome, + bolt11: &str, + mint_override: Option<&str>, +) -> Result { + let bolt11 = bolt11.trim(); + if bolt11.is_empty() { + return Err(WalletOpsError::Wallet("bolt11 invoice is empty".into())); + } + let mint_url = resolve_mint(home, mint_override)?; + if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { + return Err(WalletOpsError::RealMintDisallowed { mint_url }); + } + let wallet = open_wallet_async(home, &mint_url).await?; + let quote = wallet + .melt_quote(PaymentMethod::BOLT11, bolt11, None, None) + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + // Fee-aware estimate (addendum 8 §1.3): the proof fees this wallet would pay on top of + // amount + reserve, the SDK's way, reserving nothing. Not estimable ⇒ 0 and the reason. + let (expected_fees_sats, expected_swap_fee_sats, mut expected_fees_note) = + match expected_melt_fees(&wallet, quote.amount + quote.fee_reserve).await { + Ok((input_fee, swap_fee)) => (input_fee + swap_fee, swap_fee, None), + Err(reason) => (0, 0, Some(reason)), + }; + // The keyset's ppk for the planner's post-swap recomputation (addendum 9 §1.2); a cached + // metadata read. Unreadable ⇒ 0 and the reason, never a guess. + let input_fee_ppk = match active_keyset_input_fee_ppk(&wallet).await { + Ok(ppk) => ppk, + Err(reason) => { + let note = format!("keyset input_fee_ppk not readable: {reason}"); + expected_fees_note = Some(match expected_fees_note { + Some(existing) => format!("{existing}; {note}"), + None => note, + }); + 0 + } + }; + Ok(MeltEstimate { + mint_url, + quote_id: quote.id, + amount_sats: quote.amount.to_u64(), + fee_reserve_sats: quote.fee_reserve.to_u64(), + expiry_unix: quote.expiry, + expected_fees_sats, + expected_swap_fee_sats, + input_fee_ppk, + expected_fees_note, + }) +} + +/// What the mint says about ONE melt quote this wallet raised, by id, refreshed from the mint. +/// `None` when this wallet never raised a quote with that id. The seller fee remittance reconciles a +/// SPENDING row against the quote its admission bound — this call — never against "some quote for +/// the invoice" (addendum 5 §1, rule 2). Read-only: nothing here spends. +pub async fn melt_status_for_quote_async( + home: &MaxplayerHome, + quote_id: &str, + mint_override: Option<&str>, +) -> Result, WalletOpsError> { + let quote_id = quote_id.trim(); + if quote_id.is_empty() { + return Ok(None); + } + let mint_url = resolve_mint(home, mint_override)?; + if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { + return Err(WalletOpsError::RealMintDisallowed { mint_url }); + } + let wallet = open_wallet_async(home, &mint_url).await?; + let known = wallet + .localstore + .get_melt_quote(quote_id) + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + if known.is_none() { + return Ok(None); + } + let quote = wallet + .check_melt_quote_status(quote_id) + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + Ok(Some(MeltQuoteStatus { + mint_url, + quote_id: quote.id, + state: quote.state, + amount_sats: quote.amount.to_u64(), + fee_reserve_sats: quote.fee_reserve.to_u64(), + expiry_unix: quote.expiry, + })) +} + +/// What the mint says about the melt quote(s) this wallet raised for `bolt11`, refreshed from the +/// mint. `None` when the wallet never raised a quote for that invoice — which means no melt for it +/// can have started, because [`melt_async`] persists its quote before it prepares anything. When +/// several quotes exist for one invoice (an estimate plus the payment's own), the one that says +/// PAID wins, then PENDING, so a payment that landed is never read as unpaid. Read-only: nothing +/// here spends. +pub async fn melt_status_for_invoice_async( + home: &MaxplayerHome, + bolt11: &str, + mint_override: Option<&str>, +) -> Result, WalletOpsError> { + let bolt11 = bolt11.trim(); + let mint_url = resolve_mint(home, mint_override)?; + if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { + return Err(WalletOpsError::RealMintDisallowed { mint_url }); + } + let wallet = open_wallet_async(home, &mint_url).await?; + let mine: Vec = wallet + .localstore + .get_melt_quotes() + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))? + .into_iter() + .filter(|quote| quote.request.trim() == bolt11) + .map(|quote| quote.id) + .collect(); + if mine.is_empty() { + return Ok(None); + } + // The invoice may have several quotes (the remittance raises an estimate quote at plan time and + // a payment quote at melt time). Report the one that is MOST alive: PAID over settling over a + // live UNPAID over anything else — and among live UNPAID quotes the one expiring last. This is a + // snapshot of the quotes this wallet holds NOW; it cannot speak for a quote raised later, and it + // does not prove that an expired or FAILED quote cannot still be paid (the mint may accept it). + // The remitter therefore uses it only for rows with no quote bound — a `planned` row, or a + // legacy `spending` row from before quotes were bound — and reconciles a bound `spending` row by + // its exact quote id through `melt_status_for_quote_async`, releasing nothing on this ranking + // (addendum 5 §1 rule 2; addendum 6 §1.2). The fake mint's test ranking is similar in spirit + // but not identical to this one. + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0); + let rank = |status: &MeltQuoteStatus| match status.state { + MeltQuoteState::Paid => 0, + MeltQuoteState::Pending | MeltQuoteState::Unknown => 1, + MeltQuoteState::Unpaid if now_unix <= status.expiry_unix => 2, + MeltQuoteState::Unpaid => 3, + MeltQuoteState::Failed => 4, + }; + let mut best: Option = None; + for quote_id in mine { + let quote = wallet + .check_melt_quote_status("e_id) + .await + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + let status = MeltQuoteStatus { + mint_url: mint_url.clone(), + quote_id: quote.id, + state: quote.state, + amount_sats: quote.amount.to_u64(), + fee_reserve_sats: quote.fee_reserve.to_u64(), + expiry_unix: quote.expiry, + }; + if best.as_ref().is_none_or(|current| { + rank(&status) < rank(current) + || (rank(&status) == rank(current) && status.expiry_unix > current.expiry_unix) + }) { + best = Some(status); + } + } + Ok(best) +} + /// List configured mints (default first). pub fn list_mints(home: &MaxplayerHome) -> Result, WalletOpsError> { let default = normalize_mint_url(home.config.default_mint())?; @@ -833,9 +2070,11 @@ pub fn remove_mint(home: &mut MaxplayerHome, mint_url: &str) -> Result<(), Walle if normalized == default { return Err(WalletOpsError::MintPinnedDefault { mint_url: default }); } - let present = home.config.extra_mints.iter().any(|entry| { - normalize_mint_url(entry).ok().as_deref() == Some(normalized.as_str()) - }); + let present = home + .config + .extra_mints + .iter() + .any(|entry| normalize_mint_url(entry).ok().as_deref() == Some(normalized.as_str())); if !present { return Err(WalletOpsError::MintNotAllowed { mint_url: normalized, @@ -866,7 +2105,8 @@ pub fn mint_blocking( amount_sats: u64, mint_override: Option<&str>, ) -> Result { - crate::runtime_guard::refuse_nested_block_on("mint_blocking").map_err(WalletOpsError::Wallet)?; + crate::runtime_guard::refuse_nested_block_on("mint_blocking") + .map_err(WalletOpsError::Wallet)?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -912,7 +2152,8 @@ pub fn send_blocking( amount_sats: u64, mint_override: Option<&str>, ) -> Result { - crate::runtime_guard::refuse_nested_block_on("send_blocking").map_err(WalletOpsError::Wallet)?; + crate::runtime_guard::refuse_nested_block_on("send_blocking") + .map_err(WalletOpsError::Wallet)?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -938,7 +2179,8 @@ pub fn melt_blocking( bolt11: &str, mint_override: Option<&str>, ) -> Result { - crate::runtime_guard::refuse_nested_block_on("melt_blocking").map_err(WalletOpsError::Wallet)?; + crate::runtime_guard::refuse_nested_block_on("melt_blocking") + .map_err(WalletOpsError::Wallet)?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -946,6 +2188,86 @@ pub fn melt_blocking( runtime.block_on(melt_async(home, bolt11, mint_override)) } +/// [`melt_within_async`] on a runtime of its own — the seller fee remittance's spending call. Same +/// nested-runtime refusal as every `*_blocking` wrapper here. +pub fn melt_within_blocking( + home: &MaxplayerHome, + bolt11: &str, + mint_override: Option<&str>, + ceiling: Option<&MeltCeiling>, +) -> Result { + crate::runtime_guard::refuse_nested_block_on("melt_within_blocking") + .map_err(WalletOpsError::Wallet)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + runtime.block_on(melt_within_async(home, bolt11, mint_override, ceiling)) +} + +pub fn melt_quote_blocking( + home: &MaxplayerHome, + bolt11: &str, + mint_override: Option<&str>, +) -> Result { + crate::runtime_guard::refuse_nested_block_on("melt_quote_blocking") + .map_err(WalletOpsError::Wallet)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + runtime.block_on(melt_quote_async(home, bolt11, mint_override)) +} + +pub fn melt_status_for_invoice_blocking( + home: &MaxplayerHome, + bolt11: &str, + mint_override: Option<&str>, +) -> Result, WalletOpsError> { + crate::runtime_guard::refuse_nested_block_on("melt_status_for_invoice_blocking") + .map_err(WalletOpsError::Wallet)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + runtime.block_on(melt_status_for_invoice_async(home, bolt11, mint_override)) +} + +/// [`melt_status_for_quote_async`] on a runtime of its own. +pub fn melt_status_for_quote_blocking( + home: &MaxplayerHome, + quote_id: &str, + mint_override: Option<&str>, +) -> Result, WalletOpsError> { + crate::runtime_guard::refuse_nested_block_on("melt_status_for_quote_blocking") + .map_err(WalletOpsError::Wallet)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + runtime.block_on(melt_status_for_quote_async(home, quote_id, mint_override)) +} + +/// [`pay_melt_quote_async`] on a runtime of its own. **Retained, and DEAD on the seller fee +/// remittance path** since addendum 8: that path's spending edge is +/// [`prepare_melt_payment_blocking`] → [`PreparedMeltPayment::confirm`]; removal of this wrapper is +/// owed to the owners. Addendum 5 §1, rule 1 still holds for it: pay the bound quote by id, never +/// re-quote. +pub fn pay_melt_quote_blocking( + home: &MaxplayerHome, + quote_id: &str, + mint_override: Option<&str>, + ceiling: &MeltCeiling, +) -> Result { + crate::runtime_guard::refuse_nested_block_on("pay_melt_quote_blocking") + .map_err(WalletOpsError::Wallet)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| WalletOpsError::Wallet(error.to_string()))?; + runtime.block_on(pay_melt_quote_async(home, quote_id, mint_override, ceiling)) +} + pub fn invoice_blocking( home: &MaxplayerHome, amount_sats: u64, @@ -968,6 +2290,287 @@ mod tests { static NEXT: AtomicU64 = AtomicU64::new(0); + /// Stage 2a, addendum 3 §1: the money hold. A ceiling admits a payment-time quote only when the + /// invoice is the one planned AND invoice + the quote's fee reserve fit under the maximum gross + /// debit — checked against the quote the mint raised at PAYMENT time, so a reserve that grew + /// between the estimate and the payment is refused before any proof is consumed. + #[test] + fn a_melt_ceiling_admits_only_the_planned_invoice_within_the_gross_debit() { + let ceiling = MeltCeiling { + max_debit_sats: 15, + invoice_sats: 13, + planned_quote_id: Some("q-estimate".to_owned()), + }; + assert!(ceiling.admits(13, 2), "13 + 2 = 15 fits exactly"); + assert!(ceiling.admits(13, 0), "a smaller reserve fits"); + assert!( + !ceiling.admits(13, 4), + "13 + 4 = 17 exceeds the 15-sat gross: the reserve grew between estimate and payment" + ); + assert!( + !ceiling.admits(12, 0), + "a different invoice amount than the one planned is refused even when it fits" + ); + assert!(!ceiling.admits(14, 0), "and so is a larger one"); + assert!( + !ceiling.admits(u64::MAX, u64::MAX), + "the sum saturates rather than wrapping under the ceiling" + ); + } + + /// Addendum 10 §1.1: the bound on a PREPARED melt is the ACTUAL-confirmability bound, one + /// arithmetic for the planner, the gate and the pre-fence check. Verdict 4714623 §3.2's witness — + /// gross 19, reserve 2, 1000 ppk, one 32-sat proof: invoice 13 prepares input 4 (15 = 8+4+2+1) + /// and swap 1; the PREPARED total 20 > 19, but confirm swaps to 19 = [16, 2, 1], recomputes 3, + /// 19 ≥ 18, and debits at most 13 + 2 + 3 + 1 = 19 ≤ 19 — ADMITTED. Round 8 refused it. + #[test] + fn a_melt_ceiling_admits_a_prepared_melt_by_what_confirm_will_actually_debit() { + let ceiling = MeltCeiling { + max_debit_sats: 19, + invoice_sats: 13, + planned_quote_id: Some("q-estimate".to_owned()), + }; + let bound = ceiling + .admits_confirmable(13, 2, Some(4), 1, 1000, true) + .expect("13 + 2 + actual 3 + swap 1 = 19 fits 19"); + assert_eq!( + bound, + ConfirmBound { + need_sats: 15, + prepared_input_fee_sats: 4, + target_sats: 19, + actual_input_fee_sats: 3, + swap_fee_sats: 1, + worst_debit_sats: 19, + } + ); + assert_eq!( + MeltCeiling::total_debit(13, 2, 4, 1), + 20, + "the PREPARED total is 20 — an estimate above the actual debit, no longer the gate" + ); + // One sat less of gross and the same melt is over the ceiling, by the ACTUAL figures. + let tighter = MeltCeiling { + max_debit_sats: 18, + ..ceiling.clone() + }; + assert_eq!( + tighter.admits_confirmable(13, 2, Some(4), 1, 1000, true), + Err(ConfirmShortfall::OverCeiling { + bound: bound.clone(), + max_debit_sats: 18, + }) + ); + // Record 37's schedule: invoice 12, reserve 0, prepared 2 (12 = 8+4) ⇒ target 14 = [8,4,2] + // ⇒ actual 3 ⇒ 14 < 15: the SDK would refuse AFTER the swap — refused here, before it. + let planned_12 = MeltCeiling { + max_debit_sats: 20, + invoice_sats: 12, + planned_quote_id: None, + }; + assert!(matches!( + planned_12.admits_confirmable(12, 0, Some(2), 1, 1000, true), + Err(ConfirmShortfall::TargetShort { + needed_after_swap_sats: 15, + bound: ConfirmBound { + target_sats: 14, + actual_input_fee_sats: 3, + .. + }, + }) + )); + // Exact fit (no swap): the selected proofs already carry the prepared fee; only the debit + // bound applies, with actual = prepared. + assert_eq!( + ceiling + .admits_confirmable(13, 2, Some(4), 0, 1000, false) + .map(|bound| bound.worst_debit_sats), + Ok(19) + ); + assert!(matches!( + ceiling.admits_confirmable(13, 2, Some(5), 0, 1000, false), + Err(ConfirmShortfall::OverCeiling { .. }) + )); + // A different invoice than the one planned is refused even when it fits. + assert_eq!( + ceiling.admits_confirmable(12, 0, Some(0), 0, 0, false), + Err(ConfirmShortfall::DifferentInvoice { + invoice_sats: 12, + planned_invoice_sats: 13, + }) + ); + // Saturating, never wrapping. + assert!(matches!( + ceiling.admits_confirmable(13, u64::MAX, Some(u64::MAX), u64::MAX, 1000, true), + Err(ConfirmShortfall::OverCeiling { .. }) + )); + assert_eq!(MeltCeiling::total_debit(u64::MAX, 1, 1, 1), u64::MAX); + } + + /// The planner's question, through the same function: the largest invoice for which + /// `confirm_bound` holds with the prepared fee computed on the split of `need`. Addendum 10 §1.2: + /// 19/2/1000/[32] ⇒ 13; a consistently reserve-0 schedule at gross 20 ⇒ 15 (not 12); 20/2 ⇒ 13; + /// 3/1 ⇒ none. + #[test] + fn the_confirmability_bound_selects_the_verdicts_invoices_when_searched_downward() { + let largest = |gross: u64, reserve: u64| { + (1..=gross.saturating_sub(reserve)).rev().find(|&invoice| { + confirm_bound(invoice, reserve, None, 1, 1000, true, gross).is_ok() + }) + }; + assert_eq!(largest(19, 2), Some(13)); + assert_eq!(largest(20, 0), Some(15)); + assert_eq!(largest(20, 2), Some(13)); + assert_eq!(largest(3, 1), None); + let fifteen = confirm_bound(15, 0, None, 1, 1000, true, 20).expect("15/0 fits 20"); + assert_eq!( + ( + fifteen.target_sats, + fifteen.actual_input_fee_sats, + fifteen.worst_debit_sats + ), + (19, 3, 19) + ); + } + + /// The typed refusal names every part, the total and the ceiling, and says what happened to the + /// prepared melt — the line the remittance prints. + #[test] + fn a_total_ceiling_refusal_names_the_parts_the_total_and_the_ceiling() { + let refusal = WalletOpsError::MeltTotalExceedsCeiling { + mint_url: "https://mint.example".to_owned(), + quote_id: "q-pay".to_owned(), + invoice_sats: 13, + fee_reserve_sats: 2, + input_fee_sats: 4, + swap_fee_sats: 1, + total_sats: 20, + max_debit_sats: 15, + }; + assert_eq!( + refusal.to_string(), + "melt refused before spending: mint https://mint.example quote q-pay would debit 20 sats in total \ + (13 sats invoice + 2 sats fee reserve + 4 sats proof input fee + 1 sats swap fee) against a ceiling \ + of 15 sats; the prepared melt was cancelled and its proofs released; nothing was posted to the mint" + ); + } + + /// A prepared payment whose verdict never comes is cancelled on drop: the thread must have + /// replied and exited, not hung. Exercised with a thread that models the protocol (the real + /// body needs a wallet with a stored quote — the full path is the remittance's regression). + #[test] + fn dropping_an_undecided_prepared_payment_cancels_it_and_joins_its_thread() { + let (command_tx, command_rx) = std::sync::mpsc::channel::(); + let (reply_tx, reply_rx) = + std::sync::mpsc::channel::, WalletOpsError>>(); + let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let seen = Arc::clone(&cancelled); + let thread = std::thread::spawn(move || { + let verdict = command_rx.recv(); + if matches!(verdict, Ok(PreparedCommand::Cancel) | Err(_)) { + seen.store(true, std::sync::atomic::Ordering::SeqCst); + } + let _ = reply_tx.send(Ok(None)); + }); + let payment = PreparedMeltPayment { + preparation: MeltPreparation { + mint_url: "https://mint.example".to_owned(), + quote_id: "q-pay".to_owned(), + invoice_sats: 13, + fee_reserve_sats: 2, + input_fee_sats: 0, + swap_fee_sats: 0, + requires_swap: false, + input_fee_ppk: 0, + total_debit_sats: 15, + expiry_unix: u64::MAX, + }, + command: Some(command_tx), + reply: reply_rx, + thread: Some(thread), + }; + assert!(format!("{payment:?}").contains("decided: false")); + drop(payment); + assert!( + cancelled.load(std::sync::atomic::Ordering::SeqCst), + "drop without a verdict must send Cancel and wait for the thread" + ); + } + + /// `confirm` and `cancel` each consume the payment and relay the thread's reply. + #[test] + fn a_prepared_payment_relays_confirm_and_cancel_verdicts() { + fn fixture( + script: impl FnOnce(PreparedCommand) -> Result, WalletOpsError> + + Send + + 'static, + ) -> PreparedMeltPayment { + let (command_tx, command_rx) = std::sync::mpsc::channel::(); + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + let verdict = command_rx.recv().expect("a verdict"); + let _ = reply_tx.send(script(verdict)); + }); + PreparedMeltPayment { + preparation: MeltPreparation { + mint_url: "https://mint.example".to_owned(), + quote_id: "q-pay".to_owned(), + invoice_sats: 13, + fee_reserve_sats: 2, + input_fee_sats: 1, + swap_fee_sats: 0, + requires_swap: false, + input_fee_ppk: 0, + total_debit_sats: 16, + expiry_unix: u64::MAX, + }, + command: Some(command_tx), + reply: reply_rx, + thread: Some(thread), + } + } + let outcome = MeltOutcome { + mint_url: "https://mint.example".to_owned(), + paid_sats: 13, + fee_sats: 1, + balance_sats: 100, + balance_after_sats: Some(100), + quote_id: "q-pay".to_owned(), + fee_reserve_sats: 2, + input_fee_sats: 1, + swap_fee_sats: 0, + }; + let expected = outcome.clone(); + let confirmed = fixture(move |verdict| { + assert!(matches!(verdict, PreparedCommand::Confirm)); + Ok(Some(outcome)) + }) + .confirm() + .expect("confirm relays the outcome"); + assert_eq!(confirmed, expected); + + fixture(|verdict| { + assert!(matches!(verdict, PreparedCommand::Cancel)); + Ok(None) + }) + .cancel() + .expect("cancel relays Ok"); + + let none_for_confirm = fixture(|_| Ok(None)) + .confirm() + .expect_err("no outcome is an error"); + assert!( + none_for_confirm + .to_string() + .contains("reported no outcome for a confirm") + ); + + let failed = fixture(|_| Err(WalletOpsError::Wallet("mint said no".to_owned()))) + .confirm() + .expect_err("a failed confirm is relayed"); + assert_eq!(failed.to_string(), "wallet error: mint said no"); + } + // Finding DD: `SendOutcome.token` is a BEARER cashu token (spendable ecash). Its `Debug` MUST // redact the token — a derived Debug would print it verbatim, so any debug log of a SendOutcome // would leak spendable funds. Assert the debug rendering contains neither the token nor any of @@ -988,8 +2591,7 @@ mod tests { ); // No substring of the token beyond a trivial prefix leaks (guard against partial exposure). assert!( - !rendered.contains("spendable-bearer-ecash-secret") - && !rendered.contains(&token[6..]), + !rendered.contains("spendable-bearer-ecash-secret") && !rendered.contains(&token[6..]), "SendOutcome Debug must not leak token material: {rendered}" ); assert!( @@ -1022,7 +2624,10 @@ mod tests { let _ = std::fs::remove_dir_all(&root); let mut home = bootstrap(&root).expect("bootstrap"); // Issue #378: the shipped default mint is the real minibits mint (not testnut). - assert_eq!(home.config.default_mint(), crate::home::DEFAULT_MINIBITS_MINT_URL); + assert_eq!( + home.config.default_mint(), + crate::home::DEFAULT_MINIBITS_MINT_URL + ); let listed = list_mints(&home).expect("list"); assert_eq!(listed.len(), 1); assert!(listed[0].is_default); @@ -1132,7 +2737,10 @@ mod tests { let err = mint_blocking(&home, 1, Some("https://evil.example")).expect_err("deny"); assert!(matches!(&err, WalletOpsError::MintNotAllowed { .. })); // #465: a genuine membership miss KEEPS the `mints add` remedy — the distinction the fix draws. - assert!(err.to_string().contains("mints add"), "membership miss keeps the `mints add` remedy: {err}"); + assert!( + err.to_string().contains("mints add"), + "membership miss keeps the `mints add` remedy: {err}" + ); let _ = std::fs::remove_dir_all(&root); } @@ -1233,7 +2841,10 @@ mod tests { #[test] fn post_confirm_balance_read_failure_preserves_outcome() { // Read failed: best-effort `before - spent`, never an error → the token is still returned. - assert_eq!(post_confirm_balance(Err("boom".into()), 100, 30, "send"), 70); + assert_eq!( + post_confirm_balance(Err("boom".into()), 100, 30, "send"), + 70 + ); // Underflow-safe when the estimate would go negative. assert_eq!(post_confirm_balance(Err("boom".into()), 10, 30, "send"), 0); // Read ok and balance decreased → report the read value. @@ -1304,8 +2915,7 @@ mod tests { let root = temp_home("complete-nested"); let _ = std::fs::remove_dir_all(&root); let home = bootstrap(&root).expect("bootstrap"); - let err = complete_mint_by_id_blocking(&home, "quote", Some(21), None) - .expect_err("nested"); + let err = complete_mint_by_id_blocking(&home, "quote", Some(21), None).expect_err("nested"); assert!(err.to_string().contains("nested block_on refused")); let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/maxplayer/src/sell.rs b/crates/maxplayer/src/sell.rs index 2438f8f5e..555d6e5c7 100644 --- a/crates/maxplayer/src/sell.rs +++ b/crates/maxplayer/src/sell.rs @@ -82,8 +82,10 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { sell_usage(out); return SUCCESS; } - // `maxplayer seller fees` — read-only ledger of the platform fee journal. Dispatched before the - // option parser because it takes no seller options and never boots a seat. + // `maxplayer seller fees` — the platform fee journal (read-only) and, under `fees remit`, the + // operator's inspection/recovery path over the remittance the node performs automatically after + // each collect (dry run by default, `--confirm` to pay now). Dispatched before the option parser + // because it takes no seller options and never boots a seat. if args.first().map(String::as_str) == Some("fees") { return crate::seller_fees::run(&args[1..], out, err); } @@ -792,7 +794,7 @@ impl SellOptions { fn sell_usage(w: &mut dyn Write) { let _ = writeln!( w, - "Usage:\n maxplayer seller --agent --rate-sats [--git-remote ] [--claim-open-pool] [--accept-open-targeted] [--name ] [--home ] [--skip-doctor]\n maxplayer seller # zero-prompt relaunch from config.toml\n maxplayer seller --agent-argv [--agent-argv ...] --rate-sats # power-user hatch\n maxplayer seller fees [--home ] # per-job ledger: what the buyer paid / mint fee / platform fee (10%) / you keep — recorded only, nothing is paid out\n\nNotes:\n - required user choices: --agent (or --agent-argv) + --rate-sats (first run)\n - defaults: relay=wss://relay.maxplayer.ai mint=mint.minibits.cash git-remote=relay-git key=0600 auto\n - no --key (packaged key file only)\n - startup runs the doctor readiness gate and REFUSES to boot on a blocking failure (no working nix, agent unresolvable, no mint reachable, seller key missing, relay unreachable), each with a fix hint\n - --skip-doctor: bypass the startup readiness checks (default: checks-on; not recommended). The nix check still runs — it is an environment requirement (#745) with no bypass\n - --unsafe-no-sandbox: serve a STRANGER-FACING surface with no working sandbox (either open surface) — this box then runs code written by strangers with no containment (waives only that one check)\n - BOTH open surfaces are OFF by default, and they are separate: --claim-open-pool opts in to untargeted pool offers, --accept-open-targeted opts in to targeted offers from buyers you have not named\n - with neither set and no [seller] accept_offers_only_from, this seat claims NOTHING and says so at boot\n - --offer-backfill-secs : see OPEN-POOL offers posted up to n seconds before startup (default 1200; 0 = live-only; targeted offers always backfill)\n{}", + "Usage:\n maxplayer seller --agent --rate-sats [--git-remote ] [--claim-open-pool] [--accept-open-targeted] [--name ] [--home ] [--skip-doctor]\n maxplayer seller # zero-prompt relaunch from config.toml\n maxplayer seller --agent-argv [--agent-argv ...] --rate-sats # power-user hatch\n maxplayer seller fees [--home ] # per-job ledger: what the buyer paid / mint fee / platform fee (10%) / you keep, plus what is remitted / unremitted\n maxplayer seller fees remit [--home ] [--dry-run | --confirm] # inspect / force the platform fee remittance the node performs automatically after each collect; dry run unless --confirm\n\nNotes:\n - required user choices: --agent (or --agent-argv) + --rate-sats (first run)\n - defaults: relay=wss://relay.maxplayer.ai mint=mint.minibits.cash git-remote=relay-git key=0600 auto\n - no --key (packaged key file only)\n - startup runs the doctor readiness gate and REFUSES to boot on a blocking failure (no working nix, agent unresolvable, no mint reachable, seller key missing, relay unreachable), each with a fix hint\n - --skip-doctor: bypass the startup readiness checks (default: checks-on; not recommended). The nix check still runs — it is an environment requirement (#745) with no bypass\n - --unsafe-no-sandbox: serve a STRANGER-FACING surface with no working sandbox (either open surface) — this box then runs code written by strangers with no containment (waives only that one check)\n - BOTH open surfaces are OFF by default, and they are separate: --claim-open-pool opts in to untargeted pool offers, --accept-open-targeted opts in to targeted offers from buyers you have not named\n - with neither set and no [seller] accept_offers_only_from, this seat claims NOTHING and says so at boot\n - --offer-backfill-secs : see OPEN-POOL offers posted up to n seconds before startup (default 1200; 0 = live-only; targeted offers always backfill)\n{}", crate::skill::docs_pointer_line() ); } diff --git a/crates/maxplayer/src/seller_fees.rs b/crates/maxplayer/src/seller_fees.rs index 64a1fe356..0113b2ee1 100644 --- a/crates/maxplayer/src/seller_fees.rs +++ b/crates/maxplayer/src/seller_fees.rs @@ -1,11 +1,29 @@ -//! `maxplayer seller fees` — the seller-facing read-out of the platform fee journal. +//! `maxplayer seller fees` — the seller-facing read-out of the platform fee journal — and +//! `maxplayer seller fees remit`, the operator's inspection and recovery path over the platform fee +//! remittance the seller node performs automatically after each collected payment. //! -//! Prints, per collected job and as a total, the four figures a seller needs to see without doing -//! arithmetic or reading source: what the buyer paid (the offer amount), the mint's swap fee, the -//! platform fee (rate and sats), and what the seller keeps. Read-only as to money: it opens -//! `seller.sqlite` (which applies the store's additive schema migration if the file predates the -//! current version), reads, prints, and exits. It moves no sats — the platform fee it shows is -//! recorded, not paid out, because no payout destination exists in the product. +//! `seller fees` prints, per collected job and as a total, the four figures a seller needs to see +//! without doing arithmetic or reading source: what the buyer paid (the offer amount), the mint's +//! swap fee, the platform fee (rate and sats), and what the seller keeps — plus how much of the +//! platform fee has been remitted, how much is unremitted, and every remittance so far. Read-only as +//! to money: it opens `seller.sqlite` (applying the store's additive schema migration if the file +//! predates the current version), reads, prints, and exits. +//! +//! `seller fees remit` is the third of the three callers of `maxplayer_core::fee_remit::remit` (the +//! other two are the seller node's: its collect path, and the retry tick that backs a failed +//! remittance off and tries again while the node runs). It prints the recent remittance attempts and their +//! outcomes, reconciles an attempt interrupted mid-payment, resolves the platform's Lightning +//! address over LNURL-pay, takes a melt quote for the unremitted balance, and prints the plan. +//! **Without `--confirm` that is all it does** (a dry run is the default). With `--confirm` it forces +//! an attempt now — for an operator whose automatic path has been failing, or who has turned it off +//! with `[platform_fee] auto_remit = false` — paying the invoice from the seller's ecash through +//! `wallet_ops::prepare_melt_payment_blocking` → `PreparedMeltPayment::confirm`: the payment quote +//! is raised first and checked against the accrued gross, the melt is prepared and its total +//! (invoice + reserve + the SDK's proof fees) bounded under that gross, the store fence binds the +//! quote to the row, and then exactly that prepared melt is confirmed (the same gated wallet +//! `maxplayer wallet melt` uses underneath — it honours `allow_real_mints`; the older +//! `wallet_ops::pay_melt_quote_blocking` is retained but dead on this path), and recording the +//! settlement so the same sats are never paid twice. Running it again after a payment pays nothing. use std::io::Write; use std::path::PathBuf; @@ -13,6 +31,23 @@ use std::path::PathBuf; const SUCCESS: i32 = 0; const USAGE_ERROR: i32 = 1; const RUNTIME_ERROR: i32 = 2; +/// `remit` declined and moved nothing: nothing unremitted, a balance below the destination's +/// minimum, a fee reserve that does not fit, a planned attempt still settling at the mint, or a +/// spending row HELD on its bound quote (addendum 6 §1.3). Distinct from `SUCCESS` so a script +/// cannot read a refusal as a payment. +const REFUSED: i32 = 3; + +/// What the arguments asked for. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Command { + /// `maxplayer seller fees [--home ]` + Ledger { home: Option }, + /// `maxplayer seller fees remit [--home ] [--dry-run | --confirm]` + Remit { + home: Option, + confirm: bool, + }, +} /// Entry from `sell::run` for `maxplayer seller fees ...`. pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { @@ -20,8 +55,8 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { usage(out); return SUCCESS; } - let home = match parse_home(args) { - Ok(home) => home, + let command = match parse_command(args) { + Ok(command) => command, Err(message) => { let _ = writeln!(err, "{message}"); usage(err); @@ -31,7 +66,7 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { #[cfg(not(feature = "wallet"))] { - let _ = (home, out); + let _ = (command, out); let _ = writeln!( err, "maxplayer seller fees requires the wallet feature (rebuild with default features)" @@ -41,8 +76,12 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { #[cfg(feature = "wallet")] { - match print_ledger(home, out) { - Ok(()) => SUCCESS, + let result = match command { + Command::Ledger { home } => print_ledger(home, out).map(|()| SUCCESS), + Command::Remit { home, confirm } => remit_live(home, confirm, out), + }; + match result { + Ok(code) => code, Err(message) => { let _ = writeln!(err, "{message}"); RUNTIME_ERROR @@ -51,38 +90,67 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { } } -fn parse_home(args: &[String]) -> Result, String> { +fn parse_command(args: &[String]) -> Result { + let (remit, options) = match args.first().map(String::as_str) { + Some("remit") => (true, &args[1..]), + _ => (false, args), + }; let mut home = None; + let mut confirm = false; + let mut dry_run = false; let mut idx = 0; - while idx < args.len() { - match args[idx].as_str() { + while idx < options.len() { + match options[idx].as_str() { "--home" => { idx += 1; home = Some(PathBuf::from( - args.get(idx).ok_or("--home requires a value")?, + options.get(idx).ok_or("--home requires a value")?, + )); + } + "--confirm" if remit => confirm = true, + "--dry-run" if remit => dry_run = true, + other => { + return Err(format!( + "unknown seller fees{} option: {other}", + if remit { " remit" } else { "" } )); } - other => return Err(format!("unknown seller fees option: {other}")), } idx += 1; } - Ok(home) + if confirm && dry_run { + return Err("--confirm and --dry-run contradict each other; pass one".to_owned()); + } + Ok(if remit { + Command::Remit { home, confirm } + } else { + Command::Ledger { home } + }) } fn usage(w: &mut dyn Write) { let _ = writeln!( w, - "Usage:\n maxplayer seller fees [--home ]\n\nPrints, for every job this seat has collected payment on, what the buyer paid, the mint's fee,\nthe platform fee (rate and sats) and what you keep, then the totals — the platform fee total is\nbroken out by the rate each job was recorded at. Moves no sats (it only opens, reads and prints\nthe seller store). The platform fee is recorded, not paid out: there is no payout destination\nyet, so nothing here is a bill due." + "Usage:\n maxplayer seller fees [--home ]\n maxplayer seller fees remit [--home ] [--dry-run | --confirm]\n\n`seller fees` prints, for every job this seat has collected payment on, what the buyer paid, the\nmint's fee, the platform fee (rate and sats) and what you keep, then the totals — the platform fee\nbroken out by the rate each job was recorded at and split into remitted / unremitted — and every\nremittance so far. Moves no sats (it only opens, reads and prints the seller store).\n\nThe seller node pays the platform fee AUTOMATICALLY: after each payment it collects, it remits the\nunremitted balance to the platform's Lightning address (fixed in the product; not configurable),\nbest-effort — a failed attempt is logged and journaled, and the node retries on its own clock while\nit runs (backing off from 30 seconds to at most every 30 minutes); the next collected payment is one\nmore trigger. Set `[platform_fee] auto_remit = false` in config.toml to stop the automatic attempts;\nthe fee still accrues and is still owed.\n\n`seller fees remit` is inspection and recovery. The default is a DRY RUN: it prints the recent\nattempts and their outcomes, resolves the address, quotes the mint's melt fee, prints the plan and\nmoves nothing. `--confirm` pays NOW (whether or not auto_remit is on), at most the unremitted total —\nthe mint's melt fee comes out of that amount, never on top, enforced against the quote the mint\nraises for the payment itself. It refuses (exit 3, nothing moved) when nothing is unremitted, when\nthe balance is below the destination's minimum (small balances accumulate until they clear it),\nwhen an earlier attempt is still settling, or when another live process (the node) holds a planned\nattempt whose lease has not run out. Running it again after a payment pays nothing: the receipts it\ndischarged are recorded, and an interrupted attempt is reconciled with the mint, not repeated.\nExit 0 = dry run printed or payment made; 1 = usage; 2 = error; 3 = refused, nothing moved." ); } #[cfg(feature = "wallet")] -fn print_ledger(home: Option, out: &mut dyn Write) -> Result<(), String> { +fn open_store( + home: &Option, +) -> Result< + ( + maxplayer_core::seller_node::store::SellerStore, + PathBuf, + PathBuf, + ), + String, +> { use maxplayer_core::seller_node::STATE_DB_FILE; use maxplayer_core::seller_node::store::SellerStore; let root = match home { - Some(path) => path, + Some(path) => path.clone(), None => maxplayer_core::home::default_home_dir() .map_err(|error| format!("resolve home: {error}"))?, }; @@ -95,10 +163,23 @@ fn print_ledger(home: Option, out: &mut dyn Write) -> Result<(), String } let store = SellerStore::open(&db).map_err(|error| format!("open {}: {error}", db.display()))?; + Ok((store, root, db)) +} + +#[cfg(feature = "wallet")] +fn print_ledger(home: Option, out: &mut dyn Write) -> Result<(), String> { + let (store, _root, db) = open_store(&home)?; let accrued = store .accrued_fees() .map_err(|error| format!("read receipts: {error}"))?; - let _ = write!(out, "{}", render(&accrued, &db.display().to_string())); + let remittances = store + .remittances() + .map_err(|error| format!("read remittances: {error}"))?; + let _ = write!( + out, + "{}", + render(&accrued, &remittances, &db.display().to_string()) + ); Ok(()) } @@ -106,9 +187,11 @@ fn print_ledger(home: Option, out: &mut dyn Write) -> Result<(), String #[cfg(feature = "wallet")] pub(crate) fn render( accrued: &maxplayer_core::seller_node::store::AccruedFees, + remittances: &[maxplayer_core::seller_node::store::FeeRemittance], db: &str, ) -> String { use maxplayer_core::platform_fee::bps_to_percent_label; + use maxplayer_core::seller_node::store::{RemittanceState, SettledBy}; let mut text = String::new(); text.push_str(&format!("Seller fee ledger — {db}\n")); @@ -130,13 +213,18 @@ pub(crate) fn render( Some(kept) => format!("{kept} sats"), None => "unknown (mint fee not recorded)".to_owned(), }; + let remitted = match &row.remittance_id { + Some(id) => format!("remittance {id}"), + None => "unremitted".to_owned(), + }; text.push_str(&format!( - " job {}\n what the buyer paid: {} sats\n mint fee: {}\n platform fee ({}): {} sats\n you keep: {}\n", + " job {}\n what the buyer paid: {} sats\n mint fee: {}\n platform fee ({}): {} sats — {}\n you keep: {}\n", row.job_id, row.amount_sats, mint_fee, bps_to_percent_label(row.fee_bps), row.fee_sats, + remitted, kept )); } @@ -158,9 +246,17 @@ pub(crate) fn render( if accrued.rows_without_mint_fee == 1 { "" } else { "s" } )); } + let in_flight = if accrued.in_flight_fee_sats == 0 { + String::new() + } else { + format!( + ", {} sats in flight (a remittance is settling)", + accrued.in_flight_fee_sats + ) + }; text.push_str(&format!( - " platform fee: {} sats — recorded, not paid out (no payout destination exists yet)\n", - accrued.total_fee_sats + " platform fee: {} sats accrued — {} sats remitted, {} sats unremitted{}\n", + accrued.total_fee_sats, accrued.remitted_fee_sats, accrued.unremitted_fee_sats, in_flight )); // The rate the total was taken at, never assumed: rows can carry different recorded rates (a // store that collected before the rate was set holds 0% rows beside 10% rows), so the total is @@ -188,6 +284,58 @@ pub(crate) fn render( text.push_str(" you keep: unknown (no job recorded its mint fee)\n"); } } + text.push_str("Remittances:\n"); + if remittances.is_empty() { + text.push_str( + " none yet — `maxplayer seller fees remit` shows the plan; `--confirm` pays the unremitted balance\n", + ); + } + for row in remittances { + let melt_fee = match (row.melt_fee_sats, row.settled_by, row.melt_fee_reserve_sats) { + (Some(fee), _, _) => format!("{fee} sats"), + // Settled by reconciliation: the mint reports the quote PAID but not the fee it kept for + // a quote another run paid; the quote's reserve is the fee's ceiling and IS recorded. + (None, Some(SettledBy::Reconciliation), Some(reserve)) => format!( + "not observed (settled by reconciliation against the mint, which reports the quote paid but not the fee it kept; at most {reserve} sats, the quote's reserve)" + ), + (None, Some(SettledBy::Reconciliation), None) => { + "not observed (settled by reconciliation against the mint, which reports the quote paid but not the fee it kept)".to_owned() + } + (None, _, _) => "not observed".to_owned(), + }; + let state = match row.state { + RemittanceState::Planned => "PLANNED (settling — re-run remit to reconcile)".to_owned(), + // Addendum 4 §1 / addendum 5 §1 / addendum 6 §1.2: the owner's compare-and-set admitted + // the melt and bound the quote it pays. Reconciliation asks the mint about THAT quote — + // settles on PAID, otherwise HOLDS (UNPAID at any age, FAILED, PENDING, UNKNOWN, or no + // such quote): no clock, no terminal observation and no other process releases it; an + // operator decision does (none automated in this round). + RemittanceState::Spending => format!( + "SPENDING (melt admitted, bound to melt quote {}; resolved only by the mint's verdict on that quote — re-run remit to reconcile)", + row.spending_quote_id + .as_deref() + .unwrap_or("none recorded — admitted before quotes were bound") + ), + RemittanceState::Settled => "settled".to_owned(), + RemittanceState::Failed => "failed (no sats left; receipts released)".to_owned(), + }; + text.push_str(&format!( + " {}: {} sats to {} — gross {} sats, melt fee {}, invoice {}, {} receipt{}, planned at unix {}{}\n", + state, + row.net_sats, + row.destination, + row.gross_sats, + melt_fee, + row.payment_hash, + row.receipts, + if row.receipts == 1 { "" } else { "s" }, + row.created_at_unix, + match row.settled_at_unix { + Some(at) => format!(", resolved at unix {at}"), + None => String::new(), + } + )); + } text } @@ -227,11 +375,134 @@ pub(crate) fn platform_fee_by_rate( rates.sort_by_key(|rate| rate.fee_bps); rates } +// ---- remit ------------------------------------------------------------------------------------ + +/// `maxplayer seller fees remit`: the operator's inspection and recovery path over the ONE remit +/// entry point in the product, [`maxplayer_core::fee_remit::remit`] — the same function the seller +/// node calls automatically after every collected payment and on its retry tick. Here it runs against the shipped effects +/// (LNURL over https, the packaged wallet at `home`, the home's default mint), as a dry run unless +/// `--confirm` was passed, and maps the outcome to an exit code so a script cannot read a refusal +/// as a payment. Note that `--confirm` pays regardless of `[platform_fee] auto_remit`: that switch +/// governs the automatic attempt only, and this command is how an operator pays when it is off. +#[cfg(feature = "wallet")] +fn remit_live(home: Option, confirm: bool, out: &mut dyn Write) -> Result { + use maxplayer_core::fee_remit::{LiveEffects, RemitTrigger, remit}; + + let (store, root, db) = open_store(&home)?; + let home = maxplayer_core::home::bootstrap(&root) + .map_err(|error| format!("open home {}: {error}", root.display()))?; + let auto_remit = home.config.platform_fee.auto_remit; + let mut effects = LiveEffects::new(home)?; + let now_unix = i64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| format!("clock: {error}"))? + .as_secs(), + ) + .map_err(|error| format!("clock: {error}"))?; + let _ = writeln!(out, "Platform fee remittance — {}", db.display()); + let _ = writeln!( + out, + "Automatic remittance after each collected payment: {}", + if auto_remit { + "ON ([platform_fee] auto_remit = true, the default)" + } else { + "OFF ([platform_fee] auto_remit = false) — the fee still accrues and is owed; this command pays it" + } + ); + let trigger = if confirm { + RemitTrigger::Command + } else { + RemitTrigger::DryRun + }; + let outcome = remit(&store, &mut effects, trigger, now_unix, out)?; + Ok(exit_code_for(&outcome)) +} + +/// The exit code for one run's outcome. Nonzero for everything that did not pay or print a clean +/// plan — including a HELD spending row (addendum 6 §1.3): reconciliation that finds the in-flight +/// row bound to a quote the mint has not reported PAID refuses the run, prints the one `HELD:` line +/// naming the row, the quote, the mint's answer and the held receipts, and exits [`REFUSED`] — a +/// dry run included — so a stuck fee is visible to an operator and to anything scripting this +/// command. No flag clears it; that is an operator's decision, owed as later work. +#[cfg(feature = "wallet")] +fn exit_code_for(outcome: &maxplayer_core::fee_remit::RemitOutcome) -> i32 { + use maxplayer_core::fee_remit::RemitOutcome; + match outcome { + RemitOutcome::DryRun | RemitOutcome::Paid { .. } => SUCCESS, + RemitOutcome::Refused(_) => REFUSED, + // A melt refused at the ceiling (addendum 3 §1) spent nothing and needs no operator action + // beyond a later retry: it exits like any other refusal, not like a failed payment. + RemitOutcome::MeltRefused { .. } => REFUSED, + // The payment quote could not be raised (addendum 5 §1): nothing spent, the planned row + // released — a failed effect the operator should see, like a failed payment. + RemitOutcome::QuoteFailed { .. } => RUNTIME_ERROR, + RemitOutcome::MeltFailed { .. } => RUNTIME_ERROR, + } +} + +#[cfg(test)] +mod parse_tests { + use super::*; + + #[test] + fn parse_command_reads_ledger_and_remit_forms_and_refuses_contradictions() { + let strings = |items: &[&str]| items.iter().map(|s| (*s).to_owned()).collect::>(); + assert_eq!( + parse_command(&[]).expect("empty"), + Command::Ledger { home: None } + ); + assert_eq!( + parse_command(&strings(&["--home", "/x"])).expect("home"), + Command::Ledger { + home: Some(PathBuf::from("/x")) + } + ); + assert_eq!( + parse_command(&strings(&["remit"])).expect("remit"), + Command::Remit { + home: None, + confirm: false + }, + "dry run is the default" + ); + assert_eq!( + parse_command(&strings(&["remit", "--dry-run", "--home", "/x"])).expect("remit dry"), + Command::Remit { + home: Some(PathBuf::from("/x")), + confirm: false + } + ); + assert_eq!( + parse_command(&strings(&["remit", "--home", "/x", "--confirm"])) + .expect("remit confirm"), + Command::Remit { + home: Some(PathBuf::from("/x")), + confirm: true + } + ); + assert!(parse_command(&strings(&["--home"])).is_err()); + assert!(parse_command(&strings(&["--rate-sats"])).is_err()); + assert!( + parse_command(&strings(&["--confirm"])).is_err(), + "--confirm without remit is not a ledger option" + ); + assert!(parse_command(&strings(&["remit", "--confirm", "--dry-run"])).is_err()); + assert!(parse_command(&strings(&["remit", "--yes"])).is_err()); + assert!( + parse_command(&strings(&["remitt"])).is_err(), + "a typo is not the ledger" + ); + } +} #[cfg(all(test, feature = "wallet"))] mod tests { use super::*; - use maxplayer_core::seller_node::store::{AccruedFees, JobFeeAccrual}; + use maxplayer_core::seller_node::store::{ + AccruedFees, FeeRemittance, JobFeeAccrual, ReceiptFees, RemittanceState, SellerStore, + SettledBy, + }; fn row( job: &str, @@ -247,6 +518,7 @@ mod tests { fee_bps, fee_sats, received_at_unix: 1, + remittance_id: None, } } @@ -259,17 +531,20 @@ mod tests { total_mint_fee_sats: 1, rows_without_mint_fee: 0, total_fee_sats: 10, + unremitted_fee_sats: 10, by_job: vec![row("job-a", 100, Some(1), 1000, 10)], + ..AccruedFees::default() }; - let text = render(&accrued, "/tmp/x/seller.sqlite"); + let text = render(&accrued, &[], "/tmp/x/seller.sqlite"); for needle in [ "what the buyer paid: 100 sats", "mint fee: 1 sats", - "platform fee (10%): 10 sats", + "platform fee (10%): 10 sats — unremitted", "you keep: 89 sats", - "recorded, not paid out", + "platform fee: 10 sats accrued — 0 sats remitted, 10 sats unremitted\n", // Round 3: the total carries its rate, as the usage text promises. " at 10%: 10 sats on 100 sats paid, 1 job\n", + "Remittances:\n none yet", ] { assert!(text.contains(needle), "missing {needle:?} in:\n{text}"); } @@ -311,15 +586,18 @@ mod tests { total_mint_fee_sats: 3, rows_without_mint_fee: 0, total_fee_sats: 15, + unremitted_fee_sats: 15, by_job, + ..AccruedFees::default() }; - let text = render(&accrued, "db"); + let text = render(&accrued, &[], "db"); let totals = text .split_once("Totals:\n") .map(|(_, totals)| totals) .expect("a totals block"); assert!( - totals.contains(" platform fee: 15 sats — recorded, not paid out"), + totals + .contains(" platform fee: 15 sats accrued — 0 sats remitted, 15 sats unremitted"), "{text}" ); assert!( @@ -346,12 +624,14 @@ mod tests { total_mint_fee_sats: 1, rows_without_mint_fee: 1, total_fee_sats: 10, + unremitted_fee_sats: 10, by_job: vec![ row("old-job", 21, None, 0, 0), row("new-job", 100, Some(1), 1000, 10), ], + ..AccruedFees::default() }; - let text = render(&accrued, "db"); + let text = render(&accrued, &[], "db"); assert!(text.contains("mint fee: not recorded"), "{text}"); assert!( text.contains("you keep: unknown (mint fee not recorded)"), @@ -381,21 +661,145 @@ mod tests { ); } + // Stage 2a: a discharged row names its remittance, the totals split remitted from unremitted, + // and the remittance list prints every journaled figure — gross, melt fee, net, destination + // literal, payment hash, state — so the seller can reconcile. #[test] - fn render_with_no_rows_says_so() { - let text = render(&AccruedFees::default(), "db"); - assert!(text.contains("No collected payments yet."), "{text}"); + fn render_names_the_remittance_on_discharged_rows_and_lists_remittances() { + let mut discharged = row("job-a", 100, Some(1), 1000, 10); + discharged.remittance_id = Some("abc123".to_owned()); + let accrued = AccruedFees { + total_amount_sats: 150, + total_mint_fee_sats: 2, + rows_without_mint_fee: 0, + total_fee_sats: 15, + unremitted_fee_sats: 5, + remitted_fee_sats: 10, + in_flight_fee_sats: 0, + by_job: vec![discharged, row("job-b", 50, Some(1), 1000, 5)], + }; + let remittances = vec![ + FeeRemittance { + remittance_id: "old".to_owned(), + gross_sats: 7, + melt_fee_sats: None, + melt_fee_reserve_sats: Some(1), + net_sats: 6, + destination: "maxplayer@agi.cash".to_owned(), + melt_quote_id: None, + payment_hash: "old".to_owned(), + bolt11: "ln-old".to_owned(), + state: RemittanceState::Failed, + created_at_unix: 5, + settled_at_unix: Some(6), + settled_by: None, + owner: Some("pid1-a".to_owned()), + lease_until_unix: Some(305), + spending_since_unix: None, + spending_quote_id: None, + receipts: 0, + }, + FeeRemittance { + remittance_id: "abc123".to_owned(), + gross_sats: 10, + melt_fee_sats: Some(1), + melt_fee_reserve_sats: Some(2), + net_sats: 9, + destination: "maxplayer@agi.cash".to_owned(), + melt_quote_id: Some("q".to_owned()), + payment_hash: "abc123".to_owned(), + bolt11: "ln-abc".to_owned(), + state: RemittanceState::Settled, + created_at_unix: 7, + settled_at_unix: Some(8), + settled_by: Some(SettledBy::Melt), + owner: Some("pid1-a".to_owned()), + lease_until_unix: Some(307), + spending_since_unix: None, + spending_quote_id: None, + receipts: 1, + }, + // Settled by reconciliation: the fee is unobserved and the row SAYS why, with the + // paying quote's reserve as the ceiling on it. + FeeRemittance { + remittance_id: "rec".to_owned(), + gross_sats: 20, + melt_fee_sats: None, + melt_fee_reserve_sats: Some(3), + net_sats: 17, + destination: "maxplayer@agi.cash".to_owned(), + melt_quote_id: Some("q-rec".to_owned()), + payment_hash: "rec".to_owned(), + bolt11: "ln-rec".to_owned(), + state: RemittanceState::Settled, + created_at_unix: 9, + settled_at_unix: Some(10), + settled_by: Some(SettledBy::Reconciliation), + owner: Some("pid2-b".to_owned()), + lease_until_unix: Some(309), + spending_since_unix: None, + spending_quote_id: None, + receipts: 2, + }, + // Addendum 4 §1: a SPENDING row (melt admitted, mint not yet heard) is named as such, + // never as planned — the operator must know it will not be released on time. + FeeRemittance { + remittance_id: "mid".to_owned(), + gross_sats: 4, + melt_fee_sats: None, + melt_fee_reserve_sats: Some(1), + net_sats: 3, + destination: "maxplayer@agi.cash".to_owned(), + melt_quote_id: Some("q-mid".to_owned()), + payment_hash: "mid".to_owned(), + bolt11: "ln-mid".to_owned(), + state: RemittanceState::Spending, + created_at_unix: 11, + settled_at_unix: None, + settled_by: None, + owner: Some("pid3-c".to_owned()), + lease_until_unix: Some(311), + spending_since_unix: Some(12), + spending_quote_id: Some("q-mid-pay".to_owned()), + receipts: 1, + }, + ]; + let text = render(&accrued, &remittances, "db"); + for needle in [ + "platform fee (10%): 10 sats — remittance abc123\n", + "platform fee (10%): 5 sats — unremitted\n", + " platform fee: 15 sats accrued — 10 sats remitted, 5 sats unremitted\n", + "Remittances:\n", + " failed (no sats left; receipts released): 6 sats to maxplayer@agi.cash — gross 7 sats, melt fee not observed, invoice old, 0 receipts, planned at unix 5, resolved at unix 6\n", + " settled: 9 sats to maxplayer@agi.cash — gross 10 sats, melt fee 1 sats, invoice abc123, 1 receipt, planned at unix 7, resolved at unix 8\n", + " settled: 17 sats to maxplayer@agi.cash — gross 20 sats, melt fee not observed (settled by reconciliation against the mint, which reports the quote paid but not the fee it kept; at most 3 sats, the quote's reserve), invoice rec, 2 receipts, planned at unix 9, resolved at unix 10\n", + " SPENDING (melt admitted, bound to melt quote q-mid-pay; resolved only by the mint's verdict on that quote — re-run remit to reconcile): 3 sats to maxplayer@agi.cash — gross 4 sats, melt fee not observed, invoice mid, 1 receipt, planned at unix 11\n", + ] { + assert!(text.contains(needle), "missing {needle:?} in:\n{text}"); + } + assert!(!text.contains("none yet"), "{text}"); + // An in-flight figure is named when present. + let in_flight = AccruedFees { + in_flight_fee_sats: 5, + unremitted_fee_sats: 0, + ..accrued + }; + let text = render(&in_flight, &remittances, "db"); + assert!( + text.contains("0 sats unremitted, 5 sats in flight (a remittance is settling)"), + "{text}" + ); } - // End to end through `run`: a real seller store at `--home`, one collected job (face 100, mint - // fee 1, platform fee 10% = 10), printed with the four labels and `you keep: 89 sats`. #[test] - fn run_reads_a_real_store_at_home_and_prints_the_ledger() { - use maxplayer_core::seller_node::STATE_DB_FILE; - use maxplayer_core::seller_node::store::{ReceiptFees, SellerStore}; + fn render_with_no_rows_says_so() { + let text = render(&AccruedFees::default(), &[], "db"); + assert!(text.contains("No collected payments yet."), "{text}"); + } + fn temp_home(label: &str) -> PathBuf { let root = std::env::temp_dir().join(format!( - "maxplayer-seller-fees-{}-{}", + "maxplayer-seller-fees-{label}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -403,6 +807,16 @@ mod tests { .as_nanos() )); std::fs::create_dir_all(&root).expect("temp home"); + root + } + + // End to end through `run`: a real seller store at `--home`, one collected job (face 100, mint + // fee 1, platform fee 10% = 10), printed with the four labels and `you keep: 89 sats`. + #[test] + fn run_reads_a_real_store_at_home_and_prints_the_ledger() { + use maxplayer_core::seller_node::STATE_DB_FILE; + + let root = temp_home("ledger"); { let store = SellerStore::open(root.join(STATE_DB_FILE)).expect("open store"); store @@ -434,54 +848,52 @@ mod tests { "job job-1", "what the buyer paid: 100 sats", "mint fee: 1 sats", - "platform fee (10%): 10 sats", + "platform fee (10%): 10 sats — unremitted", "you keep: 89 sats", - "platform fee: 10 sats — recorded, not paid out", + "platform fee: 10 sats accrued — 0 sats remitted, 10 sats unremitted", " at 10%: 10 sats on 100 sats paid, 1 job\n", + "Remittances:\n none yet", ] { assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); } - // A home with no store is a clear error, not a crash and not an empty ledger. + // A home with no store is a clear error, not a crash and not an empty ledger — for both + // forms of the command. let empty = root.join("nothing-here"); std::fs::create_dir_all(&empty).expect("empty home"); - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = run( - &["--home".to_owned(), empty.display().to_string()], - &mut out, - &mut err, - ); - assert_eq!(code, RUNTIME_ERROR); - assert!( - String::from_utf8_lossy(&err).contains("no seller store at"), - "{}", - String::from_utf8_lossy(&err) - ); + for args in [ + vec!["--home".to_owned(), empty.display().to_string()], + vec![ + "remit".to_owned(), + "--home".to_owned(), + empty.display().to_string(), + ], + ] { + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run(&args, &mut out, &mut err); + assert_eq!(code, RUNTIME_ERROR); + assert!( + String::from_utf8_lossy(&err).contains("no seller store at"), + "{}", + String::from_utf8_lossy(&err) + ); + } let _ = std::fs::remove_dir_all(&root); } // Round 3 — the case the three partial tests left uncovered: a REAL store written at schema v8 // (fee columns present, no `mint_fee_sats` column) holding a receipt collected under that - // schema; `maxplayer seller fees` opens it (the additive migration to v9 runs), and prints THAT - // row through the seller read-out with its mint fee "not recorded" and "you keep" unknown — - // never a measured 0, never a kept figure. The store is then re-read off disk at v9 to prove - // the row printed was the migrated one, and a row collected on the migrated store prints beside - // it with both figures known. + // schema; `maxplayer seller fees` opens it (the additive migration runs), and prints THAT row + // through the seller read-out with its mint fee "not recorded" and "you keep" unknown — never a + // measured 0, never a kept figure. The store is then re-read off disk to prove the row printed + // was the migrated one, and a row collected on the migrated store prints beside it with both + // figures known. #[test] fn run_prints_a_genuinely_migrated_v8_row_as_not_recorded_rather_than_zero() { use maxplayer_core::seller_node::STATE_DB_FILE; - use maxplayer_core::seller_node::store::{ReceiptFees, SellerStore}; - let root = std::env::temp_dir().join(format!( - "maxplayer-seller-fees-v8-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - std::fs::create_dir_all(&root).expect("temp home"); + let root = temp_home("v8"); let db = root.join(STATE_DB_FILE); { // The v8 shape verbatim: `receipts` with the platform-fee columns and WITHOUT @@ -529,10 +941,10 @@ mod tests { "job v8-job", "what the buyer paid: 100 sats", "mint fee: not recorded (collected before this version tracked it)", - "platform fee (10%): 10 sats", + "platform fee (10%): 10 sats — unremitted", "you keep: unknown (mint fee not recorded)", "mint fees: 0 sats across the jobs that recorded one, plus 1 job whose mint fee was not recorded", - "platform fee: 10 sats — recorded, not paid out", + "platform fee: 10 sats accrued — 0 sats remitted, 10 sats unremitted", " at 10%: 10 sats on 100 sats paid, 1 job\n", "you keep: unknown (no job recorded its mint fee)", ] { @@ -547,8 +959,8 @@ mod tests { "no kept figure may be derived without the mint fee:\n{out}" ); - // The row that printed was the migrated one: the file now reads schema 9 with the - // `mint_fee_sats` column present and NULL on the v8 row. + // The row that printed was the migrated one: the file now reads the current schema with the + // `mint_fee_sats` column present and NULL on the v8 row, and `remittance_id` NULL too. { let conn = rusqlite::Connection::open(&db).expect("reopen migrated store"); let version: String = conn @@ -558,15 +970,20 @@ mod tests { |row| row.get(0), ) .expect("schema_version"); - assert_eq!(version, "9", "the open migrated the v8 file to v9"); - let mint_fee: Option = conn + assert_eq!( + version, + maxplayer_core::seller_node::store::SCHEMA_VERSION.to_string(), + "the open migrated the v8 file to the current schema" + ); + let (mint_fee, remittance): (Option, Option) = conn .query_row( - "SELECT mint_fee_sats FROM receipts WHERE receipt_id = 'v8-receipt'", + "SELECT mint_fee_sats, remittance_id FROM receipts WHERE receipt_id = 'v8-receipt'", [], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) - .expect("migrated column readable"); + .expect("migrated columns readable"); assert_eq!(mint_fee, None, "the v8 row's mint fee is NULL, not 0"); + assert_eq!(remittance, None, "the v8 row is unremitted"); } // A collection on the migrated store records its mint fee and prints beside the old row @@ -598,10 +1015,10 @@ mod tests { assert_eq!(code, SUCCESS, "stderr={}", String::from_utf8_lossy(&err)); for needle in [ "2 collected jobs, oldest first:", - "job v8-job\n what the buyer paid: 100 sats\n mint fee: not recorded (collected before this version tracked it)\n platform fee (10%): 10 sats\n you keep: unknown (mint fee not recorded)\n", - "job v9-job\n what the buyer paid: 100 sats\n mint fee: 1 sats\n platform fee (10%): 10 sats\n you keep: 89 sats\n", + "job v8-job\n what the buyer paid: 100 sats\n mint fee: not recorded (collected before this version tracked it)\n platform fee (10%): 10 sats — unremitted\n you keep: unknown (mint fee not recorded)\n", + "job v9-job\n what the buyer paid: 100 sats\n mint fee: 1 sats\n platform fee (10%): 10 sats — unremitted\n you keep: 89 sats\n", "mint fees: 1 sats across the jobs that recorded one, plus 1 job whose mint fee was not recorded", - "platform fee: 20 sats — recorded, not paid out", + "platform fee: 20 sats accrued — 0 sats remitted, 20 sats unremitted", " at 10%: 20 sats on 200 sats paid, 2 jobs\n", "you keep: 89 sats across the jobs that recorded a mint fee; the rest is unknown", ] { @@ -610,14 +1027,261 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + // Addendum 6 §1.3: a HELD spending row (bound quote not PAID at the mint) exits the command + // nonzero — REFUSED, the same code as every other refusal — on the dry run and on --confirm + // alike, so the stuck fee is visible to an operator and to anything scripting `remit`. This + // test pins the exit-code MAPPING on the outcomes the core returns and the shape of the core's + // `HELD:` line (`fee_remit::Refusal::SpendingHeld`); the command itself — dry run and + // `--confirm` through `run`, complete output, exact exit — is invoked by + // `a_held_spending_row_prints_one_held_line_and_exits_refused_on_dry_run_and_confirm` below. + // Failed effects stay distinct (RUNTIME_ERROR): a hold is a refusal that moved nothing, not a + // broken payment. + #[test] + fn a_held_spending_row_exits_refused_on_dry_run_and_confirm() { + use maxplayer_core::fee_remit::{Refusal, RemitOutcome}; + let held = RemitOutcome::Refused(Refusal::SpendingHeld { + remittance_id: "hash-held".to_owned(), + owner: "old-run".to_owned(), + spending_since_unix: 100, + quote_id: Some("paid-quote-held".to_owned()), + observed: "mint https://mint.example reports melt quote paid-quote-held UNPAID (expiry unix 200)".to_owned(), + held_sats: 15, + }); + assert_eq!(exit_code_for(&held), REFUSED); + assert_ne!( + exit_code_for(&held), + SUCCESS, + "a dry run that finds a held row is not clean" + ); + let line = match &held { + RemitOutcome::Refused(refusal) => refusal.to_string(), + _ => unreachable!(), + }; + assert_eq!(line.lines().count(), 1, "one line, not a paragraph: {line}"); + for needle in [ + "HELD: remittance hash-held is SPENDING (admitted by old-run at unix 100)", + "bound to melt quote paid-quote-held", + "reports melt quote paid-quote-held UNPAID (expiry unix 200)", + "15 sats of receipts stay pinned to it", + "an operator decision, not a timeout, resolves it", + ] { + assert!(line.contains(needle), "missing {needle:?} in {line}"); + } + assert_eq!( + exit_code_for(&RemitOutcome::Refused(Refusal::Settling { + remittance_id: "hash-held".to_owned(), + })), + REFUSED, + "PENDING / UNKNOWN on the bound quote is a hold too" + ); + assert_eq!( + exit_code_for(&RemitOutcome::MeltFailed { + remittance_id: "hash-held".to_owned(), + error: "mint unreachable".to_owned(), + }), + RUNTIME_ERROR + ); + assert_eq!(exit_code_for(&RemitOutcome::DryRun), SUCCESS); + } + + // §4 gate 1 in code: the live path builds its effects on the packaged wallet through + // `wallet_ops::melt_quote_blocking` + `prepare_melt_payment_blocking` (the retained + // `pay_melt_quote_blocking` is not called) — but `run remit` on a store with NOTHING unremitted returns before + // any network or wallet call, so this exercises the real CLI entry point offline, including the + // line that tells the operator whether the automatic remittance is on. + #[test] + fn run_remit_on_an_empty_store_refuses_before_any_network_or_wallet_call() { + use maxplayer_core::seller_node::STATE_DB_FILE; + let root = temp_home("run-remit-empty"); + drop(SellerStore::open(root.join(STATE_DB_FILE)).expect("open store")); + for args in [ + vec![ + "remit".to_owned(), + "--home".to_owned(), + root.display().to_string(), + ], + vec![ + "remit".to_owned(), + "--confirm".to_owned(), + "--home".to_owned(), + root.display().to_string(), + ], + ] { + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run(&args, &mut out, &mut err); + let out = String::from_utf8(out).expect("utf8"); + assert_eq!( + code, + REFUSED, + "stderr={} stdout={out}", + String::from_utf8_lossy(&err) + ); + assert!(out.contains("Platform fee remittance — "), "{out}"); + assert!( + out.contains("Automatic remittance after each collected payment: ON ([platform_fee] auto_remit = true, the default)"), + "{out}" + ); + assert!(out.contains("Recent attempts: none journaled yet"), "{out}"); + assert!(out.contains("0 sats unremitted"), "{out}"); + assert!( + out.contains("Nothing to remit. REFUSED — nothing moved."), + "{out}" + ); + } + // `--confirm --dry-run` is a usage error, before the store is even opened. + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run( + &[ + "remit".to_owned(), + "--confirm".to_owned(), + "--dry-run".to_owned(), + ], + &mut out, + &mut err, + ); + assert_eq!(code, USAGE_ERROR); + assert!(String::from_utf8_lossy(&err).contains("contradict")); + let _ = std::fs::remove_dir_all(&root); + } + + // Addendum 7 §2.3 (addendum 6 §1.3): the REAL command, dry run and `--confirm`, on a store whose + // in-flight row is SPENDING and bound to a melt quote — through `run`, the shipped `LiveEffects` + // and the packaged wallet at this home. The bound quote id is one this wallet never raised, so + // the wallet's local lookup answers "no such quote" without a network call (the mint is asked + // only about a quote the wallet knows), and reconciliation HOLDS on that observation: the + // complete output carries exactly one `HELD:` line naming the row, the bound quote, the answer + // and the held sats, no plan and no payment, and the exit is REFUSED on both runs. The other + // four non-PAID answers (UNPAID, FAILED, PENDING, UNKNOWN) render the same line on the core's + // full path against the fake mint (`fee_remit::tests`, gate (d)); a real mint cannot be made to + // say PENDING offline. #[test] - fn parse_home_accepts_only_home() { - assert_eq!(parse_home(&[]).expect("empty"), None); + fn a_held_spending_row_prints_one_held_line_and_exits_refused_on_dry_run_and_confirm() { + use maxplayer_core::seller_node::STATE_DB_FILE; + use maxplayer_core::seller_node::store::RemittancePlan; + let root = temp_home("run-remit-held"); + { + let store = SellerStore::open(root.join(STATE_DB_FILE)).expect("open store"); + for (index, fee) in [10u64, 5].into_iter().enumerate() { + store + .collect_receipt( + &format!("receipt-{index}"), + &format!("job-{index}"), + fee * 10, + ReceiptFees { + mint_fee_sats: 1, + fee_bps: 1000, + fee_sats: fee, + }, + index as i64 + 1, + ) + .expect("collect"); + } + let planned = store + .plan_remittance( + &RemittancePlan { + payment_hash: "hash-held".to_owned(), + gross_sats: 15, + net_sats: 13, + melt_fee_reserve_sats: 2, + destination: "maxplayer@agi.cash".to_owned(), + bolt11: "lnbc-held".to_owned(), + melt_quote_id: None, + }, + "old-run", + i64::MAX / 2, + 100, + ) + .expect("plan"); + assert_eq!(planned.state, RemittanceState::Planned); + let mut clock = || 100; + let admitted = store + .admit_remittance_spend( + "hash-held", + "old-run", + "paid-quote-never-raised", + 60, + &mut clock, + ) + .expect("store") + .expect("admitted"); + assert_eq!(admitted.state, RemittanceState::Spending); + assert_eq!( + admitted.spending_quote_id.as_deref(), + Some("paid-quote-never-raised") + ); + } + for (args, label) in [ + ( + vec![ + "remit".to_owned(), + "--home".to_owned(), + root.display().to_string(), + ], + "dry run", + ), + ( + vec![ + "remit".to_owned(), + "--confirm".to_owned(), + "--home".to_owned(), + root.display().to_string(), + ], + "--confirm", + ), + ] { + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run(&args, &mut out, &mut err); + let out = String::from_utf8(out).expect("utf8"); + let err = String::from_utf8_lossy(&err); + assert_eq!(code, REFUSED, "[{label}] stderr={err} stdout={out}"); + assert_eq!(err, "", "[{label}] nothing on stderr"); + // The ENTIRE stdout, by equality (addendum 8 §2.1). The only field that varies between + // hosts is this home's store path; every other figure is fixed by the fixture (the + // planned/admitted clock is 100, the lease is the constant i64::MAX / 2). Both runs print + // the same text: the dry run journals nothing, and the `--confirm` run journals its hold + // AFTER printing, so "Recent attempts" is empty on both. No plan, no dry-run offer, no + // journal line, no payment — by construction of the expected text, not by a denylist. + let expected = format!( + "Platform fee remittance — {store}\n\ + Automatic remittance after each collected payment: ON ([platform_fee] auto_remit = true, the default)\n\ + Recent attempts: none journaled yet\n\ + Reconciling in-flight remittance hash-held (planned at unix 100 by old-run, lease until unix {lease}: 13 sats to maxplayer@agi.cash, gross 15 sats) — SPENDING since unix 100, bound to melt quote paid-quote-never-raised: asking the mint about that quote by id\n\ + \x20 HELD: remittance hash-held is SPENDING (admitted by old-run at unix 100), bound to melt quote paid-quote-never-raised; this wallet holds no such melt quote; 15 sats of receipts stay pinned to it — a spending row is released by nobody and on no clock; it settles only when the mint reports that quote PAID; an operator decision, not a timeout, resolves it. REFUSED — nothing moved by this run; re-run later to reconcile.\n", + store = root.join(STATE_DB_FILE).display(), + lease = i64::MAX / 2, + ); + assert_eq!(out, expected, "[{label}] the complete stdout, by equality"); + } + let store = SellerStore::open(root.join(STATE_DB_FILE)).expect("reopen"); + let row = store + .in_flight_remittance() + .expect("query") + .expect("still in flight"); assert_eq!( - parse_home(&["--home".to_owned(), "/x".to_owned()]).expect("home"), - Some(PathBuf::from("/x")) + (row.state, row.spending_quote_id.as_deref()), + (RemittanceState::Spending, Some("paid-quote-never-raised")), + "held: nothing written by either run" ); - assert!(parse_home(&["--home".to_owned()]).is_err()); - assert!(parse_home(&["--rate-sats".to_owned()]).is_err()); + let accrued = store.accrued_fees().expect("read"); + assert_eq!( + ( + accrued.remitted_fee_sats, + accrued.in_flight_fee_sats, + accrued.unremitted_fee_sats + ), + (0, 15, 0), + "the receipts stay pinned to the held row" + ); + let attempts = store.recent_remit_attempts(10).expect("attempts"); + assert_eq!( + attempts.len(), + 1, + "the --confirm hold is journaled; the dry run journals nothing: {attempts:?}" + ); + assert_eq!(attempts[0].remittance_id.as_deref(), Some("hash-held")); + let _ = std::fs::remove_dir_all(&root); } } diff --git a/docs/SELLER-QUICKSTART.md b/docs/SELLER-QUICKSTART.md index 4be067266..a07754baa 100644 --- a/docs/SELLER-QUICKSTART.md +++ b/docs/SELLER-QUICKSTART.md @@ -1200,13 +1200,14 @@ On a typical keyset the fee is **1 sat** for small amounts: - **The setup default is `100`, and that is the number to start from.** Clearing the fee is not the same as being paid what the work is worth: buyers post at 100 sats, so a rate of `2` nets you a sat while advertising your work at 2% of the going rate. Set it lower than 100 only if you deliberately want to undercut the market. - The **receipt / journal records the FACE (offer) amount**, not your wallet net. The face is the accounting figure; the **sats you receive are `face − fee`**. Do not read the receipt's face number as "sats pocketed." -### Platform fee (stage 1: recorded, not paid) +### Platform fee (recorded at collect; paid automatically by your node) The product charges a platform fee on each payment you collect. **The rate is set by the product, in the binary, and you cannot change it** — there is no config key, no environment variable and no -flag for it. **The rate is 10%.** This stage only records what that comes to: **there is no way to -pay it yet**, because no payout destination exists in the product, so the figure in your journal is -not a bill that is due. +flag for it. **The rate is 10%.** Collection records what that comes to, and **your node then pays it +automatically**: once a payment has landed and its receipt is journaled, the node makes a best-effort +attempt to remit the whole unremitted balance to the platform's Lightning address. You do not have to +remember to pay; there is nothing to run. **The fee is 10% of the offer amount — the price the buyer paid — not of what lands in your wallet.** The mint's own input fee (described above) is a separate deduction and does not shrink the @@ -1222,17 +1223,150 @@ maxplayer seller fees [--home ] ``` and each collected job prints four figures with plain labels: **what the buyer paid** (the offer -amount), **mint fee**, **platform fee (10%)**, and **you keep** (`paid − mint fee − platform fee`), -plus the totals. The `seller node collect ok` log line carries the same figures -(`amount_received=` is what the buyer paid, then `mint_fee=`, `fee_sats=`, `fee_bps=`, `kept=`). -Jobs collected before the mint fee was recorded print `mint fee: not recorded` and no "you keep" -figure, rather than a made-up zero. - -**This stage records the fee and pays nobody.** No sats leave your wallet on account of it: there is -no fee recipient in this version, and no payout, transfer or remittance of the recorded amount exists -anywhere in the binary. A 100-sat offer with a 1-sat mint fee, for example, records -`amount_sats = 100, mint_fee_sats = 1, fee_bps = 1000, fee_sats = 10`, prints `you keep: 89 sats`, -and moves nothing. +amount), **mint fee**, **platform fee (10%)** with whether it is unremitted or which remittance paid +it, and **you keep** (`paid − mint fee − platform fee`), plus the totals — the platform fee split +into remitted and unremitted — and every remittance so far. The `seller node collect ok` log line +carries the same figures (`amount_received=` is what the buyer paid, then `mint_fee=`, `fee_sats=`, +`fee_bps=`, `kept=`). Jobs collected before the mint fee was recorded print `mint fee: not recorded` +and no "you keep" figure, rather than a made-up zero. + +**How the automatic remittance behaves.** A 100-sat offer with a 1-sat mint fee, for example, records +`amount_sats = 100, mint_fee_sats = 1, fee_bps = 1000, fee_sats = 10`, prints `you keep: 89 sats`, and +the 10 sats are now **unremitted** platform fee. Right after that receipt is written — and only after +a *new* receipt, never on a replayed payment — the node starts one remittance attempt on a thread of +its own: + +- It resolves the platform's Lightning address over LNURL-pay (the address is fixed in the binary, + not configurable — a seller-editable address would let a seller pay the fee to itself), reads the + destination's minimum, and **if the unremitted balance is below that minimum it does nothing**: a + 10% fee on payments under 10 sats owes under 1 sat, and small balances simply accumulate until they + clear the minimum. That is the expected steady state for small jobs, not an error. +- Otherwise it takes a melt quote from your default mint and invoices **the largest amount the + wallet can actually pay for at most the accrued fee**: the mint's melt fee reserve and the proof + fees come out of it (a mint that charges per input proof, or one whose proofs do not fit the + amount and need a swap first). The wallet SDK (CDK) *estimates* the proof fee before paying and + *recomputes* it on the proofs its swap hands back, refusing after the swap if they fall short — so + the planner runs that recomputation itself and picks an amount that survives it (the plan prints + both figures: `expected proof fees (SDK estimate, bounded exactly at payment): N sats; actual + proof input fee the SDK recomputes on the swapped proofs: M sats ⇒ worst case W sats leaves the + wallet (≤ G)`). **You never pay more than the fee you accrued; every fee comes out of that amount, + not on top of it.** It then journals + the attempt in `seller.sqlite` (`fee_remittances`: gross, melt fee, net, the address literal, + melt quote id, payment hash, state, which process owns the attempt, and when it was admitted to + spend), pays the invoice from your ecash through the wallet's gated melt (the same + `allow_real_mints` gate `maxplayer wallet melt` honours), and marks the receipts it covered as + discharged. **The accrued fee is a hard ceiling on everything that leaves the wallet, enforced + before any ecash is spent**, in one sequence: first the payment quote's **invoice plus the mint's + fee reserve alone** is checked against the gross (a reserve that grew past it is refused here); if + the live reserve differs from the estimate and the planned amount would no longer confirm, the + attempt re-plans once onto a new invoice; then the wallet prepares the payment locally — reserving + proofs; it may fetch the mint's fee table, but sends no proofs and pays no fee yet — and the node + re-runs the SDK's post-swap arithmetic on the prepared figures: the bound is the **actual** proof + input fee the SDK recomputes on the proofs its swap hands back, so **the invoice plus the reserve + plus that actual input fee plus any pre-melt swap fee** must fit the gross, and the payment is + refused if the SDK would fail after its swap. The SDK's *prepared* fee display is not the bound: a + prepared total over the gross whose actual debit fits is paid. On any of these refusals the + prepared payment (if any) is cancelled — the proofs go back to unspent — before any ecash is + consumed; the journaled row is left as it was, still planned with its receipts pinned, the refusal + is journaled, and the next attempt's reconciliation releases that row (it was this process's own + earlier attempt) and re-quotes. After a payment the report counts the SDK's fee once — `melt fee taken by + the mint` already includes the actual proof input fee — and prints `actual debit: D sats = net + + melt fee + swap fee`; if the wallet cannot read its balance afterwards it prints `wallet balance + now: unknown (…)` rather than a guessed number. **Known bound:** the mint can change its fee + table between the preparation and the payment and the SDK accepts no caller maximum; if that + happens the payment can fail after its swap (the swap fee is lost and the row stays held until + the mint says PAID or an operator decides) or cost more than predicted (a `WARNING` line). Not + observed on any mint; disclosed, not solved. Also owed: if cancelling a prepared payment itself + fails, a local proof reservation can remain — reopening the wallet does not clear it — until a + supported recovery path exists. (The operator's plain `maxplayer wallet melt` keeps its older + check — invoice plus reserve only — and is not changed by this.) +- **It cannot affect the payment it followed.** Your receipt is written and the job is marked paid + before the attempt starts. If the attempt fails — the mint is down, the payout host is unreachable, + the wallet is short, no route — the failure is written to the node log and journaled + (`fee_remit_attempts`), and the balance stays unremitted. **The node then retries on its own clock, + for as long as it runs**: after a failure the next attempt comes within a minute, and the ceiling + on the wait then doubles — 2, 4, 8, 16 minutes — up to once every 30 minutes, each delay + randomised between zero and that ceiling so a fleet of sellers does not hit a recovering host in + the same second. A success resets the clock to its 30-second base; a balance under the + destination's minimum is not a failure and does not lengthen it. Nothing runs at startup: the + first check comes between 30 and 60 seconds after boot, and a payment collected in those first + seconds cannot pull it earlier — the 30-second floor holds. The retries live inside the node's + main loop, so stopping the node stops them from being scheduled — and a payment already in flight + when you stop the node is **drained, not cut off**: once serving has ended the node waits up to 60 + seconds for the attempt to finish (ecash that may already be with the mint is never abandoned + mid-payment), and if that wait runs out it logs one incident line and exits while the attempt + finishes on its own. The next collected payment also tries again with the whole accumulated + balance. An attempt interrupted mid-payment is reconciled with the mint on the next attempt: + settled if the mint reports the payment landed. An attempt that never reached the point of + spending is released if its quote failed or expired, or if the process that owned it is provably + gone. An attempt that DID reach the point of spending — the store admitted it and bound the one + mint quote it may pay — is **held**, however long, on anything but the mint saying PAID: not + released on "expired", not on "failed", not on any clock. We do not infer that a payment is dead + from a clock, because the mint pays a quote it calls unpaid or failed regardless of its expiry, + and a payment prepared before the expiry can land after it; releasing on either would let the + same balance be paid twice. Two processes sharing one store — the node and a hand-run + `maxplayer seller fees remit --confirm`, say — pay an accrued balance once, not twice: at most one + remittance can be in flight (a second cannot even be planned while one exists), the receipts it + covers are pinned to it, only the process that planned it may pay it, it passes a single + compare-and-set in the store immediately before spending which also fixes the one mint quote it + may pay, and it never pays under any other quote; a second process asks the mint about that exact + quote and holds off unless it is PAID, and every release is written as a condition on the row, so + a release decided on a stale reading changes nothing. A held attempt is visible, not silent: + `maxplayer seller fees remit` prints one `HELD:` line naming the row, the quote, what the mint said + (unpaid, failed, pending, unknown, or a quote the wallet does not know — the same one line for each) + and how many sats are pinned, and exits 3 — on the dry run too. Clearing it is an operator's + decision, and there is no command for it yet; until then the node's later attempts are refused and + the balance accumulates unremitted behind the held row. This is what the module's two-process tests + prove, and its bound: two processes, each on its own connection to one store, against one fake mint + that accepts unpaid or failed quotes regardless of expiry as the inspected CDK 0.17.2 mint + implementation does (pauses after the plan, after the quote, after the gate, inside the payment + after the wallet's last local check, and between a release decision and its write; the lease and + the quote expiring while paused; distinct invoices; funds for a second payment present; actual + melts counted). What the two processes share is stated per test in the module's documentation, + not assumed: all of them share the one store; the fake mint's quote registry is shared in seven + (the live-owner, expired-estimate, expired-quote-held, paused-owner, stale-snapshot, full-path and + delayed-payment cases); one clock is handed from the first process to the second in four of those + (expired-quote-held, paused-owner, stale-snapshot, delayed-payment) while the other three leave + each process its own clock; and only the delayed-payment case also shares one wallet's proofs + between the two. They do not run a real mint or a real wallet, and no deployed mint's behaviour + was measured — the mint behaviour they model is read from the pinned CDK 0.17.2 source. +- **The log stays readable while it retries.** Every attempt gets at most one line. The first + failure's line carries its detail — the destination, the balance it saw, the error — and the + backoff it starts; later attempts in the same streak get one line each (how many have failed, + when the next is; the moment the delay reaches its 30-minute cap is said on that same line); and + the success that ends a streak says how many attempts failed and for how long the fee sat owed — + the line to look for when you ask "did it ever go out?". + +**The off switch.** If you need to stop the automatic payout — a misbehaving mint, an incident — set + +```toml +[platform_fee] +auto_remit = false # or MAXPLAYER_PLATFORM_FEE__AUTO_REMIT=false +``` + +and restart the node. One flag covers both automatic paths — the attempt after each payment and the +retry clock. This is an **operational valve, not a waiver**: the fee keeps accruing on every payment, +stays owed, and stays visible in `maxplayer seller fees`; when you turn the switch back on the next +attempt (a collect, or the retry clock's first check) remits the whole accumulated balance. The switch +cannot change the rate or the address — the `[platform_fee]` table has no key for either. +`maxplayer seller fees remit --confirm` still pays by hand while it is off. + +**Inspecting and forcing a remittance by hand.** `maxplayer seller fees remit` is the operator's +window onto the automatic path, and its recovery lever: + +``` +maxplayer seller fees remit [--home ] # dry run: recent attempts, resolve, quote, print the plan, move nothing +maxplayer seller fees remit --confirm [--home ] # pay the unremitted balance NOW (also with auto_remit = false) +``` + +The dry run prints whether the automatic remittance is on, the recent attempts with their outcomes +(paid / refused / failed and why), any attempt still in flight, the gross unremitted fee, the mint's +melt fee reserve, the invoice amount the platform receives, and the most that can leave your wallet — +and moves nothing. `--confirm` forces one attempt now, under exactly the same rules as the automatic +path. It refuses, moving nothing (exit 3), when nothing is unremitted; when the balance is below the +destination's minimum (the command prints how far short you are); when an earlier attempt is still +settling at the mint; or when an admitted attempt is held on its bound quote (the `HELD:` line above). +**Running it again after a payment pays nothing** — the receipts it discharged are recorded. ---