Repository navigation
feat(trade): receive — import Cashu tokens into a trade home (stacked on #1107) - #1119
Merged
Merged
Conversation
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.
…wn duplicate check
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Oct 9, 2026
…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
… CI clippy -D warnings)
maxie-agent
added a commit
that referenced
this pull request
Oct 10, 2026
…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
marked this pull request as ready for review
October 10, 2026 21:53
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thatmaxplayer-mint issuewrote 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)<mint>afterAssetcanonicalization. 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.fundnow counts receives toward the same cap. Then commonpreflight, the keyset must belong to this mint, incoming DLEQ is verified when present, input fee < amount, and NUT-07 must report every proof UNSPENT.amount − input fee, andnetis what gets reported.already_spent. PENDING inputs keep the attempt unresolved. Replacement outputs are never created.refused. Timeouts, 5xx and transport errors are ambiguous: the state stayssubmittedand the swap is replayed identically later.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.money::recover(used byrecoverand by theserve/market recovery pass) resumes receives withinreceive::RECOVERY_ITEM_SECONDS = 120.money::pending,recovery_statusand read-onlystatusinclude receives and print only the summary. Exit codes: 0done; 3prepared/submitted; 1 for pre-journal refusal,refusedoralready_spent; 4quarantined.receive(attempt id),mint,state,amount,fee,net,credited,terminalandmanual_recovery.mint::rpc(restore/swap),mint::states/unspent,wallet::preflight, and thewallet()builder throughbounded. Shared signatures are unchanged and there is no new HTTP code. Lab crash hooks:TRADE_CRASH_BEFORE_RECEIVE_SWAP(87) andTRADE_CRASH_AFTER_RECEIVE_SWAP(86).refusedoralready_spentreceive 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)happy_path_deducts_input_fee_and_credits_net(ppk 100)lost_swap_reply_restores_same_outputs_without_second_swapcrash_after_journal_before_post_then_recover_completescrash_after_post_before_wallet_commit_then_recover_completesdouble_receive_swaps_once_credits_once_even_reencodedalready_spent_token_refused_and_nothing_credited; journaled-then-spent-elsewhere →already_spent(lab)invalid_tokens_refused_before_journal+ unitlocked_and_duplicate_proofs_refused_staticallycap_exceeded_refused_before_post_and_shared_with_fundingrefused, no replaydefinitive_refusal_is_terminal_refusedinvalid_returned_dleq_quarantines_without_credit,omitted_returned_dleq_quarantines_without_creditstatus)cli_never_prints_token_and_never_takes_it_as_argvcdk_token_parsing_accepts_nostr_mint_urlLocal results at 7d8cd74 (
--locked, protoc from nix):cargo fmt --checkand clippy (default and lab) pass with CI's-D warningsand allow list. Lab focused run: lib 24, cli 5 (skill_commands_and_flags_match_binary_helppasses), 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_dleqreturnsMissingDleqProofforNone(nut12.rs:161), and cdk'sverify_token_dleqmaps every error toCouldNotVerifyDleq(wallet/mod.rs:896), so those proofs are quarantined anyway.nostr:// status
MintUrl::format_url(cashu-0.17.2src/mint_url.rs:52-83) only splits on://, lowercases the protocol and host, and trims/.MintUrl::from_str(:114) therefore acceptsnostr://npub1…, andTokenV4 round-trips it.maxplayer-mint issueon main (169bd28) builds tokens the same way:crates/maxplayer-mint/src/issue.rs:188,Token::new(MintUrl::from_str(url)?, …). Proven bycdk_token_parsing_accepts_nostr_mint_url.Asset::new(src/lib.rs:29-32) allows onlyhttp/https("unsupported mint scheme"), soreceive nostr://…is refused before anything is journaled. That gate belongs to the parallelfeat/credit-trade-nostr-mintswork.receiveusesAssetcanonicalization and the shared helpers only, so once that branch reroutesrpc/wallet()/preflight, receive goes through the same dispatch. The unit test asserts that receive gating followsAssetexactly.Unverified
mint::rpcerror 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) and25cce15(docs). The reviewed range wasa0058a0..7d8cd74.swap: HTTP 400text made an UNSPENT token permanentlyrefused(a transportexpired/bad_requesttoo)mint::MintRefusal { code, detail }(src/mint.rs).mint::rpcputs it in the error chain only for HTTP 400 whose body is JSON with a numericcodeand a stringdetail.detailis sanitized and bounded to 120 B, and the raw body is still never kept.receive::nut_erroris nowMintRefusal::of(e).is_some(). Every other 400, timeout, 429/5xx or transport refusal stayssubmitted(exit 3) and replays the same outputs. A transport such as #1121's nostr connector must buildMintRefusalonly for a real mint NUT error, never forexpired/bad_request.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 staysubmitted; CLI exit 3; a laterreceiveof 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_swapfinishset the state beforej.put, so a failed write was printed/exited as the unjournaled statefinishbuilds 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 printssubmittedand exits 3.failed_terminal_journal_write_exits_3_and_journal_unchanged(fault hookTRADE_FAIL_RECEIVE_TERMINAL_WRITE, lab-only: refusal not journaled → stdoutsubmitted, exit 3, journalsubmitted; without the fault → exit 1,refused)funderror ignored receivesrefused/already_spentreceives do not count;prepared/submitted/done/quarantinedones do. The SKILL receive section lists exits 0/1/3/4 and saysquarantinedis 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 thefunderror 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); labcli_exit_1_for_journaled_already_spent;skill_commands_and_flags_match_binary_helpstatusprinted a different, null-filled shape for receivesstatusprintsReceipt::summary()(same fields asrecover:receive,mint,state,amount,fee,net,credited,terminal,manual_recovery) pluskind; never the token. Other kinds' status JSON is unchanged byte-for-byte (code path untouched).cli_exit_codes_for_journaled_refused_and_quarantinedchecks the status fields for a quarantined receive and that status output is secret-freenut10::Secret::try_fromalready fails closedlocked_and_duplicate_proofs_refused_staticallynow builds an HTLC proof tooNot changed:
recovery_statusstill prefers exit 3 over 4 when anything is unresolved, and a quarantined receive still printsmanual_recovery: trueper line. That is the existing cross-kind rule, so it is out of scope here.Open points from the review
submittedwith the same journaled outputs, so the cost is extrarecoverpasses, 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.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-basednut_error(→ non-NUT 400 test), state-before-persistfinish(→ journal-write test), status without receive summary (→ CLI status test), quarantined not charged (→ quarantine-cap test), refused charged (→ refused-cap test),MintRefusal::parseaccepting any 400 (→ unit),funderror not naming receives (→ quarantine-cap test).Local gates at 25cce15 (
--locked, CI allow list, lab withTRADE_LAB_SECONDS=1as in CI, serial runs):cargo fmt --checkpasses, 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)andMoney-path tests(no rerun needed).Unverified this round: no real mint,
maxplayer-mintsidecar or nostr relay. TheMintRefusalcontract for #1121's nostr transport (wireexpired/bad_requestmust 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 ofMoney-path testsfailed in an untouched crate (maxplayer-corereviewer::tests::durable_cache_reuses_unsafe_and_survives_restart_without_rebilling,review_service_already_running). This branch changes onlycrates/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 ina5b65399and059ebeb6(CI-only: deprecatedfetch_updatein 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 against25cce15and the cdk 0.17.2 registry sources.already_spentprobe(): restore, then NUT-07, then a second restore on SPENT. Testrefusal_racing_a_landed_swap_restores_twice_and_credits: the mint executes the swap, answers with a NUT error, and blanks the next restore. Result isdoneand credited once.cdk-axuminto_responsealways returns 400 with a NUT body.cdk-commonerror.rsmaps Internal, Database, DHKE(_) and the_fallback toUnknown(50000)and ConcurrentUpdate to 50000.is_definitive_failuretreats Internal, TokenPending and ConcurrentUpdate as non-definitive. NewMintRefusal::DEFINITIVEallowlist: 10001, 11001, 11005, 11007-11010, 11013-11015, 12001-12003. Codes 50000, 11002, 11003, 11004, 31004 and unknown codes staysubmitted. Tests:ambiguous_nut_codes_stay_submitted_then_complete(50000/11002/11004/99999),transient_50000_for_a_landed_swap_is_credited_on_recover, unitonly_allowlisted_codes_are_definitive.refusedis sticky per Y-setreceivewith 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/servenever retry automatically. Tests:repeat_receive_retries_a_refused_attempt_once,refused_retry_rechecks_the_cap.donedoneis journaled.recoverfinishes a pending release, and adonerecord that is not yet released counts as pending (exit 3). Tests:credited_rows_unspendable_until_done_is_journaled(a lab crash beforedone, thenwithdrawfails, thenrecovercredits once) andcrash_after_done_before_release_is_released_by_recover.--abandoncommand with an evidence gate. The current behaviour fails closed (the swap is retained and replayed).Receipt.tokenfield is removed. Older rows still deserialize because unknown fields are ignored. Output secrets stay, because they are needed for restore.CouldNotVerifyDleqreads as "verification failed". Any other error reads as "check could not run".detailreaches stderrMintRefusalDisplay prints only the code (unit testdisplay_withholds_mint_detail).receive::cap_used, used by bothfundandreceive.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 receivepassed 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/registryand found no such code:verify_proofs(signatoryverify_message);CouldNotVerifyDleqadd_proofs→AttemptUpdateSpentProof(swap_saga/mod.rs:194)check_state_transition: Spent → nothing). Ourrefusedadditionally requires a probe that shows inputs UNSPENT, so this combination means the mint is inconsistent. Final.verify_transaction_balanced: fee = input keysetinput_fee_ppk(get_proofs_fee) vs fixed output total; alsoInsufficientFunds/SplitValuesGreater/AmountOverflow/OverIssuecheck_inputs_unique/check_outputs_unique;add_blinded_messagesDuplicateverify_*_keyset,verify_transaction_balancedmax_inputs/max_outputs(verify_inputs/verify_outputs)checkstate(check_spendable.rs:17) andrestore(mod.rs:1160), so on cdk therefusedstate is not even reachable for them: the probe errors and the record stayssubmitted.UnknownKeySet/AmountKey/KeysetUnknownverify_outputs_keyset:!keyset.activeis_expired()An earlier ambiguous POST that lands late is covered by the probe (restore, then NUT-07, then a second restore) that runs before
refusedis journaled. It is not a reason to retry, because a retry would POST the same request again.Findings
refusedis final. The NUT code is journaled (refusal_code). A repeatreceivedoes not POST, exits 1 with "final: the token was NOT imported … can be redeemed elsewhere", and the cap is freed.recoverdoes 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 existingdefinitive_refusal_is_terminal_refusedandrefused_receive_does_not_consume_the_capRefuseddoc now states that refusals are final, with the reasoning.e4a00597refusedthrough them. The remaining case needs a POST delayed past a refusal and a config change. A follow-up could restorerefusedoutputs on an overlapping receive.mint.rsnow say "strict subset" and name 11003 as deliberately excluded; commands.md names 11003.e4a00597releasedrecords)doneand released; exit 3 also covers a release that is still outstanding;refusedis final.e4a00597e4a00597donewhile rows are reserved)settledandrefusal_code.recover's status line counts adonerecord that is not yet released as unresolved (exit 3) through the newReceipt::unresolved.e4a00597; existingcrash_after_done_before_release_is_released_by_recoveralready_spentis stickyrecovery_statusignoresreleasede4a00597Gates 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 withTRADE_LAB_SECONDS=1172 passed / 0 failed (receive: 30/0).Mutation check (disposable worktree, separate target)
output_side_refusal_is_final…legacy_refused_record…refusedrefusedcounts against the capEach 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
e4a00597Run 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 wasMoney-path tests, in core testseller_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 knowndurable_cache_…flake. The diff touches onlycrates/maxplayer-trade, and #1123's run on the same code passed Money-path, so I reran the failed job once and it passed.