diff --git a/crates/maxplayer-trade/README.md b/crates/maxplayer-trade/README.md index f0bef4461..6dd9669c5 100644 --- a/crates/maxplayer-trade/README.md +++ b/crates/maxplayer-trade/README.md @@ -83,8 +83,8 @@ an active sat keyset, and clock skew at most 60 seconds. Preflight runs before e `take`, funding quotes and lock submission; successful earlier preflight is not cached as permission for a later lock. Test mints and loopback HTTP(S) fixtures remain supported. HTTP is refused except on loopback, and redirects are disabled. There is no real-money -opt-in environment variable or allow-list flag. Each lock's gross amount and cumulative -funding per mint/home are capped at **100,000 sats**, including pending/lost funding intents. +opt-in environment variable or allow-list flag. Each lock's gross amount is capped at +**100,000 sats**. Funding intents plus charged receives (gross) share one 100,000-sat cap per mint per home, including pending/lost funding intents. Each withdrawal invoice is also capped at **100,000 sats**, plus at most max(32 sats, ceil(invoice amount × 2%)) of quoted fee reserve, bounded together with input fees by `--max-debit`; spendable funds must cover the invoice, input fees and required change. @@ -624,13 +624,55 @@ A mint can still lie, withhold evidence, or process an already-delivered request process-exit boundaries and one claim/refund race, not a full model checker. +## Receive (import a Cashu token) + +`maxplayer-trade --home receive --token-file ` (or the token on stdin; +never argv) imports one token, e.g. one written by `maxplayer-mint issue`. Code: `src/receive.rs`. + +- Refused before any journal write: token mint not exactly `` after canonicalization, + multi-mint, non-sat, NUT-10 locked (P2PK/HTLC), > 128 or duplicate proofs, invalid incoming + DLEQ (when present), > 100,000 sats, or a receive that would exceed the shared cap below. Common preflight and NUT-07 UNSPENT are required before the journal write. +- One swap into fresh home-owned outputs; input proofs (with secrets), blinded outputs and + their secrets are journaled before the POST (the token text is not). A second output set + is never created. Ambiguity → NUT-09 restore of the same outputs and identical + replay only. A definitive NUT error with inputs UNSPENT is `refused`; SPENT inputs with nothing + of ours restorable is terminal `already_spent`. Only DLEQ-verified proofs are credited; + missing/invalid DLEQ on our outputs is terminal `quarantined` (exit 4). +- Cap: Funding intents plus charged receives (gross) share one 100,000-sat cap per mint per home; `refused`/`already_spent` receives do not count; `prepared`/`submitted`/`done`/`quarantined` ones do. A quarantined receive is not spendable but still occupies the cap. +- Exits: 0 `done` with its credited rows released; 1 pre-journal refusal, `refused` or + `already_spent`; 3 unresolved (`prepared`/`submitted`, `done` whose release is still + outstanding, or a journal write failed; run `recover`); 4 `quarantined`. +- Only a definitive NUT error (HTTP 400, JSON `code` + `detail`, code on the + `MintRefusal::DEFINITIVE` allowlist, a strict subset of cdk-common 0.17.2 + `is_definitive_failure`: 11003 OutputsAlreadySigned and auth codes are deliberately left out) + can make a receive `refused`. `50000` (cdk's transient Internal/Database catch-all), + 11002 TokenPending, 11003, 11004 OutputsPending, unknown codes, any other 400 and transport + refusals stay `submitted` and replay. Every absence check restores, checks NUT-07 and, + on SPENT, restores again before `already_spent`. +- `refused` is FINAL and journals its NUT code (`refusal_code`). Neither `recover` nor a + repeated `receive` replays it: against cdk 0.17.2 every allowlisted code is deterministic + for the same inputs and outputs (output-keyset codes 12001-12003, 11008, 11015, fee-based + 11005 included), so no retryable code exists. The token was not imported, nothing is + charged to the cap, and it can be redeemed elsewhere. A record without a code (written + before this rule) is final too. `status`/`recover` lines carry `settled` and `refusal_code`. +- Credited proofs are stored RESERVED under the receive id and released only after `done` + is journaled, so `withdraw`/`send` cannot spend them earlier. Mint error detail text is + never printed; only the NUT code. +- Idempotent per Y-set (sha256 of the sorted proof Ys): repeats resume the existing attempt + and only ever replay its identical swap. +- The mint input fee is deducted; output reports `amount`, `fee`, `net`, `credited`, state and + attempt id only. `recover`/`serve` resume receives under the same 120 s item budget. +- `nostr://` mints: cdk 0.17.2 `MintUrl`/`Token` parse them (unit test + `cdk_token_parsing_accepts_nostr_mint_url`), but this crate's `Asset::new` admits only + http/https, so a nostr:// receive is refused until the nostr transport branch lands. + ## Funding and withdrawals This path is **not a production safety certification**. Public-mint results, if any, belong in its separate run report; the historical test counts above predate this change. - Each lock gross is capped at **100,000 sats**, at both planning and submission. -- Funding on every mint is capped at **100,000 sats cumulatively per mint per home**. The journal charges +- Funding on every mint is capped at **100,000 sats cumulatively per mint per home**: Funding intents plus charged receives (gross) share one 100,000-sat cap per mint per home; `refused`/`already_spent` receives do not count; `prepared`/`submitted`/`done`/`quarantined` ones do. The journal charges an intent before creating the mint quote; pending, failed, issued and lost-reply intents stay charged across restarts. A lost quote response is deliberately not auto-replaced. - `fund` locks the quote to a private NUT-20 key and requires the mint to echo that diff --git a/crates/maxplayer-trade/skills/maxplayer-trade/SKILL.md b/crates/maxplayer-trade/skills/maxplayer-trade/SKILL.md index fb456dcc2..edf6ac4b9 100644 --- a/crates/maxplayer-trade/skills/maxplayer-trade/SKILL.md +++ b/crates/maxplayer-trade/skills/maxplayer-trade/SKILL.md @@ -1,12 +1,12 @@ --- name: maxplayer-trade -description: Trade Cashu tokens for a human with the standalone maxplayer-trade CLI, including real money. Use for “sell/list X of mint A for Y of mint B”, “buy that lot”, “find listings”, “fund my trade wallet”, “withdraw”, “check balance”, or “something got interrupted”. +description: Trade Cashu tokens for a human with the standalone maxplayer-trade CLI, including real money. Use for “sell/list X of mint A for Y of mint B”, “buy that lot”, “find listings”, “fund my trade wallet”, “receive this token”, “withdraw”, “check balance”, or “something got interrupted”. --- # Trade Cashu for the human -> Round-five operating policy. Read the scoped evidence and -> verification limits in [verification](references/verification.md). +> Round-five operating policy. Scoped evidence and limits: +> [verification](references/verification.md). ## 1. Establish custody and scope @@ -34,15 +34,17 @@ Use `balance ` and `preflight ` for each relevant mint. Passing pref allows a mint, but does not establish honesty or complete compatibility. The CLI also preflights before fund/list/take/withdraw: NUT-07/09/11/12/14, an active sat keyset, reachable mint, and clock skew at most 60 seconds. Real mints need no opt-in setting. Never -bypass a failed check. Gross locks and cumulative funding per mint per home are -capped at **100,000 sats**, as is each withdrawal invoice. The withdrawal Lightning +bypass a failed check. Gross locks and each withdrawal invoice are capped at +**100,000 sats**. Funding intents plus charged receives (gross) share one +100,000-sat cap per mint per home; `refused`/`already_spent` receives do not +count; `prepared`/`submitted`/`done`/`quarantined` ones do. The withdrawal Lightning fee reserve ceiling is **max(32 sats, 2% of invoice sats rounded up)**. Pass `--max-debit` to cap invoice amount + Cashu input fee + Lightning reserve. Fees can make an otherwise eligible net lock amount too large. **Before every real-money `fund`, `list`, `take`, or `withdraw`, present the exact -operation and get an explicit yes.** A broad “trade for me”, previous trade approval, -or a published listing is not approval for a new operation. State: +operation and get an explicit yes.** A broad “trade for me”, earlier approval +or a published listing does not approve a new operation. State: - Both mint URLs and direction (or the single funding/withdrawal mint and destination). - Exact net amounts, maximum total debit, minimum receipt, and fee cap in sats. @@ -53,15 +55,13 @@ or a published listing is not approval for a new operation. State: have their own unresolved-payment risk, not a guaranteed 75-minute timeout. - A mint can steal its tokens. If the counterparty vanishes, funds stay locked until the refund deadline and successful recovery. `serve` must stay running while locks are live (the active `take` also watches its trade). - NUT-14 claims remain valid after locktime. Nutshell refund status: round 4 reports a **successful cashu.cz Nutshell/0.21.0 - refund**; see the version-specific limits in [verification](references/verification.md). NUT-07 advertisement alone does not prove witness emission. The maker relies on + NUT-14 claims remain valid after locktime. Nutshell refund status and limits: [verification](references/verification.md). NUT-07 advertisement alone does not prove witness emission. The maker relies on the chosen mint reporting the HTLC witness if the taker withholds its notice. - Mint choice is the buyer’s/seller’s responsibility; there is no rating or verification gate. + Mint choice is the trader's responsibility; there is no rating or verification gate. -Use the human's price and fee budget, never more permissive limits. If fees are +Use the human's price and fee budget, never looser limits. If fees are unspecified, propose a numeric cap and wait for yes; the CLI default is not consent. -If a command cannot enforce the approved bounds, **do not run it**: explain the -missing enforcement and escalate. Do not substitute a guessed flag or a larger cap. +If a command cannot enforce the approved bounds, **do not run it**; escalate. Never guess a flag or raise a cap. **Done:** an explicit yes binds this exact action, amounts, limits, and risks. ## 3. Execute the selected workflow @@ -71,12 +71,22 @@ missing enforcement and escalate. Do not substitute a guessed flag or a larger c After confirmation, `fund --amount `. On a real mint it prints a BOLT11 invoice and never auto-pays. Give that invoice privately to the human to pay from **their own wallet**; the agent must never pay without separate explicit consent. -Do not treat printing an invoice as funded balance. Preserve the quote ID; after +An invoice is not funded balance. Preserve the quote ID; after interruption use `recover`, and when needed resume that same funding quote with `fund --amount --quote ` after confirmation. Never create -or pay a replacement merely because a reply or issuance timed out. -**Done:** issuance is reconciled and `balance ` confirms the result, or the -retained quote is reported as unresolved without another payment. +or pay a replacement because a reply or issuance timed out. +**Done:** issuance reconciled and `balance ` confirms it, or the retained +quote is reported unresolved without another payment. + +### Receive a token + +Needs the human's yes (mint URL, amount); charges the shared cap (§2). +Save the token to a private file; run `receive --token-file ` (or +stdin). Never put a token in argv/chat/logs. Only plain sat proofs of that +mint; fee deducted. Exit 0 `done`; 1 `refused` (final: not imported; +redeem elsewhere) or `already_spent`; 3 unresolved/release pending: +`recover`, never re-import; 4 `quarantined`: +unspendable, still on the cap; escalate. **Done:** `done` and `balance `. ### Sell/list X of mint A for Y of mint B @@ -84,7 +94,7 @@ After confirmation, `list --give-mint --give --want-mint --want --max-fees `. X and Y are net lot amounts, not interchangeable “bitcoin” assets. The seller's maximum debit is X plus the approved outgoing fee cap. Record the lot ID and keep `serve` running until complete or refunded. Listing authorizes serving -that fixed lot; it does not authorize further listings or price changes. +that fixed lot, not further listings or price changes. `cancel ` is allowed only with **no active quote/swap**. Cancellation never revokes an authorized HTLC lock. On rejection, continue recovery; do not force-release @@ -109,8 +119,7 @@ a timeout. **Done:** verify the terminal result and per-mint balances, or enter Use only an invoice the human provides. Validate amount, destination, expiry, and fee/debit bounds before confirmation. `withdraw --invoice --max-debit ` may spend immediately; it is **not** a quote preview. Read the withdrawal limitations in the -command reference first. Non-terminal means payment and/or change remain unresolved, -not that payment failed. Never create a replacement invoice, withdrawal, or payment +command reference first. Non-terminal means unresolved, not failed. Never create a replacement invoice, withdrawal, or payment while a submitted payment is unresolved. A pre-POST refusal cannot pay later via recovery; only explicit same-invoice withdrawal can authorize a new attempt. `quote_created` is never auto-submitted. A timeout after POST is not a failed payment. **Done:** `done` and reconciled change/balance are confirmed, @@ -122,25 +131,25 @@ or report the retained authorization and recover it without paying again. reservation is spendable. After **any interruption**, use `recover` with the same home and relays before new work. It resumes existing authorizations and does not admit new trade requests. It makes **one bounded pass**, at most **120 seconds per -funding/withdrawal/swap item**, without waiting for lock deadlines: exit **0** means +funding/withdrawal/receive/swap item**, without waiting for lock deadlines: exit **0** means all terminal with no deferred work, **3** unresolved/deferred, **4** terminal manual recovery, **1** command error, pre-submission refusal, or definitive terminal `unpaid_released` (never proof a submitted withdrawal was refused; a submitted non-terminal withdrawal exits **3**), **2** CLI usage error. -Timeout does not undo an RPC or release reservations. Restore `serve` while locks +Timeout neither undoes an RPC nor releases reservations. Restore `serve` while locks are live; a single recovery pass is not a watcher. See the recovery reference for quarantine and errors. **Done:** classify every reported outcome using the recovery reference. ## 4. Verify and report -Check public swap/withdrawal state with `status` and balances with `balance` -without stopping the owner process. A zero exit, elapsed deadline, missing listing, or mint saying PAID is not -by itself reconciled success. Keep the home. Report spent/received amounts and fees +Check public state with `status` and balances with `balance` without stopping +the owner process. A zero exit, elapsed deadline, missing listing or PAID mint is not +reconciled success by itself. Keep the home. Report spent/received amounts and fees only when known; distinguish complete, refunded, unresolved, and manual recovery. On `refund_quarantined` or `claim_quarantined`, stop new money actions and escalate to the human immediately. These are terminal **manual recovery**, not success or spendable funds. Never credit/import unverifiable outputs, edit journal records, replace attempts, or treat quarantine as refund permission. Persistent RPC failure, -missing witnesses, unexpected states, or accounting discrepancies also need a human; +missing witnesses, unexpected states or accounting gaps also need a human; retain safe recovery for other obligations, without weakening any check. **Done:** the human has an accurate outcome and any unresolved custody obligations. diff --git a/crates/maxplayer-trade/skills/maxplayer-trade/references/commands.md b/crates/maxplayer-trade/skills/maxplayer-trade/references/commands.md index 1a65aa4d4..13c1dc145 100644 --- a/crates/maxplayer-trade/skills/maxplayer-trade/references/commands.md +++ b/crates/maxplayer-trade/skills/maxplayer-trade/references/commands.md @@ -17,6 +17,7 @@ preflight balance status fund --amount [--quote ] +receive [--token-file ] list --give-mint --give --want-mint --want --max-fees discover serve @@ -30,7 +31,7 @@ Use `-h` / `--help` at the root or on any application subcommand. The built-in `help [COMMAND]` also shows root or command help. There is no version option. `--home ` is required for operational commands; `--relay ` is repeatable. All command-specific flags in the signatures above are required except -`--quote`, `--max-debit`, and `--max-fees` (default 16 on list/take). Positional mint arguments are +`--quote`, `--max-debit`, `--token-file` (stdin when omitted), and `--max-fees` (default 16 on list/take). Positional mint arguments are canonical URLs; lot arguments are public lot IDs; `--quote` is a saved funding quote for the same mint and amount. Never use a lab-feature binary for the human's funds. @@ -49,11 +50,11 @@ Never use a lab-feature binary for the human's funds. admission claims use the pinned quote fee, not the receiver’s outgoing budget. Refund/recovery may incur mint fees; disclose this rather than promising a fully refunded balance. - No platform/trade fee. Do not equate no platform fee with no mint/Lightning fees. -- Hard cap: 100,000 sats gross per lock; 100,000 cumulative funding per mint per home. +- Hard cap: 100,000 sats gross per lock. Funding intents plus charged receives (gross) share one 100,000-sat cap per mint per home; `refused`/`already_spent` receives do not count; `prepared`/`submitted`/`done`/`quarantined` ones do. Each withdrawal invoice is also capped at 100,000 sats (millisatoshis rounded up to sats). Funding counts all retained intents, including pending ones. None of these limits is permission to spend that amount. Never split operations or rotate homes - to bypass limits. Withdrawing does not reset cumulative funding authority. + to bypass limits. Withdrawing does not reset this cumulative cap. Example: a lot gives 32 A for 24 B. If the human approves **at most 27 B total**, **at least 32 A net**, and **3 B fee sats**, use `take --max-give 27 @@ -86,6 +87,32 @@ external-wallet routing fee budget in the human's approval; this CLI cannot enfo fees charged by another wallet. Never assume a real invoice is auto-paid because a test mint behaved that way. Reuse the retained quote for the same mint/amount. +## Receive + +`receive --token-file ` (or the token on stdin; never argv) imports one +Cashu token. Refused **before anything is journaled**: a token whose mint is not +exactly `` after canonicalization, multi-mint tokens, non-sat units, P2PK/HTLC +or other NUT-10 locked proofs, more than 128 or duplicate proofs, an invalid incoming +DLEQ (when present), a token above **100,000 sats**, or one that would push this +mint past the shared cap. Funding intents plus charged receives (gross) share one 100,000-sat cap per mint per home; `refused`/`already_spent` receives do not count; `prepared`/`submitted`/`done`/`quarantined` ones do. A quarantined receive is not spendable but +still occupies the cap. Common preflight runs, then NUT-07 must report every proof UNSPENT. + +One swap into fresh home-owned outputs; input proofs, blinded outputs and their +secrets are journaled before the POST (not the token text). The mint input fee is deducted: `net = amount - fee`. +Only DLEQ-verified result proofs are credited. Output is one JSON line with +`receive` (attempt id), `mint`, `state`, `amount`, `fee`, `net`, `credited`; never +the token or proofs; also `settled` and `refusal_code`. Repeating the same token +(any encoding) resumes the same attempt and only replays its identical swap; a +second output set is never created. Exit 0 `done` and released; 3 unresolved +(`prepared`/`submitted`, `done` with release outstanding, or the state could not +be journaled); 1 pre-journal refusal, `refused` or +`already_spent`; 4 `quarantined`. Only a definitive mint NUT error (HTTP 400, JSON +`code` + `detail`, allowlisted code such as 11001/11005/12001-12003) yields `refused`; +`50000`, 11002, 11003 (outputs already signed: evidence of a landing), 11004, +unknown codes, any other 400 or transport refusal stay `submitted` (exit 3) and the +identical swap is replayed. `refused` is FINAL: no command retries it, the token was +not imported and can be redeemed elsewhere, and it does not occupy the cap. + ## Withdrawal: enforcement before execution Each invoice is capped at **100,000 sats**. The Lightning reserve ceiling is diff --git a/crates/maxplayer-trade/skills/maxplayer-trade/references/recovery.md b/crates/maxplayer-trade/skills/maxplayer-trade/references/recovery.md index 9faf156b0..a345b1790 100644 --- a/crates/maxplayer-trade/skills/maxplayer-trade/references/recovery.md +++ b/crates/maxplayer-trade/skills/maxplayer-trade/references/recovery.md @@ -23,7 +23,7 @@ receiver claims remain valid after locktime. Keep the watcher alive. ## Bounded pass and exit status `recover` makes one pass without waiting for future lock deadlines. Each funding, -withdrawal, and swap attempt has a **120-second** budget; this is not a 120-second +withdrawal, receive, and swap attempt has a **120-second** budget; this is not a 120-second whole-command deadline. Items are handled sequentially and relay/setup/publication work is additional. A timeout does not undo a delivered RPC or release reservations. @@ -96,6 +96,22 @@ unsent quote. `refused` permits a same-invoice retry; `unpaid_released` requires new invoice for a new payment. A failed pre-POST withdrawal cannot pay later via serve/recover/market commands; a post-POST timeout is still unresolved, not failure. Escalate persistent uncertainty without forcing release. +## Receive states + +| State | Meaning and action | +| --- | --- | +| `prepared` | Journaled, swap not yet sent. Recovery sends exactly the journaled swap. | +| `submitted` | Swap may have reached the mint. Recovery restores (NUT-09) the same outputs and replays only the identical swap; never import the token again elsewhere. | +| `done` | Terminal: DLEQ-verified proofs credited (held reserved until `done` is journaled, then released; `recover` finishes a pending release); check `balance`. | +| `refused` | Exit 1: the mint returned a definitive NUT error (allowlisted code; `50000`, 11002, 11004 and unknown codes are not), a fresh restore found none of our outputs and the inputs stayed UNSPENT. FINAL: nothing credited, not charged to the cap, `refusal_code` journaled; neither `recover` nor a repeated `receive` retries it. The token was not imported; it can be redeemed elsewhere. Any other 400 or transport refusal stays `submitted`. | +| `already_spent` | Terminal (exit 1): inputs SPENT and none of our outputs restorable. Nothing credited, not charged. | +| `quarantined` | Terminal manual recovery (exit 4): our outputs were signed but lack valid DLEQ; never credited, not spendable, but still occupies the cap. Preserve the home and escalate. | + +Cap: Funding intents plus charged receives (gross) share one 100,000-sat cap per mint per home; `refused`/`already_spent` receives do not count; `prepared`/`submitted`/`done`/`quarantined` ones do. +Exits: 0 `done` and released, 1 `refused`/`already_spent`, 3 `prepared`/`submitted`, `done` with release outstanding (run `recover`) or a failed journal write, 4 `quarantined`. +Inputs reported PENDING keep the attempt unresolved. A lost swap reply is never +answered with replacement outputs. + ## Funding and market status are separate Fresh UNPAID after funding expiry +60 seconds becomes terminal `expired_unpaid`, diff --git a/crates/maxplayer-trade/src/coordinator.rs b/crates/maxplayer-trade/src/coordinator.rs index 0846684e4..aa0747dba 100644 --- a/crates/maxplayer-trade/src/coordinator.rs +++ b/crates/maxplayer-trade/src/coordinator.rs @@ -1068,6 +1068,11 @@ pub async fn recovery_status(j: &Journal, mut unresolved: bool) -> Result<()> { unresolved |= !a.terminal(); println!("{}", a.summary()); } + for r in j.all::("receive").await? { + unresolved |= r.unresolved(); + manual |= r.state == crate::receive::ReceiveState::Quarantined; + println!("{}", r.summary()); + } println!( "{}", serde_json::json!({"status":if unresolved {"recovery_incomplete"} else {"recovery_complete"}}) diff --git a/crates/maxplayer-trade/src/lib.rs b/crates/maxplayer-trade/src/lib.rs index 4ff4c5883..9b095db69 100644 --- a/crates/maxplayer-trade/src/lib.rs +++ b/crates/maxplayer-trade/src/lib.rs @@ -476,3 +476,4 @@ pub mod wallet; pub mod money; pub mod real_money; +pub mod receive; diff --git a/crates/maxplayer-trade/src/main.rs b/crates/maxplayer-trade/src/main.rs index a32e1da8b..13080873f 100644 --- a/crates/maxplayer-trade/src/main.rs +++ b/crates/maxplayer-trade/src/main.rs @@ -73,6 +73,13 @@ enum Command { #[arg(long)] max_debit: Option, }, + /// Import one plain-sat Cashu token (max 100,000 sats) from a file or stdin; never argv. + Receive { + mint: String, + /// File holding the token; omit to read the token from stdin. + #[arg(long)] + token_file: Option, + }, /// Read public journal states without locking the home or advancing payments. Status, /// Read spendable wallet balance without submitting existing money authorizations. @@ -145,6 +152,7 @@ async fn execute() -> Result<()> { | Command::Fund { .. } | Command::Balance { .. } | Command::Withdraw { .. } + | Command::Receive { .. } ) { let keys = maxplayer_trade::wallet::identity(&cli.home)?; let relays = if cli.relay.is_empty() { @@ -221,7 +229,8 @@ async fn execute() -> Result<()> { Command::Preflight { mint } | Command::Fund { mint, .. } | Command::Balance { mint } - | Command::Withdraw { mint, .. } => mint, + | Command::Withdraw { mint, .. } + | Command::Receive { mint, .. } => mint, _ => unreachable!(), }; let asset = maxplayer_trade::Asset::new(mint_arg)?; @@ -270,6 +279,43 @@ async fn execute() -> Result<()> { ); return Ok(()); } + if let Command::Receive { ref token_file, .. } = cli.command { + use std::io::Read; + let limit = maxplayer_trade::receive::MAX_TOKEN_BYTES as u64 + 1; + let mut raw = String::new(); + match token_file { + Some(path) => fs::File::open(path) + .context("cannot open token file")? + .take(limit) + .read_to_string(&mut raw) + .context("token file is not readable UTF-8")?, + None => std::io::stdin() + .take(limit) + .read_to_string(&mut raw) + .context("stdin token is not readable UTF-8")?, + }; + let r = maxplayer_trade::receive::receive(&cli.home, &money_journal, mint, &raw).await; + drop(raw); + let r = r?; + println!("{}", r.summary()); + use maxplayer_trade::receive::ReceiveState::*; + return match r.state { + Done if r.settled() => Ok(()), + // Journaled done but the credited rows are not yet released: run recover. + Done => Err(coordinator::RecoveryIncomplete.into()), + Quarantined => Err(coordinator::ManualRecovery.into()), + Refused => anyhow::bail!( + "receive refused by the mint ({}); final: the token was NOT imported, nothing \ + was credited or charged to the cap, and it can be redeemed elsewhere", + r.refusal_code + .map_or("NUT code not journaled".to_owned(), |c| format!( + "NUT code {c}" + )) + ), + AlreadySpent => anyhow::bail!("token already spent; nothing credited"), + Prepared | Submitted => Err(coordinator::RecoveryIncomplete.into()), + }; + } if let Command::Fund { amount, ref quote, .. } = cli.command diff --git a/crates/maxplayer-trade/src/mint.rs b/crates/maxplayer-trade/src/mint.rs index f30c207c7..30cae4e61 100644 --- a/crates/maxplayer-trade/src/mint.rs +++ b/crates/maxplayer-trade/src/mint.rs @@ -51,6 +51,98 @@ pub const RPC_TIMEOUT_SECONDS: u64 = 20; // Three RPC timeouts: the swap timeout plus two timeouts of scheduling/clock margin. pub const ABANDON_GRACE_SECONDS: u64 = 3 * RPC_TIMEOUT_SECONDS; +/// A Cashu NUT error from the mint: HTTP 400 whose body is a JSON object with a +/// numeric `code` and a string `detail` (NUT-00 error response). Parsing proves only +/// that the mint answered with a NUT body: cdk 0.17.2 answers HTTP 400 + NUT body for +/// EVERY error, including transient ones (`50000` for Internal/Database/DHKE/ +/// ConcurrentUpdate and its fallback). Only [`MintRefusal::definitive`] codes may be +/// treated as "the mint processed and refused this request". +/// +/// Everything else stays ambiguous: transport errors, timeouts, 429/5xx, a 400 whose +/// body is not parseable or has no numeric code, and any transport-level refusal such +/// as a Nostr connector's `expired`/`bad_request` ("not executed"). A transport must +/// construct this type only for a real NUT error the mint itself returned. +/// +/// `detail` is sanitized (printable ASCII, at most [`MintRefusal::MAX_DETAIL`] bytes); +/// the raw body is never kept or logged. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MintRefusal { + pub code: u64, + pub detail: String, +} +impl MintRefusal { + pub const MAX_DETAIL: usize = 120; + /// Build from a NUT error code and an untrusted detail string (sanitized here). + pub fn new(code: u64, detail: &str) -> Self { + let mut detail: String = detail + .chars() + .map(|c| { + if c.is_ascii_graphic() || c == ' ' { + c + } else { + '?' + } + }) + .collect(); + detail.truncate(Self::MAX_DETAIL); + Self { code, detail } + } + /// Parse an HTTP reply. `Some` only for status 400 with a NUT error body. + pub fn parse(status: u16, body: &str) -> Option { + if status != 400 { + return None; + } + let v: serde_json::Value = serde_json::from_str(body).ok()?; + Some(Self::new( + v.get("code")?.as_u64()?, + v.get("detail")?.as_str()?, + )) + } + /// NUT codes meaning the mint verified this request and rejected it without + /// executing it. A strict SUBSET of cdk-common 0.17.2 `Error::is_definitive_failure` + /// for the errors a swap can return; do not "complete the mirror" (11003 and the + /// auth codes are deliberately left out). Everything else stays ambiguous: `50000` (cdk's + /// transient catch-all), 11002 TokenPending, 11004 OutputsPending, 11003 (our + /// outputs are already signed: evidence that the swap landed), auth/rate-limit + /// codes and every unknown code. + pub const DEFINITIVE: &'static [u64] = &[ + 10001, // TokenNotVerified + 11001, // TokenAlreadySpent + 11005, // TransactionUnbalanced + 11007, // DuplicateInputs + 11008, // DuplicateOutputs + 11009, // MultipleUnits + 11010, // UnitMismatch + 11013, // UnsupportedUnit + 11014, // MaxInputsExceeded + 11015, // MaxOutputsExceeded + 12001, // KeysetNotFound + 12002, // KeysetInactive + 12003, // KeysetExpired + ]; + pub fn definitive(&self) -> bool { + Self::DEFINITIVE.contains(&self.code) + } + /// The typed refusal anywhere in an error's chain, if any. + pub fn of(e: &anyhow::Error) -> Option<&Self> { + e.chain().find_map(|c| c.downcast_ref::()) + } +} +impl std::fmt::Display for MintRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Mint-controlled detail text is never displayed or logged; the code only. + write!(f, "mint NUT error {}", self.code) + } +} +impl std::error::Error for MintRefusal {} + +/// True only for a [`MintRefusal::definitive`] NUT error in `e`'s chain. +pub fn definitive_refusal(e: &anyhow::Error) -> bool { + MintRefusal::of(e).is_some_and(MintRefusal::definitive) +} + +/// POST a NUT endpoint. A NUT error reply is returned as a [`MintRefusal`] in the +/// error chain; every other failure is an untyped (ambiguous) error. pub async fn rpc( mint: &str, op: &str, @@ -67,6 +159,9 @@ pub async fn rpc( .await?; let status = r.status(); let text = r.text().await?; + if let Some(refusal) = MintRefusal::parse(status.as_u16(), &text) { + return Err(anyhow::Error::new(refusal).context(format!("mint {mint} {op}: HTTP {status}"))); + } ensure!( status.is_success(), "mint {mint} {op}: HTTP {status} (body withheld)" @@ -999,3 +1094,60 @@ async fn owned_commit_evidence(a: &Attempt) -> Result { .iter() .all(|s| s.state == State::Spent)) } + +#[cfg(test)] +mod refusal_tests { + use super::MintRefusal; + #[test] + fn only_a_400_nut_error_body_is_a_refusal() { + let ok = MintRefusal::parse(400, r#"{"code":11001,"detail":"Token already spent"}"#); + assert_eq!(ok, Some(MintRefusal::new(11001, "Token already spent"))); + for (status, body) in [ + (400, "bad request"), + (400, r#"{"detail":"no code"}"#), + (400, r#"{"code":"11001","detail":"string code"}"#), + (400, r#"{"code":11001}"#), + (400, r#"{"error":"expired","detail":"x"}"#), + (400, ""), + (500, r#"{"code":11001,"detail":"x"}"#), + (429, r#"{"code":11001,"detail":"x"}"#), + ] { + assert_eq!( + MintRefusal::parse(status, body), + None, + "SAFETY: {status} {body}" + ); + } + } + #[test] + fn only_allowlisted_codes_are_definitive() { + for code in [11001, 11005, 12001, 12002, 12003] { + assert!(MintRefusal::new(code, "x").definitive(), "{code}"); + } + // cdk 0.17.2: Internal/Database/DHKE/ConcurrentUpdate/fallback -> 50000. + for code in [50000, 11002, 11003, 11004, 31004, 20005, 0, 1, 99999] { + assert!( + !MintRefusal::new(code, "x").definitive(), + "SAFETY: {code} is ambiguous" + ); + } + let e = anyhow::Error::new(MintRefusal::new(50000, "busy")).context("outer"); + assert!(MintRefusal::of(&e).is_some() && !super::definitive_refusal(&e)); + let e = anyhow::Error::new(MintRefusal::new(11001, "spent")).context("outer"); + assert!(super::definitive_refusal(&e)); + } + #[test] + fn display_withholds_mint_detail() { + let r = MintRefusal::new(11001, "secret-looking-detail"); + assert_eq!(r.to_string(), "mint NUT error 11001"); + } + #[test] + fn detail_is_bounded_and_sanitized() { + let r = MintRefusal::new(1, &format!("a\nb\u{1b}[31m{}", "x".repeat(500))); + assert!(r.detail.len() <= MintRefusal::MAX_DETAIL); + assert!(r.detail.chars().all(|c| c.is_ascii_graphic() || c == ' ')); + let e = anyhow::Error::new(r.clone()).context("outer"); + assert_eq!(MintRefusal::of(&e), Some(&r)); + assert!(MintRefusal::of(&anyhow::anyhow!("mint x swap: HTTP 400 Bad Request")).is_none()); + } +} diff --git a/crates/maxplayer-trade/src/money.rs b/crates/maxplayer-trade/src/money.rs index 5d9f0cd60..75e9f7fd2 100644 --- a/crates/maxplayer-trade/src/money.rs +++ b/crates/maxplayer-trade/src/money.rs @@ -148,20 +148,20 @@ pub async fn fund(home: &Path, j: &Journal, url: &str, amount: u64) -> Result 0 && amount <= crate::real_money::CAP, "funding cap exceeded" ); - let total = j - .all::("funding") - .await? - .iter() - .filter(|f| f.mint == url) - .try_fold(0u64, |s, f| { - s.checked_add(f.amount).context("funding overflow") - })?; - ensure!( - total - .checked_add(amount) - .is_some_and(|n| n <= crate::real_money::CAP), - "cumulative funding exceeds 100,000 sats" - ); + let (funded, received) = crate::receive::cap_used(j, url).await?; + let total = funded.checked_add(received).context("funding overflow")?; + if !total + .checked_add(amount) + .is_some_and(|n| n <= crate::real_money::CAP) + { + if received > 0 { + anyhow::bail!( + "cumulative funding and receives exceed 100,000 sats for this mint \ + ({received} sats held by receives, gross, including unresolved or quarantined)" + ); + } + anyhow::bail!("cumulative funding exceeds 100,000 sats"); + } crate::wallet::preflight_for(url, Some("fund")).await?; let mut f = Funding { id: uuid::Uuid::new_v4().to_string(), @@ -844,7 +844,9 @@ pub async fn recover(home: &Path, j: &Journal) -> Result { ); } } - Ok(pending(j).await? || failed) + // Receives share the item budget and pass; see receive::RECOVERY_ITEM_SECONDS. + let receives = crate::receive::recover(home, j).await?; + Ok(pending(j).await? || failed || receives) } pub async fn pending(j: &Journal) -> Result { @@ -855,7 +857,8 @@ pub async fn pending(j: &Journal) -> Result { || j.all::("withdrawal") .await? .iter() - .any(|a| !a.terminal())) + .any(|a| !a.terminal()) + || crate::receive::pending(j).await?) } impl Withdrawal { diff --git a/crates/maxplayer-trade/src/receive.rs b/crates/maxplayer-trade/src/receive.rs new file mode 100644 index 000000000..c4d8d7a7c --- /dev/null +++ b/crates/maxplayer-trade/src/receive.rs @@ -0,0 +1,777 @@ +//! Import one plain-sat Cashu token into this home. +//! +//! Journal-before-effect: the token's input proofs and our fresh blinded outputs with +//! their secrets are journaled BEFORE the swap POST (the token text itself is not). +//! Every later pass first restores (NUT-09) those exact outputs and only ever replays +//! the identical swap; a second output set is never created. Only DLEQ-verified proofs +//! are credited. A definitive refusal is FINAL (see [`ReceiveState::Refused`]). +use crate::{ + journal::Journal, + mint, + wallet::{bounded, database, wallet}, +}; +use anyhow::{Context, Result, anyhow, ensure}; +use cashu::nuts::nut00::ProofsMethods; +use cashu::{nuts::*, secret::Secret}; +use cdk::{ + amount::SplitTarget, cdk_database::WalletDatabase, mint_url::MintUrl, wallet::KeysetFilter, +}; +use cdk_common::wallet::ProofInfo; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{collections::HashSet, path::Path, str::FromStr}; + +/// Most proofs accepted from one token (and most fresh outputs created for it). +pub const MAX_PROOFS: usize = 128; +/// Largest token text read from a file or stdin. +pub const MAX_TOKEN_BYTES: usize = 1 << 20; +/// Per-item recovery budget; identical to the funding/withdrawal/swap item budget. +pub const RECOVERY_ITEM_SECONDS: u64 = 120; + +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ReceiveState { + /// Journaled; the swap has not been POSTed. + Prepared, + /// The swap may have reached the mint; reconcile only by restore + identical replay. + Submitted, + /// Terminal: DLEQ-verified proofs credited. + Done, + /// Terminal and FINAL: the mint returned a definitive NUT refusal + /// ([`mint::MintRefusal::definitive`]) and a fresh probe found none of our outputs + /// and every input UNSPENT. Nothing is retried (not by `recover`, not by repeating + /// `receive`): against cdk 0.17.2 every allowlisted code is deterministic for the + /// same inputs and outputs, so the identical swap can never succeed (PR #1119, + /// review round 3). The token was not imported and can be redeemed elsewhere. + Refused, + /// Terminal: inputs SPENT elsewhere and none of our outputs restorable. + AlreadySpent, + /// Terminal manual recovery: our outputs were signed but failed DLEQ verification. + Quarantined, +} + +#[derive(Clone, Serialize, Deserialize)] +struct Output { + message: BlindedMessage, + secret: Secret, + r: String, +} + +/// Private recovery record. Never print it; use [`Receipt::summary`]. +#[derive(Clone, Serialize, Deserialize)] +pub struct Receipt { + pub id: String, + pub mint: String, + pub state: ReceiveState, + /// Gross token value, charged to the cumulative per-mint funding cap. + pub amount: u64, + /// Mint input fee deducted by the swap. + pub fee: u64, + /// Credited amount on success (amount - fee). + pub net: u64, + inputs: Proofs, + outputs: Vec, + result: Option, + /// Credited rows are inserted RESERVED under this receipt's operation id and only + /// released after `done` is journaled; true once that release ran. + #[serde(default)] + released: bool, + /// The definitive NUT code that made this record `refused`. `None` on records + /// written before round 3 (still final). + #[serde(default)] + pub refusal_code: Option, +} + +impl Receipt { + /// `done` and its credited rows released (spendable). + pub fn settled(&self) -> bool { + self.state == ReceiveState::Done && self.released + } + /// Recovery still owes work: non-terminal, or `done` with the release outstanding. + pub fn unresolved(&self) -> bool { + !self.terminal() || (self.state == ReceiveState::Done && !self.released) + } + pub fn terminal(&self) -> bool { + matches!( + self.state, + ReceiveState::Done + | ReceiveState::Refused + | ReceiveState::AlreadySpent + | ReceiveState::Quarantined + ) + } + /// Counts against the cap unless the mint definitively credited nothing. + fn charged(&self) -> bool { + !matches!( + self.state, + ReceiveState::Refused | ReceiveState::AlreadySpent + ) + } + /// Public accounting view: amounts, mint, state and attempt id only. + pub fn summary(&self) -> serde_json::Value { + serde_json::json!({"receive":self.id,"mint":self.mint,"state":self.state, + "amount":self.amount,"fee":self.fee,"net":self.net, + "credited":if self.state == ReceiveState::Done {self.net} else {0}, + "settled":self.settled(),"refusal_code":self.refusal_code, + "terminal":self.terminal(),"manual_recovery":self.state == ReceiveState::Quarantined}) + } +} + +/// Statically validated token: no network, nothing journaled. +pub struct Inspected { + token: Token, + pub id: String, + pub amount: u64, + ys: Vec, +} + +/// Refuse anything but plain sat proofs from exactly `mint` (canonical), <= 128 proofs, +/// no duplicates, within the per-receive cap. Errors never echo token material. +pub fn inspect(mint: &str, raw: &str) -> Result { + let raw = raw.trim(); + ensure!( + !raw.is_empty() && raw.len() <= MAX_TOKEN_BYTES, + "token missing or oversized" + ); + let token = + Token::from_str(raw).map_err(|_| anyhow!("invalid Cashu token (contents withheld)"))?; + if let Token::TokenV3(t) = &token { + ensure!(t.token.len() == 1, "multi-mint token refused"); + } + let token_mint = token + .mint_url() + .map_err(|_| anyhow!("multi-mint token refused"))?; + let canonical = url::Url::parse(&token_mint.to_string()) + .ok() + .and_then(|u| crate::Asset::new(u.as_str().trim_end_matches('/')).ok()) + .context("token mint is not a supported canonical mint")?; + ensure!(canonical.mint_url == mint, "token is for a different mint"); + ensure!( + token.unit().is_none_or(|u| u == CurrencyUnit::Sat), + "only sat tokens are accepted" + ); + let secrets = token.token_secrets(); + ensure!(!secrets.is_empty(), "empty token"); + ensure!( + secrets.len() <= MAX_PROOFS, + "token has more than {MAX_PROOFS} proofs" + ); + let mut ys = Vec::with_capacity(secrets.len()); + let mut seen = HashSet::new(); + for s in secrets { + // Any NUT-10 secret (P2PK, HTLC, ...) is locked: only plain proofs are accepted. + ensure!( + cashu::nuts::nut10::Secret::try_from(s).is_err(), + "locked (P2PK/HTLC) proofs refused; only plain proofs are accepted" + ); + let y = cashu::dhke::hash_to_curve(s.as_bytes())?; + ensure!(seen.insert(y), "duplicate proof in token"); + ys.push(y); + } + let amount = u64::from(token.value().map_err(|_| anyhow!("invalid token amount"))?); + ensure!(amount > 0, "zero-value token"); + ensure!( + amount <= crate::real_money::CAP, + "receive exceeds 100,000-sat cap" + ); + let mut hex: Vec<_> = ys.iter().map(|y| y.to_hex()).collect(); + hex.sort(); + let id = hex::encode(Sha256::digest(format!( + "maxplayer-trade/receive/v1|{}", + hex.join(",") + )))[..32] + .to_owned(); + Ok(Inspected { + token, + id, + amount, + ys, + }) +} + +/// Sats this home has taken in from `mint` by receiving (cap accounting). +pub async fn charged(j: &Journal, mint: &str) -> Result { + j.all::("receive") + .await? + .iter() + .filter(|r| r.mint == mint && r.charged()) + .try_fold(0u64, |s, r| { + s.checked_add(r.amount).context("receive overflow") + }) +} + +/// The one shared-cap rule: (funding intents, charged receives) on `mint`, gross. +/// `fund` and `receive` both check `funded + received + new <= 100,000`. +pub async fn cap_used(j: &Journal, mint: &str) -> Result<(u64, u64)> { + let funded = j + .all::("funding") + .await? + .iter() + .filter(|f| f.mint == mint) + .try_fold(0u64, |s, f| { + s.checked_add(f.amount).context("funding overflow") + })?; + Ok((funded, charged(j, mint).await?)) +} + +/// Shared lifetime cap: funding intents + charged receives + this receive <= 100,000. +async fn check_cap(j: &Journal, mint: &str, amount: u64) -> Result<()> { + let (funded, received) = cap_used(j, mint).await?; + ensure!( + funded + .checked_add(received) + .and_then(|n| n.checked_add(amount)) + .is_some_and(|n| n <= crate::real_money::CAP), + "cumulative funding and receives exceed 100,000 sats for this mint" + ); + Ok(()) +} + +/// Receive `raw` into `mint`. Repeat calls for the same Y-set resume the existing +/// attempt (identical swap only; a `refused` record is final and is returned as is). +/// Errors before the journal write are refusals (nothing recorded). After it, the +/// returned record's state is authoritative. +pub async fn receive(home: &Path, j: &Journal, mint: &str, raw: &str) -> Result { + let asset = crate::Asset::new(mint)?; + asset.fence()?; + let mint = asset.mint_url.as_str(); + let p = inspect(mint, raw)?; + let ys: HashSet<_> = p.ys.iter().copied().collect(); + let mut existing = None; + for r in j.all::("receive").await? { + if r.id == p.id { + ensure!(r.mint == mint, "token already journaled for another mint"); + existing = Some(r); + } else if r.charged() && r.inputs.ys()?.iter().any(|y| ys.contains(y)) { + anyhow::bail!("token overlaps earlier receive {}", r.id); + } + } + if let Some(mut r) = existing { + // `refused` is terminal, so `resume` below only no-ops: a refusal is final. + if let Err(error) = resume(home, j, &mut r).await { + eprintln!("receive {}: {error:#}; attempt retained", r.id); + } + return Ok(r); + } + let db = database(home, mint).await?; + ensure!( + db.get_proofs_by_ys(p.ys.clone()).await?.is_empty(), + "token proofs already belong to this wallet" + ); + check_cap(j, mint, p.amount).await?; + crate::wallet::preflight(mint).await?; + let w = wallet(home, mint).await?; + bounded(w.refresh_keysets()) + .await + .context("CDK wallet request timed out")??; + let keysets = bounded(w.get_mint_keysets(KeysetFilter::All)) + .await + .context("CDK wallet request timed out")??; + let inputs = p + .token + .proofs(&keysets) + .map_err(|_| anyhow!("token keyset is not a sat keyset of this mint"))?; + ensure!(inputs.len() == p.ys.len(), "token proof count mismatch"); + for (proof, y) in inputs.iter().zip(&p.ys) { + ensure!(proof.witness.is_none(), "unexpected witness on token proof"); + ensure!(proof.y()? == *y, "token proof identity mismatch"); + } + let with_dleq: Proofs = inputs + .iter() + .filter(|p| p.dleq.is_some()) + .cloned() + .collect(); + if !with_dleq.is_empty() { + bounded(w.verify_token_dleq(&Token::new( + MintUrl::from_str(mint)?, + with_dleq, + None, + CurrencyUnit::Sat, + ))) + .await + .context("CDK wallet request timed out")? + .map_err(|e| match e { + cdk::Error::CouldNotVerifyDleq => anyhow!("incoming token DLEQ verification failed"), + e => anyhow!("incoming token DLEQ check could not run: {e}"), + })?; + } + let fee = u64::from( + bounded(w.get_proofs_fee(&inputs)) + .await + .context("CDK wallet request timed out")?? + .total, + ); + ensure!(p.amount > fee, "uneconomic token: input fee consumes it"); + let net = p.amount - fee; + mint::unspent(mint, &inputs) + .await + .context("token is not UNSPENT at the mint (already spent or pending)")?; + let k = bounded(w.fetch_active_keyset()) + .await + .context("CDK wallet request timed out")??; + let amounts = bounded(w.get_keyset_fees_and_amounts_by_id(k.id)) + .await + .context("CDK wallet request timed out")??; + let outputs: Vec = + PreMintSecrets::random(k.id, net.into(), &SplitTarget::default(), &amounts)? + .secrets + .into_iter() + .map(|o| Output { + message: o.blinded_message, + secret: o.secret, + r: hex::encode(o.r.to_secret_bytes()), + }) + .collect(); + ensure!( + !outputs.is_empty() && outputs.len() <= MAX_PROOFS, + "output limit" + ); + let mut r = Receipt { + id: p.id, + mint: mint.into(), + state: ReceiveState::Prepared, + amount: p.amount, + fee, + net, + inputs, + outputs, + result: None, + released: false, + refusal_code: None, + }; + j.put("receive", &r.id, &r).await?; + if let Err(error) = resume(home, j, &mut r).await { + eprintln!("receive {}: {error:#}; attempt retained", r.id); + } + Ok(r) +} + +enum Inputs { + Unspent, + Spent, + Pending, +} +async fn inputs_state(r: &Receipt) -> Result { + let s = mint::states(&r.mint, &r.inputs).await?; + Ok(if s.states.iter().all(|s| s.state == State::Unspent) { + Inputs::Unspent + } else if s + .states + .iter() + .all(|s| matches!(s.state, State::Unspent | State::Spent)) + { + Inputs::Spent + } else { + Inputs::Pending + }) +} + +/// NUT-09 restore of the SAME outputs: None when wholly absent, else all of them. +async fn restore(r: &Receipt) -> Result>> { + let messages: Vec<_> = r.outputs.iter().map(|o| o.message.clone()).collect(); + let got: RestoreResponse = mint::rpc( + &r.mint, + "restore", + &RestoreRequest { + outputs: messages.clone(), + }, + ) + .await?; + if got.outputs.is_empty() && got.signatures.is_empty() { + return Ok(None); + } + ensure!( + got.outputs.len() == messages.len() && got.signatures.len() == messages.len(), + "partial restore; receive attempt retained" + ); + messages + .iter() + .map(|m| { + let i = got + .outputs + .iter() + .position(|o| o.blinded_secret == m.blinded_secret) + .context("restore output mismatch")?; + Ok(got.signatures[i].clone()) + }) + .collect::>>() + .map(Some) +} + +enum Probe { + /// Our outputs are signed: use these signatures. + Signed(Vec), + Unspent, + Spent, + Pending, +} + +/// The ONE absence check for every path: restore; when absent, NUT-07; when SPENT, +/// restore AGAIN before calling our outputs absent (a swap may land during NUT-07, +/// and CDK writes signatures and SPENT in one transaction). +async fn probe(r: &Receipt) -> Result { + if let Some(s) = restore(r).await? { + return Ok(Probe::Signed(s)); + } + Ok(match inputs_state(r).await? { + Inputs::Unspent => Probe::Unspent, + Inputs::Pending => Probe::Pending, + Inputs::Spent => match restore(r).await? { + Some(s) => Probe::Signed(s), + None => Probe::Spent, + }, + }) +} + +/// Operation id of the reserved credit rows (the 128-bit receipt id). +fn operation(r: &Receipt) -> Result { + uuid::Uuid::parse_str(&r.id).context("receive id is not a 128-bit hex id") +} + +/// Make credited rows spendable. Only after `done` is journaled; idempotent. +async fn release(home: &Path, j: &Journal, r: &mut Receipt) -> Result<()> { + if r.state != ReceiveState::Done || r.released { + return Ok(()); + } + database(home, &r.mint) + .await? + .release_proofs(&operation(r)?) + .await?; + let mut next = r.clone(); + next.released = true; + j.put("receive", &next.id, &next).await?; + *r = next; + Ok(()) +} + +/// Persist `state` FIRST; only then let the caller-visible record change. A failed +/// journal write returns the error and leaves `r` at its previous (journaled) state. +async fn finish(j: &Journal, r: &mut Receipt, state: ReceiveState) -> Result<()> { + let mut next = r.clone(); + next.state = state; + persist(j, &next).await?; + *r = next; + Ok(()) +} + +async fn persist(j: &Journal, r: &Receipt) -> Result<()> { + #[cfg(feature = "lab")] + if r.terminal() + && std::env::var("TRADE_FAIL_RECEIVE_TERMINAL_WRITE") + .ok() + .as_deref() + == Some("1") + { + anyhow::bail!("lab fault: receive journal write failed"); + } + j.put("receive", &r.id, r).await +} + +/// Advance one attempt. Terminal outcomes return Ok with the terminal state set. +pub async fn resume(home: &Path, j: &Journal, r: &mut Receipt) -> Result<()> { + if r.terminal() { + return release(home, j, r).await; + } + crate::Asset::new(&r.mint)?.fence()?; + if r.result.is_none() { + let signatures = match probe(r).await? { + Probe::Signed(s) => s, + Probe::Pending => anyhow::bail!("token inputs PENDING at the mint; retained"), + Probe::Spent => return finish(j, r, ReceiveState::AlreadySpent).await, + Probe::Unspent => { + if r.state != ReceiveState::Submitted { + finish(j, r, ReceiveState::Submitted).await?; + } + #[cfg(feature = "lab")] + if std::env::var("TRADE_CRASH_BEFORE_RECEIVE_SWAP") + .ok() + .as_deref() + == Some("1") + { + std::process::exit(87); + } + let sent: Result = mint::rpc( + &r.mint, + "swap", + &SwapRequest::new( + r.inputs.clone(), + r.outputs.iter().map(|o| o.message.clone()).collect(), + ), + ) + .await; + match sent { + Ok(reply) => reply.signatures, + // Definitive only for an allowlisted NUT code AND fresh absence of + // our outputs (same probe as above, double restore included). + Err(error) if mint::definitive_refusal(&error) => match probe(r).await? { + Probe::Signed(s) => s, + Probe::Unspent => { + let mut next = r.clone(); + next.state = ReceiveState::Refused; + next.refusal_code = mint::MintRefusal::of(&error).map(|e| e.code); + persist(j, &next).await?; + *r = next; + return Ok(()); + } + Probe::Spent => return finish(j, r, ReceiveState::AlreadySpent).await, + Probe::Pending => { + return Err(error.context("inputs PENDING after refusal; retained")); + } + }, + // 50000, TokenPending, unknown codes, non-NUT 400s, 429/5xx, timeouts. + Err(error) => { + return Err(error.context( + "no definitive swap reply; identical swap retained for replay", + )); + } + } + } + }; + ensure!( + signatures.len() == r.outputs.len(), + "signature count mismatch" + ); + for (s, o) in signatures.iter().zip(&r.outputs) { + ensure!( + s.amount == o.message.amount && s.keyset_id == o.message.keyset_id, + "signature amount/keyset mismatch" + ); + } + let w = wallet(home, &r.mint).await?; + let keys = bounded(w.load_keyset_keys(r.outputs[0].message.keyset_id)) + .await + .context("CDK wallet request timed out")??; + let proofs = cdk::dhke::construct_proofs( + signatures, + r.outputs + .iter() + .map(|o| o.r.parse()) + .collect::, _>>()?, + r.outputs.iter().map(|o| o.secret.clone()).collect(), + &keys, + )?; + let mut next = r.clone(); + next.result = Some(proofs); + next.state = ReceiveState::Submitted; + persist(j, &next).await?; + *r = next; + #[cfg(feature = "lab")] + if std::env::var("TRADE_CRASH_AFTER_RECEIVE_SWAP") + .ok() + .as_deref() + == Some("1") + { + std::process::exit(86); + } + } + let result = r.result.clone().context("missing receive result")?; + // Credit only DLEQ-verified proofs. Signed-but-unverifiable outputs need a human. + if result.iter().any(|p| p.dleq.is_none()) { + eprintln!("receive {}: mint omitted DLEQ; quarantined", r.id); + return finish(j, r, ReceiveState::Quarantined).await; + } + let w = wallet(home, &r.mint).await?; + match bounded(w.verify_token_dleq(&Token::new( + MintUrl::from_str(&r.mint)?, + result.clone(), + None, + CurrencyUnit::Sat, + ))) + .await + .context("CDK wallet request timed out")? + { + Ok(()) => {} + Err(cdk::Error::CouldNotVerifyDleq) => { + eprintln!("receive {}: DLEQ verification failed; quarantined", r.id); + return finish(j, r, ReceiveState::Quarantined).await; + } + Err(e) => return Err(e.into()), + } + // Insert only rows not yet present: a crash after the commit must not reset proofs + // that a later command already reserved or spent. + let db = database(home, &r.mint).await?; + let present: HashSet<_> = db + .get_proofs_by_ys(result.ys()?) + .await? + .into_iter() + .map(|p| p.y) + .collect(); + let missing: Proofs = result + .into_iter() + .filter(|p| p.y().is_ok_and(|y| !present.contains(&y))) + .collect(); + if !missing.is_empty() { + mint::unspent(&r.mint, &missing).await?; + let url = MintUrl::from_str(&r.mint)?; + let op = operation(r)?; + // RESERVED under this receipt until `done` is journaled: withdraw/send select + // only unreserved UNSPENT rows, so nothing can spend them before that. + db.update_proofs( + missing + .into_iter() + .map(|p| { + ProofInfo::new_with_operations( + p, + url.clone(), + State::Reserved, + CurrencyUnit::Sat, + Some(op), + Some(op), + ) + }) + .collect::, _>>()?, + vec![], + ) + .await?; + } + #[cfg(feature = "lab")] + if std::env::var("TRADE_CRASH_BEFORE_RECEIVE_DONE") + .ok() + .as_deref() + == Some("1") + { + std::process::exit(85); + } + finish(j, r, ReceiveState::Done).await?; + #[cfg(feature = "lab")] + if std::env::var("TRADE_CRASH_AFTER_RECEIVE_DONE") + .ok() + .as_deref() + == Some("1") + { + std::process::exit(84); + } + release(home, j, r).await +} + +/// One bounded pass over non-terminal receives. True when any remain unresolved. +pub async fn recover(home: &Path, j: &Journal) -> Result { + let mut failed = false; + for mut r in j.all::("receive").await? { + if r.terminal() { + if let Err(error) = release(home, j, &mut r).await { + failed = true; + eprintln!("receive {}: release deferred: {error:#}", r.id); + } + continue; + } + let item = tokio::time::timeout( + std::time::Duration::from_secs(RECOVERY_ITEM_SECONDS), + resume(home, j, &mut r), + ) + .await; + if !matches!(item, Ok(Ok(()))) || !r.terminal() { + failed = true; + eprintln!( + "receive {}: recovery deferred (error or timeout); attempt retained", + r.id + ); + } + } + Ok(failed) +} + +pub async fn pending(j: &Journal) -> Result { + Ok(j.all::("receive") + .await? + .iter() + .any(Receipt::unresolved)) +} + +#[cfg(test)] +mod tests { + use super::*; + const NOSTR_MINT: &str = + "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + fn proof(secret: Secret) -> Proof { + Proof::new( + 1.into(), + Id::from_str("009a1f293253e41e").unwrap(), + secret, + SecretKey::generate().public_key(), + ) + } + /// cdk 0.17.2 MintUrl/Token accept a canonical nostr:// mint; whether `receive` + /// accepts it is decided solely by this crate's `Asset` canonicalization. + #[test] + fn cdk_token_parsing_accepts_nostr_mint_url() { + let url = MintUrl::from_str(NOSTR_MINT).unwrap(); + assert_eq!(url.to_string(), NOSTR_MINT); + let encoded = Token::new( + url.clone(), + vec![proof(Secret::generate())], + None, + CurrencyUnit::Sat, + ) + .to_string(); + let decoded = Token::from_str(&encoded).unwrap(); + assert_eq!(decoded.mint_url().unwrap(), url); + assert_eq!( + inspect(NOSTR_MINT, &encoded).is_ok(), + crate::Asset::new(NOSTR_MINT).is_ok(), + "receive gating must follow Asset for nostr:// mints" + ); + } + #[test] + fn locked_and_duplicate_proofs_refused_statically() { + let mint = "http://127.0.0.1:1"; + let token = |proofs| { + Token::new( + MintUrl::from_str(mint).unwrap(), + proofs, + None, + CurrencyUnit::Sat, + ) + .to_string() + }; + let locked: Secret = cashu::nuts::nut10::Secret::from(SpendingConditions::new_p2pk( + SecretKey::generate().public_key(), + None, + )) + .try_into() + .unwrap(); + assert!( + inspect(mint, &token(vec![proof(locked)])).is_err(), + "SAFETY: P2PK refused" + ); + let htlc: Secret = cashu::nuts::nut10::Secret::from( + SpendingConditions::new_htlc_hash(&"ab".repeat(32), None).unwrap(), + ) + .try_into() + .unwrap(); + assert!( + inspect(mint, &token(vec![proof(htlc)])) + .is_err_and(|e| e.to_string().contains("locked")), + "SAFETY: HTLC refused" + ); + let s = Secret::generate(); + let dup = inspect(mint, &token(vec![proof(s.clone()), proof(s)])); + assert!( + dup.as_ref() + .is_err_and(|e| e.to_string().contains("duplicate proof")), + "SAFETY: duplicate refused by our own check" + ); + let many = (0..=MAX_PROOFS) + .map(|_| proof(Secret::generate())) + .collect(); + assert!( + inspect(mint, &token(many)).is_err(), + "SAFETY: >128 proofs refused" + ); + let mut big = proof(Secret::generate()); + big.amount = (crate::real_money::CAP + 1).into(); + assert!( + inspect(mint, &token(vec![big])).is_err(), + "SAFETY: >100,000 sats refused statically" + ); + assert!(inspect(mint, &token(vec![proof(Secret::generate())])).is_ok()); + assert!( + inspect( + "http://127.0.0.1:2", + &token(vec![proof(Secret::generate())]) + ) + .is_err(), + "SAFETY: wrong mint refused" + ); + } +} diff --git a/crates/maxplayer-trade/src/wallet.rs b/crates/maxplayer-trade/src/wallet.rs index 4bb6e111f..394af594a 100644 --- a/crates/maxplayer-trade/src/wallet.rs +++ b/crates/maxplayer-trade/src/wallet.rs @@ -192,7 +192,7 @@ pub fn read_status(home: &std::path::Path) -> Result { let db = rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; db.busy_timeout(std::time::Duration::from_secs(2))?; - let mut stmt = db.prepare("SELECT secondary_namespace, value FROM kv_store WHERE primary_namespace='trade-v1' AND secondary_namespace IN ('swap','funding','withdrawal')")?; + let mut stmt = db.prepare("SELECT secondary_namespace, value FROM kv_store WHERE primary_namespace='trade-v1' AND secondary_namespace IN ('swap','funding','withdrawal','receive')")?; let mut records = vec![]; for row in stmt.query_map([], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, Vec>(1)?)) @@ -201,6 +201,11 @@ pub fn read_status(home: &std::path::Path) -> Result { let v: serde_json::Value = serde_json::from_slice(&bytes)?; let public = if kind == "withdrawal" { serde_json::from_slice::(&bytes)?.summary() + } else if kind == "receive" { + // Same public view as `recover`; the token is never part of it. + let mut s = serde_json::from_slice::(&bytes)?.summary(); + s["kind"] = kind.clone().into(); + s } else { serde_json::json!({"kind":kind,"id":v["id"],"state":v["state"],"done":v["done"],"expired_unpaid":v["expired_unpaid"]}) }; diff --git a/crates/maxplayer-trade/tests/receive.rs b/crates/maxplayer-trade/tests/receive.rs new file mode 100644 index 000000000..ee0e919e0 --- /dev/null +++ b/crates/maxplayer-trade/tests/receive.rs @@ -0,0 +1,1011 @@ +//! `receive`: import a Cashu token. In-process fake mints only. SAFETY assertions first. +mod support; +use cashu::nuts::{CurrencyUnit, Proof, Proofs, SecretKey, Token, TokenV3}; +use cdk::mint_url::MintUrl; +use maxplayer_trade::{ + journal::Journal, + money, + receive::{self, Receipt, ReceiveState}, + wallet, +}; +use std::{path::Path, str::FromStr, sync::atomic::Ordering::SeqCst}; +use support::*; + +/// A token for `n` sats issued to an unrelated sender home (stands in for maxplayer-mint issue). +async fn issue(m: &MintFixture, n: u64) -> (tempfile::TempDir, Proofs, String) { + let sender = tempfile::tempdir().unwrap(); + fund(sender.path(), &m.url, n).await; + let proofs = wallet::wallet(sender.path(), &m.url) + .await + .unwrap() + .get_unspent_proofs() + .await + .unwrap(); + assert!( + proofs.iter().all(|p| p.dleq.is_some()), + "fixture issues DLEQ" + ); + let token = encode(&m.url, proofs.clone()); + (sender, proofs, token) +} +fn encode(mint: &str, proofs: Proofs) -> String { + Token::new( + MintUrl::from_str(mint).unwrap(), + proofs, + None, + CurrencyUnit::Sat, + ) + .to_string() +} +async fn home() -> (tempfile::TempDir, Journal) { + let h = tempfile::tempdir().unwrap(); + let j = Journal::open(h.path()).await.unwrap(); + (h, j) +} +async fn receipts(j: &Journal) -> Vec { + j.all::("receive").await.unwrap() +} +fn fake(secret: cashu::secret::Secret) -> Proof { + Proof::new( + 1.into(), + cashu::nuts::Id::from_str("009a1f293253e41e").unwrap(), + secret, + SecretKey::generate().public_key(), + ) +} + +#[tokio::test] +async fn happy_path_deducts_input_fee_and_credits_net() { + let m = MintFixture::start(100).await; + let (_s, proofs, token) = issue(&m, 100).await; + let (h, j) = home().await; + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + let fee = (proofs.len() as u64 * 100).div_ceil(1000); + assert_eq!(r.state, ReceiveState::Done, "SAFETY: credited exactly once"); + assert_eq!(m.faults.swaps.load(SeqCst), 1, "SAFETY: exactly one swap"); + assert_eq!((r.amount, r.fee, r.net), (100, fee, 100 - fee)); + assert_eq!(balance(h.path(), &m.url).await, 100 - fee); + assert_eq!(r.summary()["credited"], 100 - fee); + // Recovery is idempotent for a terminal receive. + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(balance(h.path(), &m.url).await, 100 - fee); +} + +#[tokio::test] +async fn lost_swap_reply_restores_same_outputs_without_second_swap() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 64).await; + let (h, j) = home().await; + m.faults.lose_reply.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!( + r.state, + ReceiveState::Submitted, + "SAFETY: ambiguous, not refused" + ); + assert_eq!(balance(h.path(), &m.url).await, 0); + assert_eq!(m.faults.swaps.load(SeqCst), 1); + m.faults.lose_reply.store(false, SeqCst); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!( + m.faults.swaps.load(SeqCst), + 1, + "SAFETY: restore, never a second swap" + ); + assert_eq!(balance(h.path(), &m.url).await, 64, "SAFETY: credited once"); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Done); + money::recover(h.path(), &j).await.unwrap(); + let again = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!(again.state, ReceiveState::Done); + assert_eq!(m.faults.swaps.load(SeqCst), 1); + assert_eq!(balance(h.path(), &m.url).await, 64); +} + +#[tokio::test] +async fn double_receive_swaps_once_credits_once_even_reencoded() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 48).await; + let (h, j) = home().await; + let first = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + let v3 = Token::from_str(&token).unwrap().to_v3_string(); + let second = receive::receive(h.path(), &j, &m.url, &v3).await.unwrap(); + assert_eq!(first.id, second.id, "SAFETY: idempotent per Y-set"); + assert_eq!(m.faults.swaps.load(SeqCst), 1, "SAFETY: one swap"); + assert_eq!(balance(h.path(), &m.url).await, 48, "SAFETY: one credit"); + assert_eq!(receipts(&j).await.len(), 1); +} + +#[tokio::test] +async fn already_spent_token_refused_and_nothing_credited() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 32).await; + let (first, j1) = home().await; + receive::receive(first.path(), &j1, &m.url, &token) + .await + .unwrap(); + let (h, j) = home().await; + let swaps = m.faults.swaps.load(SeqCst); + assert!( + receive::receive(h.path(), &j, &m.url, &token) + .await + .is_err() + ); + assert_eq!( + balance(h.path(), &m.url).await, + 0, + "SAFETY: nothing credited" + ); + assert_eq!(m.faults.swaps.load(SeqCst), swaps, "SAFETY: no swap POST"); + assert!(receipts(&j).await.is_empty()); +} + +#[tokio::test] +async fn invalid_tokens_refused_before_journal() { + let a = MintFixture::start(0).await; + let b = MintFixture::start(0).await; + let (_s, proofs, token) = issue(&a, 40).await; + let (h, j) = home().await; + let mut bad_dleq = proofs.clone(); + for p in &mut bad_dleq { + p.dleq.as_mut().unwrap().e = SecretKey::generate(); + } + let locked: cashu::secret::Secret = cashu::nuts::nut10::Secret::from( + cashu::nuts::SpendingConditions::new_p2pk(SecretKey::generate().public_key(), None), + ) + .try_into() + .unwrap(); + let multi = Token::TokenV3(TokenV3 { + token: vec![ + cashu::nuts::nut00::token::TokenV3Token::new( + MintUrl::from_str(&a.url).unwrap(), + proofs[..1].to_vec(), + ), + cashu::nuts::nut00::token::TokenV3Token::new( + MintUrl::from_str(&b.url).unwrap(), + proofs[1..].to_vec(), + ), + ], + memo: None, + unit: Some(CurrencyUnit::Sat), + }) + .to_string(); + let msat = Token::new( + MintUrl::from_str(&a.url).unwrap(), + proofs.clone(), + None, + CurrencyUnit::Msat, + ) + .to_string(); + let cases = [ + ("wrong mint", b.url.clone(), token.clone()), + ("p2pk", a.url.clone(), encode(&a.url, vec![fake(locked)])), + ("multi-mint", a.url.clone(), multi), + ( + "129 proofs", + a.url.clone(), + encode( + &a.url, + (0..129) + .map(|_| fake(cashu::secret::Secret::generate())) + .collect(), + ), + ), + ("bad dleq", a.url.clone(), encode(&a.url, bad_dleq)), + ("msat unit", a.url.clone(), msat), + ("garbage", a.url.clone(), "cashuBnotatoken".into()), + ]; + for (name, mint, t) in cases { + let e = receive::receive(h.path(), &j, &mint, &t) + .await + .err() + .unwrap_or_else(|| panic!("SAFETY: {name} must be refused")); + assert!(receipts(&j).await.is_empty(), "SAFETY: {name} journaled"); + assert!( + !format!("{e:#}").contains(&t), + "SAFETY: {name} echoed token" + ); + } + assert_eq!(a.faults.swaps.load(SeqCst) + b.faults.swaps.load(SeqCst), 0); + // The untouched original remains receivable afterwards. + let r = receive::receive(h.path(), &j, &a.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Done); +} + +#[tokio::test] +async fn cap_exceeded_refused_before_post_and_shared_with_funding() { + let m = MintFixture::start(0).await; + let (_s, _, big) = issue(&m, 100_001).await; + let (h, j) = home().await; + assert!(receive::receive(h.path(), &j, &m.url, &big).await.is_err()); + assert_eq!(m.faults.swaps.load(SeqCst), 0, "SAFETY: no POST over cap"); + assert!(receipts(&j).await.is_empty()); + // Cumulative: a retained 99,990-sat funding intent leaves room for at most 10. + money::fund(h.path(), &j, &m.url, 99_990).await.unwrap(); + let (_s2, _, small) = issue(&m, 64).await; + let e = receive::receive(h.path(), &j, &m.url, &small) + .await + .err() + .expect("SAFETY: cumulative cap"); + assert!(format!("{e:#}").contains("cumulative")); + assert_eq!(m.faults.swaps.load(SeqCst), 0); + // Receives also count toward later funding. + let (h2, j2) = home().await; + receive::receive(h2.path(), &j2, &m.url, &small) + .await + .unwrap(); + assert!(money::fund(h2.path(), &j2, &m.url, 99_950).await.is_err()); + assert!(money::fund(h2.path(), &j2, &m.url, 99_936).await.is_ok()); +} + +fn run( + home: &Path, + mint: &str, + file: Option<&Path>, + stdin: Option<&str>, + env: Option<&str>, +) -> std::process::Output { + use std::io::Write; + let mut c = std::process::Command::new(env!("CARGO_BIN_EXE_maxplayer-trade")); + c.args(["--home", home.to_str().unwrap(), "receive", mint]); + if let Some(f) = file { + c.args(["--token-file", f.to_str().unwrap()]); + } + if let Some(var) = env { + c.env(var, "1"); + } + c.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = c.spawn().unwrap(); + let mut input = child.stdin.take().unwrap(); + if let Some(s) = stdin { + input.write_all(s.as_bytes()).unwrap(); + } + drop(input); + child.wait_with_output().unwrap() +} +async fn cli( + home: &Path, + mint: &str, + file: Option<&Path>, + stdin: Option<&str>, + env: Option<&'static str>, +) -> std::process::Output { + let (home, mint) = (home.to_owned(), mint.to_owned()); + let (file, stdin) = (file.map(Path::to_owned), stdin.map(str::to_owned)); + tokio::task::spawn_blocking(move || run(&home, &mint, file.as_deref(), stdin.as_deref(), env)) + .await + .unwrap() +} +fn assert_secret_free(out: &std::process::Output, token: &str, proofs: &Proofs) { + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!(!text.contains(token), "SAFETY: token printed"); + for p in proofs { + assert!( + !text.contains(&p.secret.to_string()), + "SAFETY: secret printed" + ); + assert!(!text.contains(&p.c.to_hex()), "SAFETY: proof C printed"); + } +} + +#[tokio::test] +async fn cli_never_prints_token_and_never_takes_it_as_argv() { + let help = std::process::Command::new(env!("CARGO_BIN_EXE_maxplayer-trade")) + .args(["receive", "--help"]) + .output() + .unwrap(); + let help = String::from_utf8(help.stdout).unwrap(); + assert!( + help.contains("receive [OPTIONS] \n"), + "SAFETY: the only positional is the mint: {help}" + ); + let arguments = help + .split("Arguments:") + .nth(1) + .unwrap() + .split("Options:") + .next() + .unwrap(); + assert_eq!( + arguments.split_whitespace().collect::>(), + [""], + "SAFETY: no token positional" + ); + assert!(help.contains("--token-file")); + let m = MintFixture::start(0).await; + let (_s, proofs, token) = issue(&m, 24).await; + let (h, _j) = home().await; + let file = h.path().join("in.token"); + std::fs::write(&file, format!("{token}\n")).unwrap(); + // Wrong mint first: a refusal must not echo the token either. + let other = MintFixture::start(0).await; + let refused = cli(h.path(), &other.url, Some(&file), None, None).await; + assert_eq!(refused.status.code(), Some(1)); + assert_secret_free(&refused, &token, &proofs); + let out = cli(h.path(), &m.url, Some(&file), None, None).await; + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert_secret_free(&out, &token, &proofs); + let summary: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!( + (summary["state"].as_str(), summary["net"].as_u64()), + (Some("done"), Some(24)) + ); + // Stdin path: same token is idempotent, still secret-free. + let again = cli(h.path(), &m.url, None, Some(&token), None).await; + assert!(again.status.success()); + assert_secret_free(&again, &token, &proofs); + assert_eq!(m.faults.swaps.load(SeqCst), 1); + // Lost reply exits 3 (unresolved), not 1. + let (_s2, proofs2, token2) = issue(&m, 16).await; + m.faults.lose_reply.store(true, SeqCst); + let pending = cli(h.path(), &m.url, None, Some(&token2), None).await; + assert_eq!( + pending.status.code(), + Some(3), + "SAFETY: ambiguous is exit 3" + ); + assert_secret_free(&pending, &token2, &proofs2); + m.faults.lose_reply.store(false, SeqCst); + let status = std::process::Command::new(env!("CARGO_BIN_EXE_maxplayer-trade")) + .args(["--home", h.path().to_str().unwrap(), "status"]) + .output() + .unwrap(); + assert_secret_free(&status, &token2, &proofs2); + assert!(String::from_utf8_lossy(&status.stdout).contains("\"receive\"")); +} + +#[cfg(feature = "lab")] +#[tokio::test] +async fn crash_after_journal_before_post_then_recover_completes() { + let m = MintFixture::start(0).await; + let (_s, proofs, token) = issue(&m, 56).await; + let (h, j) = home().await; + let out = cli( + h.path(), + &m.url, + None, + Some(&token), + Some("TRADE_CRASH_BEFORE_RECEIVE_SWAP"), + ) + .await; + assert_eq!(out.status.code(), Some(87)); + assert_secret_free(&out, &token, &proofs); + assert_eq!( + m.faults.swaps.load(SeqCst), + 0, + "SAFETY: crashed before POST" + ); + assert_eq!(receipts(&j).await.len(), 1, "SAFETY: journaled before POST"); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(m.faults.swaps.load(SeqCst), 1); + assert_eq!(balance(h.path(), &m.url).await, 56); +} + +#[cfg(feature = "lab")] +#[tokio::test] +async fn crash_after_post_before_wallet_commit_then_recover_completes() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 56).await; + let (h, j) = home().await; + let out = cli( + h.path(), + &m.url, + None, + Some(&token), + Some("TRADE_CRASH_AFTER_RECEIVE_SWAP"), + ) + .await; + assert_eq!(out.status.code(), Some(86)); + assert_eq!(m.faults.swaps.load(SeqCst), 1); + assert_eq!(balance(h.path(), &m.url).await, 0, "crashed before credit"); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(m.faults.swaps.load(SeqCst), 1, "SAFETY: no second swap"); + assert_eq!(balance(h.path(), &m.url).await, 56, "SAFETY: credited once"); + money::recover(h.path(), &j).await.unwrap(); + assert_eq!(balance(h.path(), &m.url).await, 56); +} + +#[cfg(feature = "lab")] +#[tokio::test] +async fn journaled_token_spent_elsewhere_is_terminal_already_spent() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + let out = cli( + h.path(), + &m.url, + None, + Some(&token), + Some("TRADE_CRASH_BEFORE_RECEIVE_SWAP"), + ) + .await; + assert_eq!(out.status.code(), Some(87)); + let (other, jo) = home().await; + receive::receive(other.path(), &jo, &m.url, &token) + .await + .unwrap(); + let swaps = m.faults.swaps.load(SeqCst); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(receipts(&j).await[0].state, ReceiveState::AlreadySpent); + assert_eq!( + m.faults.swaps.load(SeqCst), + swaps, + "SAFETY: no POST of spent inputs" + ); + assert_eq!( + balance(h.path(), &m.url).await, + 0, + "SAFETY: nothing credited" + ); +} + +#[tokio::test] +async fn definitive_refusal_is_terminal_refused() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + // A NUT error code from the swap with inputs still UNSPENT. + m.faults.swap_nut_error.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Refused, "SAFETY: definitive refusal"); + assert_eq!(balance(h.path(), &m.url).await, 0); + assert!(!money::recover(h.path(), &j).await.unwrap(), "terminal"); + assert_eq!( + m.faults.swaps.load(SeqCst), + 1, + "SAFETY: no replay after refusal" + ); +} + +#[tokio::test] +async fn invalid_returned_dleq_quarantines_without_credit() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.invalid_dleq.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!( + r.state, + ReceiveState::Quarantined, + "SAFETY: never credit bad DLEQ" + ); + assert_eq!(balance(h.path(), &m.url).await, 0); + m.faults.invalid_dleq.store(false, SeqCst); + money::recover(h.path(), &j).await.unwrap(); + assert_eq!( + balance(h.path(), &m.url).await, + 0, + "SAFETY: quarantine terminal" + ); +} + +#[tokio::test] +async fn omitted_returned_dleq_quarantines_without_credit() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.omit_dleq.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!( + r.state, + ReceiveState::Quarantined, + "SAFETY: never credit proofs without DLEQ" + ); + assert_eq!(balance(h.path(), &m.url).await, 0); + assert_eq!(r.summary()["credited"], 0); +} + +fn state_of(out: &std::process::Output) -> serde_json::Value { + serde_json::from_slice(&out.stdout).unwrap_or(serde_json::Value::Null) +} + +#[tokio::test] +async fn non_nut_400_stays_submitted_and_a_later_receive_swaps() { + let m = MintFixture::start(0).await; + let (_s, proofs, token) = issue(&m, 40).await; + let (h, j) = home().await; + // 1: non-JSON 400 body. 2: JSON 400 without a numeric NUT `code` (e.g. a + // transport `expired`). Neither proves the mint refused this request. + for (bad, swaps) in [(1, 1), (2, 2)] { + m.faults.swap_bad_400.store(bad, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!( + r.state, + ReceiveState::Submitted, + "SAFETY: a non-NUT 400 ({bad}) is not a definitive refusal" + ); + assert!(!r.terminal()); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Submitted); + assert_eq!(m.faults.swaps.load(SeqCst), swaps); + } + // The CLI reports it as unresolved (exit 3), never exit 1. + let out = cli(h.path(), &m.url, None, Some(&token), None).await; + assert_eq!(out.status.code(), Some(3), "SAFETY: ambiguous is exit 3"); + assert_secret_free(&out, &token, &proofs); + m.faults.swap_bad_400.store(0, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Done, "SAFETY: same outputs replayed"); + assert_eq!(balance(h.path(), &m.url).await, 40); + assert_eq!(receipts(&j).await.len(), 1); +} + +#[tokio::test] +async fn pending_inputs_after_nut_error_stay_non_terminal() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.swap_nut_error.store(true, SeqCst); + m.faults.pending_after_swap.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!( + r.state, + ReceiveState::Submitted, + "SAFETY: PENDING inputs are never terminal" + ); + assert!(money::recover(h.path(), &j).await.unwrap(), "still pending"); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Submitted); + m.faults.pending_inputs.store(false, SeqCst); + m.faults.swap_nut_error.store(false, SeqCst); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Done); + assert_eq!(balance(h.path(), &m.url).await, 40); +} + +#[tokio::test] +async fn partial_restore_stays_non_terminal_without_second_swap() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 63).await; // several outputs + let (h, j) = home().await; + m.faults.lose_reply.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Submitted); + m.faults.lose_reply.store(false, SeqCst); + m.faults.partial_restore.store(true, SeqCst); + assert!(money::recover(h.path(), &j).await.unwrap(), "unresolved"); + let r = &receipts(&j).await[0]; + assert_eq!(r.state, ReceiveState::Submitted, "SAFETY: partial restore"); + assert_eq!(m.faults.swaps.load(SeqCst), 1, "SAFETY: no second swap"); + assert_eq!(balance(h.path(), &m.url).await, 0); + m.faults.partial_restore.store(false, SeqCst); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(m.faults.swaps.load(SeqCst), 1); + assert_eq!(balance(h.path(), &m.url).await, 63, "SAFETY: credited once"); +} + +#[tokio::test] +async fn cap_charges_gross_with_input_fees() { + let m = MintFixture::start(100).await; + let (_s, _, token) = issue(&m, 100).await; + let (h, j) = home().await; + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert!(r.fee > 0 && r.net < r.amount, "fixture charges a fee"); + let e = money::fund(h.path(), &j, &m.url, 100_000 - r.net) + .await + .err() + .expect("SAFETY: the cap counts gross, not net"); + assert!(format!("{e:#}").contains("receives"), "{e:#}"); + money::fund(h.path(), &j, &m.url, 100_000 - r.amount) + .await + .unwrap(); +} + +#[tokio::test] +async fn quarantined_receive_still_occupies_the_cap() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.omit_dleq.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Quarantined); + assert_eq!(balance(h.path(), &m.url).await, 0); + m.faults.omit_dleq.store(false, SeqCst); + let Err(err) = money::fund(h.path(), &j, &m.url, 99_961).await else { + panic!("SAFETY: quarantined receive holds the cap"); + }; + assert!( + err.to_string().contains("receives"), + "cap error names receives: {err}" + ); + money::fund(h.path(), &j, &m.url, 99_960).await.unwrap(); +} + +#[tokio::test] +async fn refused_receive_does_not_consume_the_cap() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 64).await; + let (h, j) = home().await; + m.faults.swap_nut_error.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Refused); + m.faults.swap_nut_error.store(false, SeqCst); + money::fund(h.path(), &j, &m.url, 100_000) + .await + .expect("refused receives do not count toward the cap"); +} + +#[tokio::test] +async fn overlapping_ys_with_a_charged_receive_are_refused() { + let m = MintFixture::start(0).await; + let (_s, proofs, token) = issue(&m, 63).await; + assert!(proofs.len() > 1); + let (h, j) = home().await; + m.faults.lose_reply.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Submitted, "charged, unresolved"); + m.faults.lose_reply.store(false, SeqCst); + let subset = encode(&m.url, proofs[..1].to_vec()); + let swaps = m.faults.swaps.load(SeqCst); + let e = receive::receive(h.path(), &j, &m.url, &subset) + .await + .err() + .expect("SAFETY: overlapping Ys refused"); + assert!( + format!("{e:#}").contains("overlaps earlier receive"), + "{e:#}" + ); + assert_eq!(receipts(&j).await.len(), 1, "SAFETY: nothing journaled"); + assert_eq!(m.faults.swaps.load(SeqCst), swaps, "SAFETY: no POST"); +} + +#[tokio::test] +async fn cli_exit_codes_for_journaled_refused_and_quarantined() { + let m = MintFixture::start(0).await; + let (h, j) = home().await; + let (_s, proofs, token) = issue(&m, 40).await; + m.faults.swap_nut_error.store(true, SeqCst); + let out = cli(h.path(), &m.url, None, Some(&token), None).await; + assert_eq!(out.status.code(), Some(1), "SAFETY: refused exits 1"); + assert_eq!(state_of(&out)["state"], "refused"); + assert_secret_free(&out, &token, &proofs); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Refused); + m.faults.swap_nut_error.store(false, SeqCst); + let (_s2, proofs2, token2) = issue(&m, 24).await; + m.faults.omit_dleq.store(true, SeqCst); + let out = cli(h.path(), &m.url, None, Some(&token2), None).await; + assert_eq!(out.status.code(), Some(4), "SAFETY: quarantined exits 4"); + assert_eq!(state_of(&out)["manual_recovery"], true); + assert_secret_free(&out, &token2, &proofs2); + m.faults.omit_dleq.store(false, SeqCst); + // status: same public fields as recover for receives, never the token. + let status = std::process::Command::new(env!("CARGO_BIN_EXE_maxplayer-trade")) + .args(["--home", h.path().to_str().unwrap(), "status"]) + .output() + .unwrap(); + assert_secret_free(&status, &token2, &proofs2); + let v: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap(); + let rec = v["records"].as_array().unwrap(); + let q = rec.iter().find(|r| r["state"] == "quarantined").unwrap(); + for k in ["state", "terminal", "manual_recovery", "amount", "credited"] { + assert!(!q[k].is_null(), "status receive lacks {k}: {q}"); + } + assert_eq!( + (q["kind"].as_str(), q["terminal"].as_bool()), + (Some("receive"), Some(true)) + ); + assert_eq!(q["manual_recovery"], true); + assert_eq!(q["credited"], 0); + assert!(q.get("token").is_none() && q.get("inputs").is_none()); +} + +#[cfg(feature = "lab")] +#[tokio::test] +async fn cli_exit_1_for_journaled_already_spent() { + let m = MintFixture::start(0).await; + let (_s, proofs, token) = issue(&m, 40).await; + let (h, j) = home().await; + let out = cli( + h.path(), + &m.url, + None, + Some(&token), + Some("TRADE_CRASH_BEFORE_RECEIVE_SWAP"), + ) + .await; + assert_eq!(out.status.code(), Some(87)); + let (other, jo) = home().await; + receive::receive(other.path(), &jo, &m.url, &token) + .await + .unwrap(); + let out = cli(h.path(), &m.url, None, Some(&token), None).await; + assert_eq!(out.status.code(), Some(1), "SAFETY: already_spent exits 1"); + assert_eq!(state_of(&out)["state"], "already_spent"); + assert_secret_free(&out, &token, &proofs); + assert_eq!(receipts(&j).await[0].state, ReceiveState::AlreadySpent); +} + +#[cfg(feature = "lab")] +#[tokio::test] +async fn failed_terminal_journal_write_exits_3_and_journal_unchanged() { + let m = MintFixture::start(0).await; + let (_s, proofs, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.swap_nut_error.store(true, SeqCst); + let out = cli( + h.path(), + &m.url, + None, + Some(&token), + Some("TRADE_FAIL_RECEIVE_TERMINAL_WRITE"), + ) + .await; + assert_eq!( + out.status.code(), + Some(3), + "SAFETY: an unpersisted refusal is not reported as refused: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + state_of(&out)["state"], + "submitted", + "SAFETY: printed state = journal" + ); + assert_secret_free(&out, &token, &proofs); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Submitted); + // Without the fault the refusal is journaled and reported. + let out = cli(h.path(), &m.url, None, Some(&token), None).await; + assert_eq!(out.status.code(), Some(1)); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Refused); +} + +// ---- Review round 2 ---- + +/// F1: a refusal answered for a swap that DID land, with the first post-refusal +/// restore empty. The refusal path must restore again after NUT-07 SPENT. +#[tokio::test] +async fn refusal_racing_a_landed_swap_restores_twice_and_credits() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.refuse_after_swap.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!( + r.state, + ReceiveState::Done, + "SAFETY: our signed outputs are credited, never already_spent" + ); + assert_eq!(balance(h.path(), &m.url).await, 40, "SAFETY: credited"); + assert_eq!(m.faults.swaps.load(SeqCst), 1, "SAFETY: one swap"); +} + +/// F2: cdk answers transient failures (Internal/Database -> 50000), TokenPending, +/// OutputsPending and unknown codes as HTTP 400 NUT bodies. None is a refusal. +#[tokio::test] +async fn ambiguous_nut_codes_stay_submitted_then_complete() { + for code in [50000u64, 11002, 11004, 99999] { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.swap_nut_code.store(code, SeqCst); + m.faults.swap_nut_error.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!( + r.state, + ReceiveState::Submitted, + "SAFETY: NUT code {code} is not a definitive refusal" + ); + assert!(money::recover(h.path(), &j).await.unwrap(), "{code}"); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Submitted); + m.faults.swap_nut_error.store(false, SeqCst); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Done); + assert_eq!(balance(h.path(), &m.url).await, 40, "{code}"); + } +} + +/// F2 straggler: a 50000 answered for a swap that executed stays submitted and the +/// next pass restores and credits it. +#[tokio::test] +async fn transient_50000_for_a_landed_swap_is_credited_on_recover() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.swap_nut_code.store(50000, SeqCst); + m.faults.refuse_after_swap.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Submitted, "SAFETY: 50000 ambiguous"); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Done); + assert_eq!(balance(h.path(), &m.url).await, 40); + assert_eq!(m.faults.swaps.load(SeqCst), 1, "SAFETY: one swap"); +} + +/// Round 3 (Bob's decision (a), limited retry): against cdk 0.17.2 no allowlisted code +/// can succeed on an identical replay, so the retryable set is EMPTY and every refusal +/// is final. Here an output-side code (12002 KeysetInactive, the reviewer's rotated +/// keyset case) is journaled; a repeat `receive` (even after the fault clears) never +/// POSTs again, stays `refused`, exits 1 with "not imported" and frees the cap. +#[tokio::test] +async fn output_side_refusal_is_final_frees_cap_and_exits_1() { + let m = MintFixture::start(0).await; + let (_s, proofs, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.swap_nut_code.store(12002, SeqCst); + m.faults.swap_nut_error.store(true, SeqCst); + let out = cli(h.path(), &m.url, None, Some(&token), None).await; + assert_eq!(out.status.code(), Some(1), "SAFETY: final refusal exits 1"); + assert_eq!(state_of(&out)["state"], "refused"); + assert_eq!(state_of(&out)["refusal_code"], 12002); + assert_secret_free(&out, &token, &proofs); + let r = &receipts(&j).await[0]; + assert_eq!( + (r.state, r.refusal_code), + (ReceiveState::Refused, Some(12002)) + ); + m.faults.swap_nut_error.store(false, SeqCst); + let swaps = m.faults.swaps.load(SeqCst); + let out = cli(h.path(), &m.url, None, Some(&token), None).await; + assert_eq!(out.status.code(), Some(1), "SAFETY: repeat stays final"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("NOT imported") && err.contains("redeemed elsewhere"), + "final refusal says so: {err}" + ); + assert_eq!( + m.faults.swaps.load(SeqCst), + swaps, + "SAFETY: a final refusal is never replayed" + ); + assert!(!money::recover(h.path(), &j).await.unwrap(), "terminal"); + assert_eq!( + m.faults.swaps.load(SeqCst), + swaps, + "SAFETY: recover neither" + ); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Refused); + assert_eq!( + balance(h.path(), &m.url).await, + 0, + "SAFETY: nothing credited" + ); + money::fund(h.path(), &j, &m.url, 100_000) + .await + .expect("SAFETY: a final refusal frees the cap"); + // The token was not imported here: another home redeems it. + let (other, jo) = home().await; + let r = receive::receive(other.path(), &jo, &m.url, &token) + .await + .unwrap(); + assert_eq!(r.state, ReceiveState::Done, "token redeemable elsewhere"); +} + +/// A `refused` record written before round 3 has no journaled code: still final. +#[tokio::test] +async fn legacy_refused_record_without_code_is_final() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 40).await; + let (h, j) = home().await; + m.faults.swap_nut_error.store(true, SeqCst); + let r = receive::receive(h.path(), &j, &m.url, &token) + .await + .unwrap(); + assert_eq!( + (r.state, r.refusal_code), + (ReceiveState::Refused, Some(11001)) + ); + m.faults.swap_nut_error.store(false, SeqCst); + // Rewrite the record as the old head stored it: no `refusal_code` field. + let mut v = serde_json::to_value(&r).unwrap(); + v.as_object_mut().unwrap().remove("refusal_code"); + j.put("receive", &r.id, &v).await.unwrap(); + assert_eq!(receipts(&j).await[0].refusal_code, None); + let swaps = m.faults.swaps.load(SeqCst); + let out = cli(h.path(), &m.url, None, Some(&token), None).await; + assert_eq!(out.status.code(), Some(1), "SAFETY: legacy refusal final"); + assert!(state_of(&out)["refusal_code"].is_null()); + assert!(String::from_utf8_lossy(&out.stderr).contains("not journaled")); + assert_eq!(m.faults.swaps.load(SeqCst), swaps, "SAFETY: no replay"); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Refused); + money::fund(h.path(), &j, &m.url, 100_000) + .await + .expect("SAFETY: legacy refusal frees the cap"); +} + +/// F4: credited rows stay RESERVED (unspendable) until `done` is journaled. +#[cfg(feature = "lab")] +#[tokio::test] +async fn credited_rows_unspendable_until_done_is_journaled() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 56).await; + let (h, j) = home().await; + let out = cli( + h.path(), + &m.url, + None, + Some(&token), + Some("TRADE_CRASH_BEFORE_RECEIVE_DONE"), + ) + .await; + assert_eq!(out.status.code(), Some(85)); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Submitted); + assert_eq!( + balance(h.path(), &m.url).await, + 0, + "SAFETY: credited rows are not spendable before done" + ); + let invoice = cdk_fake_wallet::create_fake_invoice(20_000, String::new()).to_string(); + assert!( + money::withdraw(h.path(), &j, &m.url, &invoice) + .await + .is_err(), + "SAFETY: withdraw cannot spend undone receive rows" + ); + money::recover(h.path(), &j).await.unwrap(); + assert_eq!(receipts(&j).await[0].state, ReceiveState::Done); + assert!(receipts(&j).await[0].settled()); + assert_eq!(balance(h.path(), &m.url).await, 56, "SAFETY: credited once"); + assert_eq!(m.faults.swaps.load(SeqCst), 1); +} + +/// F4: a crash after `done` is journaled but before release leaves the rows reserved; +/// `recover` (terminal-record pass) releases them exactly once. +#[cfg(feature = "lab")] +#[tokio::test] +async fn crash_after_done_before_release_is_released_by_recover() { + let m = MintFixture::start(0).await; + let (_s, _, token) = issue(&m, 56).await; + let (h, j) = home().await; + let out = cli( + h.path(), + &m.url, + None, + Some(&token), + Some("TRADE_CRASH_AFTER_RECEIVE_DONE"), + ) + .await; + assert_eq!(out.status.code(), Some(84)); + let r = &receipts(&j).await[0]; + assert!(r.state == ReceiveState::Done && !r.settled()); + assert_eq!(balance(h.path(), &m.url).await, 0, "still reserved"); + money::recover(h.path(), &j).await.unwrap(); + assert!(receipts(&j).await[0].settled()); + assert_eq!(balance(h.path(), &m.url).await, 56, "SAFETY: released once"); + assert!(!money::recover(h.path(), &j).await.unwrap()); + assert_eq!(balance(h.path(), &m.url).await, 56); +} diff --git a/crates/maxplayer-trade/tests/support/mod.rs b/crates/maxplayer-trade/tests/support/mod.rs index a6fc1f51c..f105adbfb 100644 --- a/crates/maxplayer-trade/tests/support/mod.rs +++ b/crates/maxplayer-trade/tests/support/mod.rs @@ -65,6 +65,24 @@ pub struct Faults { pub delay_ms: std::sync::atomic::AtomicU64, /// Count of HTTP requests that reached this mint. pub requests: std::sync::atomic::AtomicU64, + /// Count of swap POSTs that reached this mint (including ones whose reply is lost). + pub swaps: std::sync::atomic::AtomicU64, + /// Answer every swap POST with a well-formed NUT error (HTTP 400 + code), unprocessed. + pub swap_nut_error: std::sync::atomic::AtomicBool, + /// NUT code used by `swap_nut_error` / `refuse_after_swap` (0 = 11001). + pub swap_nut_code: std::sync::atomic::AtomicU64, + /// Let the mint EXECUTE the next swap, then answer it with the NUT error above and + /// blank the next restore (models a lost-reply POST landing around a refusal). + pub refuse_after_swap: std::sync::atomic::AtomicBool, + /// Answer this many restores with an empty (but well-formed) reply. + pub blank_restores: std::sync::atomic::AtomicU64, + /// Answer every swap POST with an unprocessed HTTP 400 that is NOT a NUT error: + /// 1 = non-JSON body, 2 = JSON without a numeric `code`. + pub swap_bad_400: std::sync::atomic::AtomicU64, + /// On the next swap POST, start reporting UNSPENT inputs as PENDING. + pub pending_after_swap: std::sync::atomic::AtomicBool, + /// Drop the last output/signature pair from every non-empty restore reply. + pub partial_restore: std::sync::atomic::AtomicBool, } pub struct MintFixture { pub faults: Arc, @@ -138,6 +156,65 @@ impl MintFixture { use std::sync::atomic::Ordering::SeqCst; let path = req.uri().path().to_owned(); control.requests.fetch_add(1, SeqCst); + if path.ends_with("/swap") { + control.swaps.fetch_add(1, SeqCst); + if control.pending_after_swap.swap(false, SeqCst) { + control.pending_inputs.store(true, SeqCst); + } + match control.swap_bad_400.load(SeqCst) { + 1 => { + return ( + axum::http::StatusCode::BAD_REQUEST, + "bad request", + ) + .into_response(); + } + 2 => { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error":"expired","detail":"x"})), + ) + .into_response(); + } + _ => {} + } + if control.swap_nut_error.load(SeqCst) { + let code = match control.swap_nut_code.load(SeqCst) { + 0 => 11001, + c => c, + }; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"code":code,"detail":"refused"})), + ) + .into_response(); + } + if control.refuse_after_swap.swap(false, SeqCst) { + let done = next.run(req).await; + assert!(done.status().is_success(), "fixture swap must execute"); + control.blank_restores.store(1, SeqCst); + let code = match control.swap_nut_code.load(SeqCst) { + 0 => 11001, + c => c, + }; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"code":code,"detail":"refused"})), + ) + .into_response(); + } + } + if path.ends_with("/restore") && { + let n = control.blank_restores.load(SeqCst); + n > 0 + && control + .blank_restores + .compare_exchange(n, n - 1, SeqCst, SeqCst) + .is_ok() + } { + return axum::Json(serde_json::json!({"outputs":[],"signatures":[]})) + .into_response(); + } let delay = control.delay_ms.load(SeqCst); if delay != 0 { tokio::time::sleep(std::time::Duration::from_millis(delay)).await; @@ -294,6 +371,13 @@ impl MintFixture { } } } + if path.ends_with("/restore") && control.partial_restore.load(SeqCst) { + for k in ["outputs", "signatures"] { + if let Some(a) = v[k].as_array_mut() { + a.pop(); + } + } + } if path.ends_with("/checkstate") && control.pending_inputs.load(SeqCst) { if let Some(states) = v["states"].as_array_mut() { for state in states {