Skip to content

feat(trade): nostr:// mint support (stacked on #1107) - #1121

Merged
maxie-agent merged 22 commits into
feat/credit-tradefrom
feat/credit-trade-nostr-mints
Oct 10, 2026
Merged

maxie-agent merged 22 commits into
feat/credit-tradefrom
feat/credit-trade-nostr-mints

Conversation

@maxie-agent

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

Copy link
Copy Markdown
Collaborator

Stacked on #1107 (feat/credit-trade) and on #1119 (feat/credit-trade-receive, receive). Base is feat/credit-trade; until #1119 merges, this diff also shows #1119's commits, up to 25cce15, which is merged in (merge commit a40a888; no rebase). The nostr work is the top commits only. Draft. Not for merge without a team member's explicit go-ahead.

What

maxplayer-trade can list, take, claim, refund, and receive on a nostr://<npub> mint, such as a crates/maxplayer-mint credits sidecar. HTTPS mints behave exactly as before.

Design

  • One mint transport dispatch (src/transport.rs). Every mint request in the crate goes through transport::post/get: mint::rpc (signature unchanged), the /v1/info and /v1/keysets preflight reads, and the CDK wallet builder. HTTP(S) mints keep the raw reqwest requests byte for byte. A nostr:// mint uses the reviewed core connector maxplayer_core::nostr_mint::NostrMintConnector: one kind-23410 request and one 23411 reply per call, an identical re-send on a lost reply, and a mint-enforced exp. A path dependency on maxplayer-core (features wallet) works exactly as crates/maxplayer-mint does. There is no core or mint change; git diff feat/credit-trade -- crates/maxplayer-core crates/maxplayer-mint is empty. CDK wallets for nostr mints are built with .client(connector).use_http_subscription(), as in core build_wallet_with_relays. receive (feat(trade): receive — import Cashu tokens into a trade home (stacked on #1107) #1119) gets nostr through the same helpers. Its only changes are bounded → bounded_for and the refusal wording below.
  • Canonical asset: nostr://<lowercase bech32 npub>, with no slash, path, query or fragment. Asset::new/validate (lots, peers, tokens) accept only that form, the same form the market site accepts. Asset::from_cli also accepts a 64-hex key, uppercase, or one trailing slash, and canonicalizes it.
  • Relays: a repeatable --mint-relay (max 8; wss://, or ws:// only on loopback), defaulting to wss://relay.maxplayer.ai, wss://relay.ditto.pub, wss://nostr-pub.wellorder.net in that order (transport::default_mint_relays = DEFAULT_MINT_RELAY + core FALLBACK_RELAYS). That is exactly the list and order a default credits sidecar listens on (maxplayer-mint MintConfig::effective_relays), and a test asserts the two are equal. Mint relays carry kinds 23410/23411 only, and market relays never carry mint traffic (a relay tap asserts this in tests).
    • relay.maxplayer.ai: Bob's decision (2026-10-09). It carries mint traffic (23410/23411) only. The mint-relay denylist is gone. The market fence in market.rs (3410/3411/23412, --relay) is unchanged and still refuses it. production_relay_carries_mint_traffic_but_never_market_traffic runs the real CLI with the same host (plain and RELAY.MAXPLAYER.AI/) as both --relay and --mint-relay: market is refused with production relay forbidden, and mint alone is accepted. Both checks run before any socket opens. Under strace -e connect the two CLI invocations made 0 non-Unix connects, so no test writes to the real relay.
  • Timeouts (a slow but valid nostr call is never dropped and then treated as definitive):
    • CDK wallet calls use wallet::bounded_for(mint, …): 15 s for HTTP (unchanged), and window 30 s + OUTER_MARGIN 10 s = 40 s for nostr. All 29 trade call sites and all 8 in receive use it. The old bounded is kept for compatibility.
    • A raw nostr request is bounded by the connector window, not the 20 s HTTP timeout.
    • A deadline-bound swap (lock or claim) shrinks the connector window so that the request exp is at or before send_before. With under 2 s left it is not sent at all.
    • Abandonment grace for nostr is 170 s: window 30 + margin 10 + MAX_CLOCK_SKEW_SECS 60 + 20. HTTP stays at 60 s.
    • Budgets re-checked: the maker advance stays at 100/50 s. A recovery item that touches a nostr leg gets max(120, 100 + 40) = 140 s, so the trailing own-mint states() is not cut. The 60 s inbound message path is unchanged: it can cut a nostr claim, but claiming and the preimage are saved first and the next tick claims with the full budget (same argument as feat(trade): standalone fixed-lot Cashu token trading (credit trading prototype) #1107). Any cut is ambiguous: the journaled attempt is restored, never re-sent with new outputs, and never treated as failed.
  • Payload bound: the sidecar allows at most 128 inputs/outputs, and NIP-44 plaintext is limited to 65,535 bytes. Admission (mint::validate) refuses an incoming nostr lock whose HTLC claim would exceed either limit (each input carries a preimage plus a signature), before any network call. Own locks are checked the same way before the attempt is journaled, both for the lock swap and for its later claim or refund.
  • fund/withdraw on nostr:// are refused clearly before the home is created or locked and before any journal intent; the sidecar serves no NUT-04/05/20. The library entry points (money::fund, withdraw_bounded, quote GET, melt POST) refuse too.
  • Refusals are typed, and only a mint NUT error is one (advisor HIGH on feat(trade): receive — import Cashu tokens into a trade home (stacked on #1107) #1119, which this PR made worse; fixed in 5deccae, then merged with feat(trade): receive — import Cashu tokens into a trade home (stacked on #1107) #1119's typed mint::MintRefusal in a40a888). Before, nostr_call formatted core's HttpError(400) as "HTTP 400". Core uses that for the wire codes expired and bad_request, which mean "not executed". receive took "HTTP 400" as a final refusal, so a token that expired because of clock skew became permanently refused. Now transport builds a MintRefusal in two cases only: over HTTP, from a parsed 400 NUT body (as feat(trade): receive — import Cashu tokens into a trade home (stacked on #1107) #1119's rpc did); over nostr, from the mint's own definitive numeric NUT error (nostr_refusal). Every transport code (expired, bad_request, rate_limited, unsupported, internal, unknown), plus 413, timeouts and relay failures, stays untyped and ambiguous: the attempt is kept, and the identical request is replayed later. No error is formatted as "HTTP 400" text any more.

Test map (tests/nostr.rs: real sidecar in-process on a local relay; no real mints)

Safety is asserted before return values in every test.

Requirement Test
issue → receive --token-file (CLI, hex input) → trade nostr↔https nostr_issue_receive_then_https_trade_completes_on_mint_relays_only (also: no mint kinds on market relays and vice versa)
Complete trade nostr↔nostr + lost reply / identical replay during locks and claims nostr_nostr_trade_completes_through_lost_swap_replies (each swap's first reply dropped; 4 swaps, each re-sent identically; conservation)
Timed refund on a nostr mint nostr_taker_timed_refund (lab)
Lost reply / identical replay during refund nostr_maker_refund_survives_lost_reply (lab)
Counterparty nostr mint unreachable → refund still happens nostr_counterparty_unreachable_refund_still_lands (lab; sidecar stopped, relay up = black hole)
Preflight refusal for a missing NUT nostr_preflight_refuses_missing_nut_and_cli_canonicalizes (NUT-14 off: library, list creates no listing, CLI)
relay.maxplayer.ai: mint yes, market no; defaults match the sidecar relays.rs::production_relay_carries_mint_traffic_but_never_market_traffic (real CLI, no socket opened), relays.rs::production_relay_is_forbidden (market fence, library), nostr.rs::default_mint_relays_match_the_sidecar_defaults, unit mint_relays_fenced_and_bounded (default order)
DLEQ verified on nostr-mint proofs nostr_dleq_verified_on_lock_admission (forged and missing DLEQ refused)
Dishonest counterparty locks with too many proofs nostr_lock_with_too_many_proofs_refused_at_admission (128 × 1-sat HTLC lock refused before any mint request)
fund/withdraw refused before any intent nostr_fund_and_withdraw_refused_before_any_intent (library + CLI; the home is never created)
Near the full 30 s window: completes or stays ambiguous nostr_slow_claim_inside_window_completes (25 s reply completes in the same call) and nostr_claim_past_window_stays_ambiguous_then_restores (36 s reply: retained, not abandoned, restored, never a second swap)
Swap exp ≤ send_before nostr_swap_exp_never_passes_send_before
nostr expired during receive is not a refusal nostr_expired_swap_reply_during_receive_stays_submitted_then_completes (tap answers the first receive swap with a mint-signed expired reply and the sidecar never sees it; attempt stays submitted, nothing credited; a later recover completes it, credited exactly once, exactly one replay) and unit only_a_mint_nut_error_is_a_nostr_refusal
Canonical form, relay fence, budgets unit: nostr_mint_canonical_form, mint_relays_fenced_and_bounded, nostr_grace_covers_window_exp_and_skew, nostr_claim_size_bound

Verification at head a40a888

Local (--locked, --test-threads=1, same commands and clippy -A list as the CI maxplayer-trade job):

  • cargo fmt --check: ok. Clippy default + lab --all-targets --no-deps -D warnings: ok.
  • Default: 133 passed, 0 failed. lib 31, cli 5, e2e 6, money 16, nostr 11, pinned_quote_recovery 1, receive 18, relays 8, review_regressions 17, round5 20, round7 0.
  • Lab (TRADE_LAB_SECONDS=1 --features lab): 183 passed, 0 failed. lib 31, cli 5, e2e 11, money 17, nostr 14, pinned_quote_recovery 1, receive 23, relays 8, review_regressions 46, round5 26, round7 1.
  • The lost-reply test is described in its own section below.
  • CI run 38006904078 on a40a888: 9/9 jobs green, including maxplayer-trade (fmt, clippy, default + lab tests). No reruns.

The lost-reply test: what it requires, and where it bends

nostr_nostr_trade_completes_through_lost_swap_replies (tests/nostr.rs:502) drops the first reply to every swap (two locks, two claims), so each one needs the identical re-send. It then runs both sides to a terminal state.

At production deadlines it must complete with exact balances. Lines 538–549: if both sides are not complete, assert!(lab, "production deadlines must complete through lost replies") fails. lab is true only with --features lab and TRADE_LAB_SECONDS set (line 515). Completion requires held == [96, 32, 24, 104] (line 539) and exactly 4 swap requests (line 541), so a lost reply never becomes a new swap. The default suite (locally and in CI's maxplayer-trade job) runs with lab false, so there the test passes only by completing.

With the lab's short locks (16 s short lock, 48 s long, 2 s cutoff) it may instead end fully refunded. That isn't required, only allowed. A 5 s re-send per swap can push the claim past the 16 s lock, and then refunding is the correct, safe outcome. To be plain about the history: the test as first written required completion in every mode. The first local lab run failed it with both sides refunded and every sat back. I then added this lab-only refunded branch, before the first push (it is already in 46d7084), so no CI run ever saw the stricter version. The production requirement was never loosened.

Which assertion proves no money is lost in the refunded branch: lines 550–554,

assert_eq!(
    (maker.as_str(), taker.as_str(), held),
    ("refunded", "refunded", [128, 0, 0, 128]),
    "SAFETY: a lab-deadline miss refunds both legs in full"
);

held is [maker@n1, taker@n1, maker@n2, taker@n2], the wallets' unspent balances. Each side must hold exactly the 128 sats it funded, on its own mint, with the counterparty holding 0. The conservation check at line 523 (held[0]+held[1] == 128 && held[2]+held[3] == 128, run on every branch) alone wouldn't be enough, because it would allow coins to move to the wrong party. The exact tuple rules that out. A half-refunded or stuck end (e.g. refunded/complete, or any state other than both refunded) fails the test.

The second relaxation (537e52c) is narrower, and it is lab-only too. Line 535 normally requires every swap to have been published at least twice (count >= 2, the identical re-send). In lab, a swap whose deadline-clamped window (exp - created_at) is at most one 5 s re-send interval may have count == 1. Its call ended ambiguous before a re-send was due, and recovery restored it instead of publishing anything new. A full lab run hit this once, with conservation holding. Outside lab the exception doesn't apply. Its limits:

  • In the refunded branch the test does not check the swap count (only the complete branch asserts 4).
  • The refunded branch is lab-only, so a regression that made production trades refund instead of complete would still be caught, by line 546.

Mutation check

17 mutants, each run against its named test in a separate worktree and CARGO_TARGET_DIR, both deleted afterwards. All 17 are killed by the final head.

  • Killed: admission claim bound removed; claim-size estimate ignores the witness; swap exp not clamped to send_before; no 2 s minimum window; nostr RPC cut at 20 s; nostr wallet bound back to 15 s; nostr grace back to 60 s; restore skipped on recovery; CLI fund/withdraw gate removed; library withdraw gate removed; library fund gate removed; DLEQ not verified at admission; relay.maxplayer.ai allowed (now intended, see below); non-canonical nostr URL accepted; NUT-14 not checked in preflight; maker own-mint refund skipped; taker timed refund never runs.
  • The first pass found one survivor: an estimate without the per-input witness still passed the 128 × 1-sat integration test, because bare 1-sat proofs fit. Fixed with unit test transport::tests::nostr_claim_size_bound in ae9844d, which finds the largest fitting lock, refuses the next, and shows the same proofs without witnesses would fit. Two mutants first missed their anchors because of formatting; after correcting the anchors, both were killed.
  • After Bob's decision (347ad88), the "relay.maxplayer.ai allowed" mutant describes intended behaviour and is retired. Three new mutants, run against their named tests, are all killed: market fence removed (production_relay_carries_mint_traffic_but_never_market_traffic), mint relay denied again (same test), default order changed (default_mint_relays_match_the_sidecar_defaults).
  • Refusal fix (5deccae, a40a888): five mutants, all killed. The old "any 400 is a refusal" rule is killed both by the expired-receive integration test and by the unit test; "never a refusal" by the unit test; "typed refusal for every nostr error" by the expired-receive test; HTTP 400 NUT body left untyped by receive.rs (definitive_refusal_is_terminal_refused, cli_exit_codes_for_journaled_refused_and_quarantined). Each mutant was run in place on a clean tree and reverted, with no extra worktree.

Follow-ups

  1. receive <token>: done in feat(trade): receive — import Cashu tokens into a trade home (stacked on #1107) #1119. This PR makes it work on nostr:// mints and tests issue → receive → trade.
  2. relay.maxplayer.ai for mint traffic: decided by Bob (2026-10-09) and done in 347ad88: mint traffic only, first default --mint-relay. Market fence unchanged.
  3. The sidecar has no configurable input fee (ppk 0). Fee-bearing nostr legs are covered only by the shared fee code and the HTTPS fee tests.
  4. feat(trade): receive — import Cashu tokens into a trade home (stacked on #1107) #1119's receive::RECOVERY_ITEM_SECONDS stays at 120 s. For nostr that is still more than 2 × 40 s per call; a cut leaves the receipt retained (ambiguous) for the next pass.

Not verified

  • No real mints, real relays (relay.maxplayer.ai, ditto, wellorder) or real wallets. In particular, nothing here proves relay.maxplayer.ai accepts and delivers 23410/23411 from the trade CLI's keys. docs/specs/seller-credits.md §6 records that the relay passes them for the sidecar (NIP-42, fresh keys); the trade client uses the same core connector with automatic NIP-42 auth, but this PR did not test against the live relay. Every nostr test uses an in-process sidecar on a loopback nostr-relay-builder relay. The default relays are not exercised here.
  • Real clock skew between wallet and mint. The grace arithmetic is checked by a unit test, not against a skewed mint.
  • Relay delivery under load or rate limiting; the local test relay rate limit is raised.
  • The 60 s message path cutting a nostr claim is argued from feat(trade): standalone fixed-lot Cashu token trading (credit trading prototype) #1107's design, not tested separately for nostr.

Review round 1 (reviews of a40a888; fixes 2ab62c2, merge of #1119 a5b6539 in 776896e, CI fix f0c0345, merge of #1119 059ebeb in 707f8e3)

Reviewers: cashu-rust-dev (approve-with-nits) and advisor (request-changes). Both are independent of the author. Every finding below was checked against the code at a40a888 before anything was changed. Fixes were made by the author's agent; Bob approved this pass at 02:07 UTC. Author-led work is not an independent re-review.

How the DLEQ disagreement was settled (advisor MEDIUM 1 vs cashu-rust-dev Q4)

Rejected. The bound correctly leaves DLEQ out, because DLEQ is never sent.

  • redeem_inner (mint.rs:452-457) does keep the counterparty's DLEQ on the journaled Attempt.inputs.
  • But the only place a claim/refund goes on the wire is execute → SwapRequest::new(a.inputs.clone(), messages) (mint.rs:552).
  • In cashu 0.17.2 SwapRequest::new builds inputs: inputs.without_dleqs() (nut03.rs:52-56), and without_dleqs sets p.dleq = None on every proof (nut00/mod.rs:82-90).
  • The core connector then serializes that request unchanged (post_swap → to_body(&request)), and checks the plaintext against MAX_PLAINTEXT_BYTES = 65,535 (mint_wire.rs:48). The sidecar drops any plaintext over that same limit (maxplayer-mint/src/server.rs).
  • So check_claim_fits clearing DLEQ (it also goes through SwapRequest::new) matches what is actually sent.

New boundary test transport::tests::nostr_claim_bound_covers_real_signed_claim_with_dleq covers locks of 1-sat proofs that carry DLEQ, for every count from 1 up to the largest one admission accepts:

  • it builds the real claim (add_preimage + sign_p2pk, DLEQ left on each proof) with binary-split outputs;
  • it asserts the serialized request contains no dleq;
  • it asserts the envelope is ≤ 65,535 bytes;
  • it asserts that the size limit, not the 128-I/O cap, is what stops admission.

If a future cdk version kept DLEQ on swap inputs, the no-dleq assertion would fail and the bound would have to count it.

Findings

# Source Finding Status Evidence / commit + test
A1 advisor MED 1 Claim bound ignores DLEQ Rejected (see above) 2ab62c2, nostr_claim_bound_covers_real_signed_claim_with_dleq. Mutation: claim witness without its signature in the estimate → fails "admitted 79-proof lock has a 66244-byte claim over the NIP-44 limit".
A2 advisor MED 2 README says a nostr receive cannot happen Fixed 2ab62c2: README Receive section now points to the nostr transport and its tests. Also audited README, SKILL.md and references for other stale nostr text (A3, A4 and the nits below).
A3 advisor LOW 3 Skill recover budget 120 s; a nostr swap item is 140 s Fixed 2ab62c2: SKILL.md says "nostr:// swap: 140 s". It stays under the 10,000-byte SKILL.md limit enforced by skill_commands_and_flags_match_binary_help.
A4 advisor LOW 4 "HTTP 400" is the wrong shape for a nostr refusal Fixed 2ab62c2: README, references/commands.md and references/recovery.md now say a refusal is the mint's numeric NUT code on either transport, only after an empty restore and UNSPENT inputs. Nostr expired / bad_request / rate_limited / timeout / 413 → submitted (exit 3).
A-n1 advisor nit README grace "170 s" Fixed Now 120 s, which equals NOSTR_ABANDON_GRACE_SECONDS.
A-n2 advisor nit README round-5 "15 s for every CDK call" Fixed Now 15 s HTTP / 40 s nostr://.
A-n3 advisor nit commands.md "Use HTTPS for real mints" Fixed Now HTTPS or nostr://<lowercase npub> for the credits mint.
C-Q2 / F1 cashu-rust-dev Composite budgets can cut a nostr call mid-window Validated; no code change needed (safe). Wording fixed. The advance (100 s), refund-phase (50 s), message (60 s) and item (140 s) budgets all use tokio::time::timeout. A drop leaves the swap exactly as it was before the call, and no caller treats it as a definitive failure:
- execute has no terminal path on a timeout;
- receive marks Refused only on a typed MintRefusal plus an empty restore plus UNSPENT inputs;
- lock failure becomes lock_reconciling / release only after abandoned (send_before + 120 s on the mint clock, plus a fresh not-landed check);
- the next tick restores the same outputs and replays the identical request, whose exp ≤ send_before is enforced by the mint.
The cost is liveness, not money. The "never cut short" claims in wallet.rs / coordinator.rs docs and README were reworded to "per call".
C-Q3 / F6 cashu-rust-dev Market fence bypassed by a trailing dot (relay.maxplayer.ai. / %2e) Fixed (cheap, in scope) 2ab62c2: market.rs trims the trailing dot before comparing. tests/relays.rs::production_relay_is_forbidden now covers the plain, uppercase, trailing-dot, %2e and port/path spellings. Mutation (dot not trimmed) → fails "SAFETY: market traffic to wss://relay.maxplayer.ai./ refused". An IP literal or CNAME still bypasses the hostname fence: deferred. Relays are operator-supplied, and refusing by IP / NIP-11 identity is a separate design.
C-F3 cashu-rust-dev nit Relay dedupe by exact string; loopback_only rejects [::1] Fixed 2ab62c2: dedupe on the parsed URL (lowercase host, no trailing dot or slash, default port), then apply the 8-relay cap. A single loopback helper is shared. Test mint_relays_fenced_and_bounded; mutations: raw-string dedupe → fails; old loopback check → fails on [::1].
C-Q6 / F5 cashu-rust-dev Attack surface of maxplayer-core features=["wallet"] (libgit2, hyper server, daemon code, rc path deps) Deferred, no change No concrete problem was found:
- one cdk/cashu 0.17.2, one nostr-sdk, one libsqlite3-sys;
- maxplayer-mint is a dev-dependency only;
- the trade binary never calls the extra code.
The path dependency means a core connector change alters this crate without a diff here. That is mitigated because CI has no path filters, so the trade job runs on every PR. Follow-up: a narrow core nostr-mint feature exposing only nostr_mint + mint_wire.
C-F2 cashu-rust-dev --mint-relay is not persisted across restarts Deferred Documented operator rule: commands.md already says "Preserve the same mint-relay set during recovery of a nostr:// trade". If the flag is missing, calls are ambiguous, never terminal. Follow-up: persist the relay set in the home and warn on mismatch.
C-F4 cashu-rust-dev nit claim_outputs assumes binary denominations Deferred Exact for the sidecar (UnitConfig::default()). Only a third-party nostr:// mint with non-binary amounts would differ, and the 512 B margin covers about 4 extra outputs. Follow-up: count with the keyset's FeeAndAmounts.
C-F7 cashu-rust-dev nit (test) Lab branch of the lost-reply test does not assert a clamped window or one output set per input set Deferred Test-only tightening. Sat conservation and the identical-event checks already hold in both branches.
C-F8 cashu-rust-dev nit nostr_nut_refusal is a denylist over cdk variants Deferred (needs a core API) Core does not expose whether the wire code was ErrorCode::Nut. Today's mapping is pinned by only_a_mint_nut_error_is_a_nostr_refusal. Follow-up: core exposes the NUT/named distinction and this crate allowlists on it.
Coord #1119 round 2 Merge origin/feat/credit-trade-receive a5b6539 (definitive NUT-code allowlist, double restore) Merged 776896e (merge, no rebase) Nostr refusals are still only the typed mint::MintRefusal built in transport::nostr_call. Receive gates them with mint::definitive_refusal (MintRefusal::DEFINITIVE), the same as HTTPS. New assertions in only_a_mint_nut_error_is_a_nostr_refusal build the error exactly as nostr_call does: 11001 is definitive; 11002, 11004, 50000, unknown codes and expired are not; anything definitive must be on the allowlist. Mutation: dropping both the allowlist and the transport cdk gate fails ("token pending stays ambiguous"). Dropping only the allowlist survives, because over nostr the cdk is_definitive_failure gate already excludes those codes (defense in depth, not a gap). Docs were merged to cover both transports.
CI — AtomicU64::fetch_update (added in #1119 test support) is deprecated on CI's current stable rustc, so -D warnings fails the trade clippy job (also red on #1119 at a5b6539) Fixed f0c0345 (helper), then #1119 fixed the same line itself (059ebeb); merge 707f8e3 takes #1119's version, so both branches carry identical test support.
advisor low-conf advisor ~1 s stall can make exp = send_before + 1; 60 s message path may always defer Noted The 180 s claim cutoff and the 120 s grace absorb it. The message path is fail-closed (same reasoning as C-Q2). Not load-tested.

Verification

After the merge (776896e, local, --locked, --test-threads=1):

  • fmt and clippy (default + lab, CI allow list): clean.
  • Default suite: 141 passed.
  • Lab suite (TRADE_LAB_SECONDS=1): 193 passed.

At 707f8e3 (after merging 059ebeb, a test-support-only change): clippy (default + lab) is clean and the receive suite passed 23. The full suites were not rerun locally for this one-file merge; CI covers them.

Pre-merge, at 2ab62c2, local, --locked, --test-threads=1:

  • cargo fmt --check: clean.
  • clippy, default and --features lab, with -D warnings and the CI allow list: clean.
  • Default suite: 134 passed, 0 failed.
  • Lab suite (TRADE_LAB_SECONDS=1): 184 passed, 0 failed.
  • Mutation checks were run in a separate worktree with a separate target dir, which was deleted afterwards. 4/4 mutants were killed at the intended assertion, and the unmodified fix passes.

Still unverified

  • No live relay or real mint. Delivery of 23410/23411 by the default mint relays is assumed, not measured.
  • The sidecar's server-side exp enforcement and reply replay were not re-read in this round.
  • The CNAME / IP-literal market-fence bypass is open (above).

Review round 2 (reviews of 707f8e3; fixes 39d892b, merge of #1119 round 3 e4a0059 in 6aceee5)

Reviewers: the CRD round-2 re-review (approve-with-nits) and the advisor re-review (approve-with-nits). Both are independent of the author. Fixes are author-led. Bob approved this pass at 15:02 UTC on 2026-10-10.

Finding Source Status Where / evidence
N1: a nostr receive whose swap is over the NIP-44 limit (long plain secrets, still under MAX_TOKEN_BYTES) stays submitted forever and holds the cap CRD Fixed receive.rs admission runs transport::check_swap_fits on the exact swap body (shared swap_request) after the outputs are built and BEFORE j.put. The token is refused with exit 1: nothing journaled, cap untouched, no swap published. Test nostr_oversized_receive_refused_before_journal: a 128×400-character-secret token is refused, then a 128×64-character token completes.
N1 follow-on: an EXISTING oversized submitted record brief Handled conservatively + documented It stays submitted (exit 3). Core's local 413 is untyped, so it is never refused, and it is never published; the inputs stay with the token holder. It holds its cap until resolved by hand (README receive section, recovery.md). This could only come from unreleased builds of this branch. Test nostr_preexisting_oversized_receive_is_never_sent. No automatic terminal state: marking it refused would need a non-mint classifier, which conflicts with "refusals only from typed MintRefusal".
Nit: pin 11003 as non-definitive over nostr CRD Fixed transport.rs only_a_mint_nut_error_is_a_nostr_refusal loop now includes 11003 (core-definitive, not allowlisted)
Nit: relay_key keeps ws/wss distinct; add a comment CRD Fixed transport.rs relay_key doc
Nit: shared host normalisation for market fence and relay dedupe CRD Fixed transport::normalized_host, used by market.rs fence and relay_key; production_relay_is_forbidden still passes
Nit: README late-lock window says 60 s only advisor Fixed README: abandon_grace_seconds 60 s HTTPS / 120 s nostr
Nit: advance-budget comment says 120 s item / 20 s states() advisor Fixed coordinator.rs ADVANCE_BUDGET_SECONDS doc: 140 s item and NOSTR_OUTER 40 s for nostr
Caveat: a deserialized SwapRequest would skip DLEQ stripping CRD No action Receive and claim rebuild with SwapRequest::new; nothing journals a whole SwapRequest
Stale mint.rs:552 citation advisor No action That citation lives in the review text, not in the code
Mutex for a 2nd MINT_RELAYS unit test CRD Deferred Only one unit test touches it today
F2/F4/F5/F7/F8, IP-literal/CNAME fence round 1 Deferred (unchanged) See round 1

Merge of e4a0059 (#1119 round 3). Doc conflicts in README, commands.md and recovery.md were resolved by taking #1119's final-refusal and refusal_code wording, keeping the nostr transport-code list, and adding 11003 to the ambiguous list. The auto-merge pushed SKILL.md to 10,024 bytes, so I trimmed it back to 9,996 (tests/cli.rs requires < 10,000). Nostr refusals still reach refused only through mint::definitive_refusal on a typed MintRefusal: the receive gate is unchanged apart from journaling refusal_code.

Local gates at 6aceee5 (all --locked, CI clippy allow list): fmt OK; clippy default and lab clean; default suite 143 passed, 0 failed; lab suite (TRADE_LAB_SECONDS=1) 195 passed, 0 failed. One local default run at this head failed money::failed_preflight_refuses_funding_before_intent once, while sibling worktrees were running heavy parallel test jobs. That test and its code are untouched since 707f8e3. It passed 5/5 in isolation and in the full default rerun, and passed in CI run https://github.com/MakePrisms/maxplayerai/actions/runs/38066033628 (all jobs green at 6aceee5, including maxplayer-trade; no reruns needed).

Mutation checks (disposable worktree + separate target, both deleted afterwards). Baselines pass. Each mutant fails at its SAFETY assertion:

  • removing the admission check_swap_fits → nostr_oversized_receive_refused_before_journal fails at "SAFETY: an unsendable nostr receive must be refused at admission" (the record went submitted);
  • treating a non-definitive swap error as refused → nostr_preexisting_oversized_receive_is_never_sent fails at the state assertion;
  • adding 11003 to MintRefusal::DEFINITIVE → the unit test fails at "SAFETY: nostr NUT 11003 stays ambiguous".

Still unverified: no real mint or relay was used. The fix was not checked against a non-sidecar nostr mint's size limits. The oversized-record path was tested with a synthesized journal record, not one written by an older binary.

Review round 3 (reviews of 6aceee5; fixes a8dd75d)

Reviewers: CRD round-3 re-review (approve-with-nits) and advisor round-3 re-review (approve-with-nits), both independent of the author. Fixes are author-led. Bob approved this pass at 19:48 UTC on 2026-10-10. origin/feat/credit-trade-receive is still e4a0059, so there was nothing to merge; no rebase, no force-push.

Finding Source Status Where / evidence
NEW-1 / Nit 1: "holds its cap until resolved by hand" names no procedure and conflicts with "never import the token elsewhere" CRD NEW-1, advisor Nit 1 Fixed (docs) recovery.md submitted row and README receive section: for that legacy oversized nostr row only, redeem the original token elsewhere (the swap never left the process); the next recover sees SPENT inputs with none of our outputs, records already_spent and frees the cap. Every other submitted receive must still never be re-imported. Test comment/message in nostr_preexisting_oversized_receive_is_never_sent reworded.
Nit 2: skill command reference omits the pre-journal NIP-44 refusal; SKILL.md lost "never re-import" in the merge trim advisor Fixed (docs) commands.md pre-journal refusal list adds the nostr:// swap that does not fit one NIP-44 request (exit 1, nothing journaled, cap untouched). SKILL.md receive: exit 1 includes the pre-journal refusal (nostr NIP-44 size); exit 3 says recover, never re-import. SKILL.md is 9,998 bytes (tests/cli.rs < 10,000).
README late-lock line longer than its neighbours CRD (cosmetic) Fixed Rewrapped
CRD suggestion: a test that spends the legacy row's inputs and asserts already_spent + cap (0,0) CRD Deferred The legacy row is synthetic (only unreleased branch builds could write it) and the probe → Spent → already_spent path is already covered for HTTPS receives; adding a nostr-sidecar variant is a test-only follow-up.
NEW-2: receive recovery budget is still a flat 120 s for nostr mints CRD Deferred Pre-existing, liveness only: a cut is ambiguous and the record stays submitted and replays identically. Making RECOVERY_ITEM_SECONDS nostr-aware changes the recovery timing on a money path and deserves its own review.
Micro-nit: nothing asserts nostr_nut_refusal(&nut(11003)) CRD Deferred (optional) The safety property (!definitive for 11003) is pinned; the typing assertion adds no safety.

Local gates at a8dd75d (docs/test-comment only vs 6aceee5; all --locked, CI clippy allow list): fmt OK; clippy default and lab clean; default suite 143 passed, 0 failed; lab suite (TRADE_LAB_SECONDS=1) 195 passed, 0 failed. (The first lab run was killed with its shell session mid-suite, with no failure logged; the detached rerun completed.)

Still unverified: no real mint or relay; the legacy-row resolution was traced in code, not executed.

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.
- src/transport.rs: every mint request goes through post/get; http(s) unchanged, nostr:// via
  maxplayer_core::nostr_mint::NostrMintConnector over --mint-relay (default ditto+wellorder,
  relay.maxplayer.ai refused via a one-entry deny list).
- Asset: canonical nostr://<lowercase npub>; CLI input may be hex/uppercase (from_cli).
- Timing: nostr wallet calls bounded at window+margin (40 s), swaps exp<=send_before,
  abandonment grace 170 s, nostr recovery item budget max(120, 100+40) s.
- Admission: nostr legs whose claim/refund swap exceeds 128 I/O or the NIP-44 limit refused.
- fund/withdraw refuse nostr:// before any home/journal effect.
- tests/nostr.rs: in-process maxplayer-mint sidecar on a local relay.
…-> trade test

- receive.rs CDK wallet calls use bounded_for (nostr window, not 15 s).
- transport: a definitive nostr refusal reads as 'HTTP 400' like an HTTP NUT error, so
  receive's refusal classification is transport-independent; everything else stays ambiguous.
- tests/nostr.rs funds homes through receive (library and CLI --token-file with hex input).
… to end ambiguous

Under TRADE_LAB_SECONDS the taker lock's window is clamped to send_before and can be shorter
than the 5 s re-send interval; the lost reply is then recovered by restore, not re-send. The
tap records each request's window (exp - created_at); count 1 is allowed only for lab windows
<= DEFAULT_RESEND_EVERY. Conservation and production-deadline completion stay asserted.
@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 9:02pm UTC

Request Review

…C witness

Mutation check showed the claim-size estimate could drop the witness and the 128-proof
integration test still pass (128 bare 1-sat proofs fit). The unit test finds the largest
fitting lock, refuses the next, and proves the same proofs without witnesses would fit.
…irst default --mint-relay

Bob's decision (2026-10-09): allow relay.maxplayer.ai for kinds 23410/23411 and list it first,
ahead of ditto and wellorder, matching the credits sidecar's default list (parity test against
maxplayer-mint MintConfig::effective_relays). The market fence (3410/3411/23412) is unchanged; a
new CLI test proves the same host is refused as --relay and accepted as --mint-relay, before
any socket opens (never contacts the real relay).
…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)
…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.
… nostr mints

Conflicts: mint.rs keeps the receive branch's MintRefusal and this branch's transport dispatch
(rpc/rpc_until -> transport::post); the HTTP path in transport::post now builds MintRefusal from a
parsed 400 NUT body exactly as the receive branch's rpc did. Over nostr, MintRefusal is built only
from the mint's own definitive NUT error (nostr_refusal); expired/bad_request/rate_limited/
unsupported/internal/unknown/413/timeout/relay failures stay untyped and ambiguous. SKILL.md takes
the receive branch's wording.
…, relay dedupe/loopback, trailing-dot market fence, docs

- transport: pin that the sent claim carries no DLEQ (cdk SwapRequest::new strips it) with a
  really signed, DLEQ-carrying claim at the admission boundary; dedupe mint relays on the parsed
  URL before the 8-relay cap; one loopback helper (accepts [::1]).
- market: production-relay fence ignores a trailing host dot (relay.maxplayer.ai. / %2e).
- docs: nostr receive works (README); refused = mint NUT code on either transport; 120 s nostr
  grace (was misprinted 170); per-call bounds vs enclosing budgets; skill 140 s nostr swap item.
…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
…, double restore) into nostr mints

Nostr refusals stay the typed mint::MintRefusal and go through the same MintRefusal::DEFINITIVE
allowlist (asserted in transport::tests::only_a_mint_nut_error_is_a_nostr_refusal).
…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.
… (review round 2 N1) + nits

- receive admission runs transport::check_swap_fits for nostr:// mints before any journal
  write or cap use; long plain secrets under MAX_TOKEN_BYTES are refused (exit 1)
- pre-existing oversized submitted attempt: stays submitted (local 413 is ambiguous, never
  published, never refused); documented manual resolution + test
- 11003 pinned non-definitive over nostr; shared normalized_host for market fence and relay
  dedupe; ws/wss dedupe comment; README late-lock grace per scheme; advance budget comment
…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.
- recovery.md/README: the legacy oversized nostr submitted row is resolved by
  redeeming the original token elsewhere; the next recover records
  already_spent and frees the cap (that row only).
- commands.md: list the pre-journal NIP-44 size refusal (exit 1, nothing
  journaled, cap untouched).
- SKILL.md: receive exit 1 includes the pre-journal refusal; restore 'never
  re-import' on exit 3 (9,998 bytes).
- README late-lock line rewrapped; test comment/message wording.
@maxie-agent
maxie-agent marked this pull request as ready for review October 10, 2026 21:54
@maxie-agent
maxie-agent merged commit 704a4c5 into feat/credit-trade Oct 10, 2026
11 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 — a8dd75d7 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