Skip to content

feat(trade): receive — import Cashu tokens into a trade home (stacked on #1107) - #1119

Merged
maxie-agent merged 7 commits into
feat/credit-tradefrom
feat/credit-trade-receive
Oct 10, 2026
Merged

maxie-agent merged 7 commits into
feat/credit-tradefrom
feat/credit-trade-receive

Conversation

@maxie-agent

@maxie-agent maxie-agent commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1107 (base feat/credit-trade). Draft.

Adds maxplayer-trade receive <mint> [--token-file <path>], which imports one Cashu token into a trade home, for example a token that maxplayer-mint issue wrote to a file. The token is read from a file or from stdin, never from argv, and is never printed.

Design (src/receive.rs; small hooks elsewhere)

  • Static refusal before any journal write or network call: the token's mint must equal <mint> after Asset canonicalization. Refused: multi-mint tokens, units other than sat, NUT-10 locked proofs (P2PK, HTLC or any other kind), more than 128 proofs, duplicate proofs, tokens over 100,000 sats, and empty or zero-value tokens.
  • Online refusal, still before the journal write: the combined per-mint/home lifetime cap. Funding intents plus charged receives plus this token must stay ≤ 100,000. fund now counts receives toward the same cap. Then common preflight, the keyset must belong to this mint, incoming DLEQ is verified when present, input fee < amount, and NUT-07 must report every proof UNSPENT.
  • Journal before effect: the attempt (token, inputs, fresh blinded outputs, secrets and r) is written before the swap POST. Output amount = amount − input fee, and net is what gets reported.
  • Single swap with NUT-09 restore first: every pass restores the same outputs before deciding anything. If nothing is restored and the inputs are UNSPENT, it replays the identical swap. If the inputs are SPENT, it restores again, then marks the attempt terminal already_spent. PENDING inputs keep the attempt unresolved. Replacement outputs are never created.
  • Reply classification: a well-formed HTTP 400 (NUT error) counts as a refusal only when a fresh restore is empty and the inputs are UNSPENT; the attempt then becomes terminal refused. Timeouts, 5xx and transport errors are ambiguous: the state stays submitted and the swap is replayed identically later.
  • Credit only DLEQ-verified proofs: missing or invalid DLEQ on our outputs leaves the attempt terminal quarantined (exit 4, manual recovery) and nothing is credited. Rows go into the wallet DB only if they are not already present, so a crash after commit cannot reset proofs that were spent or reserved later.
  • Idempotency: the attempt id is sha256 of the sorted proof Ys, so a re-encoded token (V3 or V4) maps to the same id. A repeat call resumes the existing attempt. A token that overlaps an earlier charged receive is refused.
  • Recovery and exit codes: money::recover (used by recover and by the serve/market recovery pass) resumes receives within receive::RECOVERY_ITEM_SECONDS = 120. money::pending, recovery_status and read-only status include receives and print only the summary. Exit codes: 0 done; 3 prepared/submitted; 1 for pre-journal refusal, refused or already_spent; 4 quarantined.
  • Output: one JSON line containing receive (attempt id), mint, state, amount, fee, net, credited, terminal and manual_recovery.
  • Mint calls: every mint call goes through existing helpers: mint::rpc (restore/swap), mint::states/unspent, wallet::preflight, and the wallet() builder through bounded. Shared signatures are unchanged and there is no new HTTP code. Lab crash hooks: TRADE_CRASH_BEFORE_RECEIVE_SWAP (87) and TRADE_CRASH_AFTER_RECEIVE_SWAP (86).
  • Cap: I agree with the shared 100,000 cap and implemented it as specified. A refused or already_spent receive is not charged, because no value entered the home. Pending, done and quarantined receives are charged.

Test map (tests/receive.rs, in-process CDK fake mints only; plus 2 unit tests)

Requirement Test
Happy path, fee deducted happy_path_deducts_input_fee_and_credits_net (ppk 100)
Lost swap reply → restore, one swap, one credit lost_swap_reply_restores_same_outputs_without_second_swap
Crash after journal / before POST (lab) crash_after_journal_before_post_then_recover_completes
Crash after POST / before wallet commit (lab) crash_after_post_before_wallet_commit_then_recover_completes
Double receive (also V3 re-encoding) double_receive_swaps_once_credits_once_even_reencoded
Already-spent token already_spent_token_refused_and_nothing_credited; journaled-then-spent-elsewhere → already_spent (lab)
Wrong mint / P2PK / multi-mint / 129 proofs / bad incoming DLEQ / msat / garbage, refused before journal invalid_tokens_refused_before_journal + unit locked_and_duplicate_proofs_refused_statically
Cap (per receive + shared with fund) refused before POST cap_exceeded_refused_before_post_and_shared_with_funding
NUT error → terminal refused, no replay definitive_refusal_is_terminal_refused
Invalid / omitted returned DLEQ → quarantined, no credit invalid_returned_dleq_quarantines_without_credit, omitted_returned_dleq_quarantines_without_credit
Token never in stdout/stderr/argv (file, stdin, refusal, exit 3, status) cli_never_prints_token_and_never_takes_it_as_argv
nostr:// parsing unit cdk_token_parsing_accepts_nostr_mint_url

Local results at 7d8cd74 (--locked, protoc from nix): cargo fmt --check and clippy (default and lab) pass with CI's -D warnings and allow list. Lab focused run: lib 24, cli 5 (skill_commands_and_flags_match_binary_help passes), receive 13. Default focused run: receive 10. Full suites at 063fc2c: default 105 passed / 0 failed, lab 150 passed / 0 failed. Round5 and round7 in the lab suite were rerun separately after an exec-session kill: 26 + 1 passed. The tests added since 063fc2c touch only receive, and were rerun.

Mutation checks ran in a separate CARGO_TARGET_DIR, which was deleted afterwards. 22 mutants were tried across two passes. Killed: removing restore, timeout treated as refusal, ignoring DLEQ failure, no cumulative cap, no per-receive cap, accepting locked proofs, accepting the wrong mint, skipping incoming DLEQ, accepting >128 proofs, accepting duplicates, no idempotency lookup, swapping already-spent inputs, no journal write before the POST, skipping the pre-journal UNSPENT check. Mutants that survived the first pass were killed in the second after the tests were strengthened. One mutant is equivalent: removing the explicit missing-DLEQ check. In cashu 0.17.2, Proof::verify_dleq returns MissingDleqProof for None (nut12.rs:161), and cdk's verify_token_dleq maps every error to CouldNotVerifyDleq (wallet/mod.rs:896), so those proofs are quarantined anyway.

nostr:// status

  • cdk 0.17.2 parsing accepts it. MintUrl::format_url (cashu-0.17.2 src/mint_url.rs:52-83) only splits on ://, lowercases the protocol and host, and trims /. MintUrl::from_str (:114) therefore accepts nostr://npub1…, and Token V4 round-trips it. maxplayer-mint issue on main (169bd28) builds tokens the same way: crates/maxplayer-mint/src/issue.rs:188, Token::new(MintUrl::from_str(url)?, …). Proven by cdk_token_parsing_accepts_nostr_mint_url.
  • This crate does not admit it yet. Asset::new (src/lib.rs:29-32) allows only http/https ("unsupported mint scheme"), so receive nostr://… is refused before anything is journaled. That gate belongs to the parallel feat/credit-trade-nostr-mints work. receive uses Asset canonicalization and the shared helpers only, so once that branch reroutes rpc/wallet()/preflight, receive goes through the same dispatch. The unit test asserts that receive gating follows Asset exactly.
  • No cdk parsing blocker was found.

Unverified

  • No real mint, wallet or nostr:// mint was exercised. Only in-process fake mints were used.
  • Behaviour against a real nostr:// transport is untested, pending the nostr transport branch.
  • Treating HTTP 400 as a definitive refusal relies on the mint::rpc error text (swap: HTTP 400). It is also gated on a fresh empty restore plus UNSPENT inputs.

Review round 1 (advisor review of 7d8cd74, request-changes)

Author-led fix pass on an independent advisor review (Maxplayer seat, read-only, no test run). Approved by Bob. Commits b3b2190 (code + tests) and 25cce15 (docs). The reviewed range was a0058a0..7d8cd74.

# Finding Fix Regression test(s)
1 HIGH Any swap: HTTP 400 text made an UNSPENT token permanently refused (a transport expired/bad_request too) New typed error mint::MintRefusal { code, detail } (src/mint.rs). mint::rpc puts it in the error chain only for HTTP 400 whose body is JSON with a numeric code and a string detail. detail is sanitized and bounded to 120 B, and the raw body is still never kept. receive::nut_error is now MintRefusal::of(e).is_some(). Every other 400, timeout, 429/5xx or transport refusal stays submitted (exit 3) and replays the same outputs. A transport such as #1121's nostr connector must build MintRefusal only for a real mint NUT error, never for expired/bad_request. unit mint::refusal_tests::only_a_400_nut_error_body_is_a_refusal, detail_is_bounded_and_sanitized; non_nut_400_stays_submitted_and_a_later_receive_swaps (non-JSON 400 and {"error":"expired"} 400 stay submitted; CLI exit 3; a later receive of the same token swaps the same outputs and credits once); pending_inputs_after_nut_error_stay_non_terminal; partial_restore_stays_non_terminal_without_second_swap
2 MED finish set the state before j.put, so a failed write was printed/exited as the unjournaled state finish builds the next record, persists it, and only then assigns it. The post-swap result write does the same. On a write error the returned receipt keeps the previous journaled state, so the CLI prints submitted and exits 3. lab failed_terminal_journal_write_exits_3_and_journal_unchanged (fault hook TRADE_FAIL_RECEIVE_TERMINAL_WRITE, lab-only: refusal not journaled → stdout submitted, exit 3, journal submitted; without the fault → exit 1, refused)
3 MED Cap docs disagreed with the code; exit 4 and quarantine cap effect undocumented; fund error ignored receives One sentence in README, SKILL.md and commands.md: 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 SKILL receive section lists exits 0/1/3/4 and says quarantined is unspendable yet still on the cap. fund's cap error names receives when they hold part of the cap. SKILL.md 9,965 B (< 10,000 test). cap_charges_gross_with_input_fees (ppk ≠ 0); quarantined_receive_still_occupies_the_cap (also asserts the fund error mentions receives); refused_receive_does_not_consume_the_cap; overlapping_ys_with_a_charged_receive_are_refused; cli_exit_codes_for_journaled_refused_and_quarantined (exit 1 / 4); lab cli_exit_1_for_journaled_already_spent; skill_commands_and_flags_match_binary_help
4 LOW status printed a different, null-filled shape for receives status prints Receipt::summary() (same fields as recover: receive, mint, state, amount, fee, net, credited, terminal, manual_recovery) plus kind; never the token. Other kinds' status JSON is unchanged byte-for-byte (code path untouched). cli_exit_codes_for_journaled_refused_and_quarantined checks the status fields for a quarantined receive and that status output is secret-free
gap HTLC static refusal none needed: nut10::Secret::try_from already fails closed unit locked_and_duplicate_proofs_refused_statically now builds an HTLC proof too

Not changed: recovery_status still prefers exit 3 over 4 when anything is unresolved, and a quarantined receive still prints manual_recovery: true per line. That is the existing cross-kind rule, so it is out of scope here.

Open points from the review

  • 120 s receive item budget vs nostr call length. Kept at 120 s in this PR. A cancelled pass leaves the attempt submitted with the same journaled outputs, so the cost is extra recover passes, not a second output set. The budget for a nostr mint belongs with feat(trade): nostr:// mint support (stacked on #1107) #1121, which owns the per-call nostr timeouts. Flagged there; not tested here.
  • Mint commits the swap but answers 400. A non-NUT 400 is now ambiguous and replays, so this is safe. A parsed NUT error followed by an empty restore and UNSPENT inputs is still recorded refused. That outcome needs the mint to violate NUT-03 (commit, then report a NUT error, then hide the signatures from NUT-09 and report the inputs UNSPENT). It is accepted as a mint bug; no test models it.

Mutation check (disposable copy, separate CARGO_TARGET_DIR, deleted afterwards; each mutant required to fail at its SAFETY assertion, not at a build error): 7/7 killed: text-based nut_error (→ non-NUT 400 test), state-before-persist finish (→ journal-write test), status without receive summary (→ CLI status test), quarantined not charged (→ quarantine-cap test), refused charged (→ refused-cap test), MintRefusal::parse accepting any 400 (→ unit), fund error not naming receives (→ quarantine-cap test).

Local gates at 25cce15 (--locked, CI allow list, lab with TRADE_LAB_SECONDS=1 as in CI, serial runs): cargo fmt --check passes, and clippy (default and lab, -D warnings) passes. Default: 116 passed / 0 failed (lib 26, cli 5, e2e 6, money 16, pinned 1, receive 18, relays 7, review_regressions 17, round5 20). Lab: 163 passed / 0 failed (lib 26, cli 5, e2e 11, money 17, pinned 1, receive 23, relays 7, review_regressions 46, round5 26, round7 1).

CI at 25cce15: run 38006487544: all 9 jobs green, including maxplayer-trade (fmt, clippy, default + lab tests) and Money-path tests (no rerun needed).

Unverified this round: no real mint, maxplayer-mint sidecar or nostr relay. The MintRefusal contract for #1121's nostr transport (wire expired/bad_request must not be typed as a NUT error) is stated here but must be implemented and tested in #1121. Not tested: the 120 s budget against slow nostr calls, and a mint that commits a swap but answers with a parsed NUT error.

CI

Run 37988697921 at 7d8cd74: all 9 jobs green, including maxplayer-trade (fmt, clippy, default + lab tests). The first attempt of Money-path tests failed in an untouched crate (maxplayer-core reviewer::tests::durable_cache_reuses_unsafe_and_survives_restart_without_rebilling, review_service_already_running). This branch changes only crates/maxplayer-trade/. The rerun of that failed job passed.

Review round 2 (Bob approved 02:07 UTC 2026-10-10)

Reviewed head 25cce15. Fixes are in a5b65399 and 059ebeb6 (CI-only: deprecated fetch_update in the test fixture). Sources: cashu-rust-dev REVIEW (request-changes) and advisor re-review (approve-with-nits). The fix pass was author-led, not independent. Before fixing, I validated each finding against 25cce15 and the cdk 0.17.2 registry sources.

Finding Status Evidence / test
F1 HIGH: the refusal path uses one restore before already_spent Fixed a5b6539 Both paths now share one probe(): restore, then NUT-07, then a second restore on SPENT. Test refusal_racing_a_landed_swap_restores_twice_and_credits: the mint executes the swap, answers with a NUT error, and blanks the next restore. Result is done and credited once.
F2 HIGH: any numeric NUT code counts as definitive Fixed a5b6539 Verified in cdk 0.17.2. cdk-axum into_response always returns 400 with a NUT body. cdk-common error.rs maps Internal, Database, DHKE(_) and the _ fallback to Unknown(50000) and ConcurrentUpdate to 50000. is_definitive_failure treats Internal, TokenPending and ConcurrentUpdate as non-definitive. New MintRefusal::DEFINITIVE allowlist: 10001, 11001, 11005, 11007-11010, 11013-11015, 12001-12003. Codes 50000, 11002, 11003, 11004, 31004 and unknown codes stay submitted. Tests: ambiguous_nut_codes_stay_submitted_then_complete (50000/11002/11004/99999), transient_50000_for_a_landed_swap_is_credited_on_recover, unit only_allowlisted_codes_are_definitive.
F3 MEDIUM: refused is sticky per Y-set Fixed a5b6539 (safer option, flag for Bob) Repeating receive with the same token retries the SAME journaled outputs. The cap and preflight are re-checked first. There is one record per Y-set, so there are never two live attempts, and a late landing stays restorable. recover/serve never retry automatically. Tests: repeat_receive_retries_a_refused_attempt_once, refused_retry_rechecks_the_cap.
F4 MEDIUM: credited proofs are spendable before done Fixed a5b6539 Credit rows are inserted RESERVED under the receipt id, the same as funding. They are released only after done is journaled. recover finishes a pending release, and a done record that is not yet released counts as pending (exit 3). Tests: credited_rows_unspendable_until_done_is_journaled (a lab crash before done, then withdraw fails, then recover credits once) and crash_after_done_before_release_is_released_by_recover.
F5 LOW: a non-NUT refusal loops forever; no abandon command Deferred This needs a product decision on an operator --abandon command with an evidence gate. The current behaviour fails closed (the swap is retained and replayed).
F6 LOW: the bearer token is journaled and never read Fixed a5b6539 The Receipt.token field is removed. Older rows still deserialize because unknown fields are ignored. Output secrets stay, because they are needed for restore.
F7 NIT: DLEQ errors are mislabelled Fixed Only CouldNotVerifyDleq reads as "verification failed". Any other error reads as "check could not run".
F8 NIT / advisor nit 2: mint detail reaches stderr Fixed MintRefusal Display prints only the code (unit test display_withholds_mint_detail).
F9 NIT: the cap rule lives in two places Fixed One receive::cap_used, used by both fund and receive.
Advisor nit 1: README intro cap sentence Fixed Uses the shared-cap sentence.
crd suggestion: ABANDON_GRACE window before a terminal state Not adopted The allowlist plus the double probe close the reported F1/F2 paths. A definitive refusal racing an earlier straggler POST is still theoretically possible. The outputs stay journaled, and the F3 retry restores them. See Unverified.

Mutation check (separate target dir, disposable copy): 7/7 mutants killed, each at its SAFETY assertion. The mutants were: single restore, any code definitive (kills 2 tests), no refused retry, retry without the cap check, credit as Unspent, and recover without release.

Local gates at a5b6539 (shared target, sources touched): fmt OK, clippy default + lab OK, default 123/0, lab (TRADE_LAB_SECONDS=1) 172/0. At 059ebeb: fmt and both clippy runs passed under rustc 1.98, and --test receive passed 30/0.

Unverified: non-CDK mints (error codes, restore DLEQ, atomic swap persistence), and the width of the straggler window under load. The tests use an in-process CDK mint behind a fault proxy. No real mint was contacted.

Review round 3 (Bob approved 15:02 UTC 2026-10-10, option (a) limited retry)

Reviews: crd round 2 (approve-with-nits) and advisor round 3 (approve-with-nits), both at 059ebeb. Fix commit: e4a00597. Author-led fix pass; not an independent review.

Retryable set under cdk 0.17.2: empty, so every refusal is final

Bob's option (a) allows a retry only where an identical replay (same inputs, same journaled outputs) can actually succeed. I traced each allowlisted code through the cdk 0.17.2 sources in ~/.cargo/registry and found no such code:

Code Source in cdk 0.17.2 Why an identical replay cannot succeed
10001 verify_proofs (signatory verify_message); CouldNotVerifyDleq Deterministic signature check on fixed inputs.
11001 add_proofs → AttemptUpdateSpentProof (swap_saga/mod.rs:194) SPENT is permanent (check_state_transition: Spent → nothing). Our refused additionally requires a probe that shows inputs UNSPENT, so this combination means the mint is inconsistent. Final.
11005 verify_transaction_balanced: fee = input keyset input_fee_ppk (get_proofs_fee) vs fixed output total; also InsufficientFunds/SplitValuesGreater/AmountOverflow/OverIssue The inputs and the output total are fixed, and the fee is a property of the input keyset (a fee change means a new keyset via rotation). A fee taken from a stale cache stays wrong forever.
11007 / 11008 check_inputs_unique / check_outputs_unique; add_blinded_messages Duplicate Request-shape checks on fixed data. The Duplicate path is either permanent or evidence of an earlier landing; the probe covers the latter.
11009 / 11010 / 11013 unit checks in verify_*_keyset, verify_transaction_balanced Keyset units never change.
11014 / 11015 max_inputs/max_outputs (verify_inputs/verify_outputs) These are operator config and change only on a restart. The same limits also gate checkstate (check_spendable.rs:17) and restore (mod.rs:1160), so on cdk the refused state is not even reachable for them: the probe errors and the record stays submitted.
12001 UnknownKeySet/AmountKey/KeysetUnknown Keysets are never deleted.
12002 verify_outputs_keyset: !keyset.active The output keyset is fixed in the journal. Only the highest-index keyset is ever reactivated (signatory common.rs:36-67), so a rotated-out keyset never comes back.
12003 is_expired() Expiry is permanent.

An earlier ambiguous POST that lands late is covered by the probe (restore, then NUT-07, then a second restore) that runs before refused is journaled. It is not a reason to retry, because a retry would POST the same request again.

Findings

# Source Decision Commit / test
N1 / F3 (retry of the same outputs can never fix output-side refusals) crd Fixed per (a): the retryable set is empty, so refused is final. The NUT code is journaled (refusal_code). A repeat receive does not POST, exits 1 with "final: the token was NOT imported … can be redeemed elsewhere", and the cap is freed. recover does not retry either. e4a00597; output_side_refusal_is_final_frees_cap_and_exits_1 (12002; also checks that a second home redeems the token), legacy_refused_record_without_code_is_final, plus the existing definitive_refusal_is_terminal_refused and refused_receive_does_not_consume_the_cap
Retry comment overclaims keyset rotation (advisor #4) advisor Fixed: the comment is removed and the Refused doc now states that refusals are final, with the reasoning. e4a00597
N2 (a refused record is never re-probed; an operator-limit change could let a stuck POST land) crd Deferred. On cdk the 11013/11014/11015 limits also block checkstate and restore, so a record cannot reach refused through them. The remaining case needs a POST delayed past a refusal and a config change. A follow-up could restore refused outputs on an overlapping receive. —
N3 / advisor #5 ("mirrors is_definitive_failure") both Fixed: README and mint.rs now say "strict subset" and name 11003 as deliberately excluded; commands.md names 11003. e4a00597
N4 (pre-released records) crd No action: harmless, as the reviewer notes. The release is a no-op that only sets the flag. —
advisor #1 (SKILL/commands/recovery/README exit lines) advisor Fixed: exit 0 means done and released; exit 3 also covers a release that is still outstanding; refused is final. e4a00597
advisor #2 ("token is journaled" / "never swaps twice") advisor Fixed: the docs now say input proofs and output secrets are journaled (not the token text), only the identical swap is replayed, and a second output set is never created. e4a00597
advisor #3 (summary shows credited done while rows are reserved) advisor Fixed: the summary carries settled and refusal_code. recover's status line counts a done record that is not yet released as unresolved (exit 3) through the new Receipt::unresolved. e4a00597; existing crash_after_done_before_release_is_released_by_recover
Low-confidence: already_spent is sticky advisor Deferred. cdk writes signatures and SPENT in one transaction, and the double restore covers the reported race. Two lagging restores would need a non-cdk mint. —
Low-confidence: recovery_status ignores released advisor Fixed (same change as advisor #3). e4a00597

Gates at e4a00597 (all --locked, local)

fmt OK; clippy default + lab (-D warnings + the CI allow list) OK; default suite 123 passed / 0 failed; lab suite with TRADE_LAB_SECONDS=1 172 passed / 0 failed (receive: 30/0).

Mutation check (disposable worktree, separate target)

Mutant output_side_refusal_is_final… legacy_refused_record…
M1: restore the old retry of every refused killed (:884 repeat stays final) killed (:940)
M3: retry only records without a code survives (has a code; this test is not meant to cover it) killed (:940)
M2: refused counts against the cap killed (:909 cap freed) killed (:947)
M4: drop the "NOT imported" message killed (:886) survives (asserts only "not journaled")

Each mutant is killed by at least one new test. The two survivals are cases outside that test's scope, as described above.

Still unverified

Behaviour of non-cdk mints (nutshell and others) for these codes; real keyset rotation on a live mint; N2's operator-config window.

CI at e4a00597

Run https://github.com/MakePrisms/maxplayerai/actions/runs/38065873574 is green, including maxplayer-trade (fmt, clippy, default + lab tests). On the first attempt the only failure was Money-path tests, in core test seller_node::run::tests::reconsider_backfills_a_capacity_skipped_open_pool_offer_when_a_slot_frees ("both offers must reach on_offer"). That is not the known durable_cache_… flake. The diff touches only crates/maxplayer-trade, and #1123's run on the same code passed Money-path, so I reran the failed job once and it passed.

Journal-before-effect single swap into fresh home-owned outputs; NUT-09 restore
of the same outputs and identical replay on ambiguity; terminal refused /
already_spent / quarantined; idempotent per Y-set; shares the per-mint
100,000-sat lifetime cap with fund; wired into recover/serve and status.
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
maxplayer Ready Ready Preview Oct 10, 2026 4:00pm UTC

Request Review

…intRefusal); persist state before reporting it; status shows receive summary

Review round 1 findings 1, 2 and 4 on #1119: a 400 without a NUT error body
(e.g. a transport expired/bad_request) stays submitted and replays the same
outputs; a failed journal write returns the error with the previous state
(exit 3); status prints the same receive fields as recover, never the token.
fund's cap error names receives. Tests: non-NUT 400, PENDING inputs, partial
restore, gross cap with fees, quarantined/refused cap accounting, overlapping
Ys, HTLC, CLI exits 1/3/4, failed terminal journal write (lab).
…uarantined holds the cap (review round 1 finding 3)
maxie-agent added a commit that referenced this pull request Oct 9, 2026
…uest/rate-limit stay ambiguous

Advisor HIGH on #1119, made worse here: transport formatted core's HttpError(400) for wire
expired/bad_request as "HTTP 400", which receive's nut_error treats as a definitive refusal, so
a token expired by wallet/mint clock skew became permanently refused. nostr_call now formats
"HTTP 400" only for the mint's own definitive NUT error; every transport code (expired,
bad_request, rate_limited, unsupported, internal, unknown), 413, timeouts and relay failures are
ambiguous: attempt retained, identical request replayed later. New unit test covers the
classification; new nostr test injects a mint-signed expired reply during receive and proves the
attempt stays submitted, a later pass completes it, credited once, exactly one replay.
maxie-agent added a commit that referenced this pull request Oct 10, 2026
…te before reporting it

Review round 1 (carried from #1119 findings 1 and 2): a 400 without a NUT error
body stays submitted and replays the identical swap; every journal update goes
through persist-then-publish so a failed write leaves the printed/returned state
at the journaled one (exit 3). SKILL.md trimmed under 10,000 B after the merge.
Tests: non-NUT 400 (lib + CLI exit 3), failed terminal write on send refusal and
on reclaim (lab).
…e), definitive NUT-code allowlist, refused retry, reserved credit until done

- F1: refusal and main paths share probe(): restore, NUT-07, restore again on SPENT
- F2: MintRefusal::DEFINITIVE mirrors cdk-common 0.17.2 is_definitive_failure;
  50000/11002/11003/11004/unknown codes stay submitted and replay
- F3: repeating receive with the same token retries a refused attempt with the
  same journaled outputs after re-checking the cap; recover never replays it
- F4: credited rows are RESERVED under the receive id until done is journaled,
  then released (recover finishes a pending release)
- F6 drop journaled token; F7 DLEQ error mapping; F8 Display prints code only;
  F9 one cap_used helper; README cap sentence
…l (empty retryable set on cdk 0.17.2), journal refusal_code, settled in summary, docs

Bob chose (a) limited retry. Traced every allowlisted code through cdk 0.17.2:
none can succeed on an identical replay (same inputs, same outputs), so the
retryable set is empty. A refused record journals its NUT code; neither recover
nor a repeated receive replays it; exit 1 says the token was NOT imported and can
be redeemed elsewhere; the cap is freed. Legacy records without a code are final.
Also: summary carries settled + refusal_code, recover status counts done-but-
unreleased as unresolved, allowlist documented as a strict subset of
is_definitive_failure (11003 left out), stale 'token is journaled'/'never swaps
twice' wording fixed.
maxie-agent added a commit that referenced this pull request Oct 10, 2026
…sal_code) into nostr mints

Conflicts in README/commands.md/recovery.md: take #1119's final-refusal wording, keep the nostr
transport-code list and the nostr NIP-44 admission refusal; 11003 now listed as ambiguous.
SKILL.md trimmed back under 10,000 bytes after the auto-merge.
@maxie-agent
maxie-agent marked this pull request as ready for review October 10, 2026 21:53
@maxie-agent
maxie-agent merged commit ea511bd into feat/credit-trade Oct 10, 2026
19 of 20 checks passed
maxie-agent added a commit that referenced this pull request Oct 11, 2026
Approved by Bob in Discord #cashu-token-marketplace (2026-10-10 20:38 UTC, stack merge go). Verified: head c7f3077 (contains #1119, #1121, #1123, #1125), CI 11/11 green, CLEAN.

This branch was successfully deployed

1 active deployment
Preview — e4a00597 Deployed Oct 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant