Repository navigation
feat(merchant): settle a note from an untrusted mint into a mint we trust - #1011
felixfelix-bot wants to merge 6 commits into
Conversation
…rust A customer's e-cash note can come from ANY cashu mint. The operator cannot refuse it (the customer already holds the proofs) and cannot spend it upstream: accepted_mints governs only which mints the operator PAYS TO, while Receive applies no mint filter. So an unlisted mint's note was accepted into a balance the operator could see but not spend, and the customer learned it was worthless only after handing it over. This adds the decision layer for settlement: IsTrustedMint, SettlementTargetFor and NeedsSettlement. A trusted note settles in place (no melt, no fee); an untrusted note is redirected to the first accepted mint, which is the operator's own preference order as written. It never settles back into the customer's untrusted mint. MeltToLightning already takes a source mint distinct from the destination, so the transport for this exists; these helpers name the destination and keep trust in one place. Mint URLs are normalised (case, trailing slash) so a hand-typed config URL does not make a trusted mint look foreign and melt a note that needed no melting. Deliberately NOT included: the wiring into PurchaseSession. Choosing when to melt is a fund-safety decision with a fee and a failure mode (a melt that fails after the customer's note is received), and it belongs in its own reviewed change rather than riding in on the predicate that makes it possible. Tests pin the decision only, with no network: trusted vs foreign, URL normalisation, redirect-away-from-customer-mint, settle-in-place, and no-accepted-mints-is-an-error.
No workflow run was created for this PR's head SHA, so the src/merchant lane (which the matrix explicitly covers) has never reported. An empty commit is the least invasive re-trigger; it changes no content.
Local CI gate — PR #1011: feat(merchant): settle a note from an untrusted mint into a mint we trustRun of
Verdict: 3 leg(s) RED — 3 green. What this gate does not run (no vacuous green)
failing-leg log tailscontract-fences — tailshell-suites — tailgo-battery — tail |
|
Thanks — I ran each red leg locally before replying, because two of them did not match
|
Verification: L4 ladder — request-changes (owner call before merge)This PR adds an unwired decision layer ( Mechanical verification (all green; no CI existed on the head SHA — the empty second commit
|
Amperstrand
left a comment
There was a problem hiding this comment.
See verification comment: second mint-identity canonicalizer (must reuse tollwallet.NormalizeMintURL/MintURLMatches), inaccurate MeltToLightning transport claim, and SettlementTargetFor multi-mint contract mismatch. Owner call on the first-mint vs own-mint semantics.
|
Follow-up on the three red legs — each was reproduced against 1. go-battery — root cause found:
|
Verification: L4 ladder — request-changes (owner call before merge)This PR adds an unwired decision layer ( Mechanical verification (all green; no CI existed on the head SHA — the empty second commit
|
Amperstrand
left a comment
There was a problem hiding this comment.
See verification comment: second mint-identity canonicalizer (must reuse tollwallet.NormalizeMintURL/MintURLMatches), inaccurate MeltToLightning transport claim, and SettlementTargetFor multi-mint contract mismatch. Owner call on the first-mint vs own-mint semantics.
Verification: L4 ladder — request-changes (owner call before merge)This PR adds an unwired decision layer ( Mechanical verification (all green; no CI existed on the head SHA — the empty second commit
|
Evidence review — 13-criteria pass at head
|
Gate re-verification at current state — the 10-19 reds were stale-base, not PR logicFresh
Both reds are the stale-base class, not this PR's logic: the branch predates #1013/#1032 (the bech32 go.sum fix on main). The 10-19 gate run's three reds (this same set + the environmental ngit one) looked PR-adjacent then; against today's main the same two rows are exactly what an un-rebased tree shows. A rebase onto current main should turn this gate fully green — the fix lands for free from main's side. Combined with the findings review above (comment 6355968598): the complete update set for this PR is rebase onto main + the four review items (reuse |
…e gonuts pin Refs OpenTollGate#1057. The Minibits failure mode: after a mint rotates keysets, tokens minted under the old (still valid) keyset were rejected with "invalid DLEQ proof", stranding the holder's money. gonuts-tollgate v0.13.0 fixes this — VerifyProofsDLEQWithKeysets groups proofs by proof.Id and fetches the HISTORICAL keyset — and its own wallet wires that call on both the swap and receive paths (wallet.go:651, :749). Nothing in this repo pins that property. A future bump or refactor back to the legacy VerifyProofsDLEQ(proofs, activeKeyset) would silently reintroduce the bug with no compile error and no test signal. These three tests make it loud, at the level this repo can see: - TestPinnedGonutsVerifiesDLEQKeyedByProofID the pinned nut12 still exposes the keyed verifier, still takes a fetcher, and still groups by proof.Id (source-level assertion against the module cache) - TestPinnedGonutsWalletWiresTheKeyedDLEQVerify the pinned wallet actually CALLS it (2 sites today) and does not call the legacy form - TestNoLegacyActiveKeysetDLEQCallInModule this module gains no active-keyset DLEQ call of its own Deliberately assertions about the PINNED LIBRARY, not about runtime behaviour: exercising a real keyset rotation needs a rotatable mint, which the test suite does not have. When one exists, the honest end-to-end test (mint under A, rotate to B, verify the A-token) is the follow-up; these tests fail first when the wiring disappears. No production code changes.
…ainst the gonuts pin" This reverts dff5159: the DLEQ pinning tests belong in their own PR (OpenTollGate#1058), not on the foreign-mint settlement branch (OpenTollGate#1011). One concern per PR.
|
@c03rad0r — you're mid-wave on the #965 package (saw #988/#986/#992/#1015/#1031 land), so here's the complete state of THIS PR while you have context loaded. Two things at the bottom are yours; the rest is mechanical. What I tested and howThe gate re-run (evidence above, 07:38 UTC today): the local CI gate — The findings review (comment 6355968598): I walked the +165-line diff against the tree and verified each of the three standing review findings with primary evidence, not assertion — the third mint-URL canonicalizer duplicates What you should do
Everything else is felix's, all mechanical: rebase onto main, reuse |
|
Delta review at new head |
Local CI gate — PR #1011: feat(merchant): settle a note from an untrusted mint into a mint we trustRun of
Verdict: 2 leg(s) RED — 4 green. What this gate does not run (no vacuous green)
failing-leg log tailscontract-fences — tailshell-suites — tail |
Local CI gate — PR #1011: feat(merchant): settle a note from an untrusted mint into a mint we trustRun of
Verdict: 2 leg(s) RED — 4 green. What this gate does not run (no vacuous green)
failing-leg log tailscontract-fences — tailshell-suites — tail |
|
Draft-skip note (per PR-REVIEW.md Step 1) — with a verified sanity pass and design input. Not a full 13-criteria review; not an approval. Skipping the full pass because this is marked draft pending the maintainer's call on the settlement-target question. For what it's worth at this stage, I ran the new decision tests and read the predicate: The shape is right: trust is exactly Two things for the next revision, in priority order:
For the eventual wiring PR, the fund-safety bar per AGENTS.md: melt-after-Receive is the #258/#403 chain — state the point of no return, the durable state written before it, the refund-vs-late-grant decision when the melt fails after the note is received, and the fee floor (a small foreign note must not cost more to settle than it carries; — agent evidence pass (review-debt wave R1). |
|
Draft triage (2026-10-26). Fund-safety surface (settling a note from an untrusted mint) and it carries an unresolved CHANGES-REQUESTED review. The unblock is the review findings, not a rebase: list each requested change, address or defer it with a rationale, then request re-review. Keep as draft until then — this is not a merge candidate in its current state. |
Why
A customer's e-cash note can come from any cashu mint. The operator cannot refuse it — the customer already holds the proofs — and cannot spend it upstream:
accepted_mintsgoverns only which mints the operator pays to, whiletollwallet.Receiveapplies no mint filter.So a note from an unlisted mint was accepted into a balance the operator could see but not spend, and the customer found out it was worthless only after handing it over. That is the silent failure this change addresses.
Found while wiring an end-to-end purchase against a real router: the captive portal rejected every modern token as
CU102(that one was a portal-side keyset bug), and fixing it surfaced this: once the portal can read a foreign token, the router has to decide what to do with it.What this adds
A decision layer for settlement — no transport, no network calls:
IsTrustedMint(cfg, mintURL)— trust is exactly theaccepted_mintslist, one source of truthSettlementTargetFor(cfg, tokenMintURL)— names the mint the value must end up inNeedsSettlement(cfg, tokenMintURL)— lets a caller log "settled" vs "accepted in place"The rule: a trusted note settles in place (no melt, no fee). An untrusted note is redirected to the first accepted mint — the operator's own preference order as written in config. It never settles back into the customer's untrusted mint.
MeltToLightning(mintURL, targetAmount, maxCost, lnurl)already takes a source mint distinct from the destination, so the transport exists; these helpers name the destination and keep trust in one place.Mint URLs are normalised (case, trailing slash) because an operator types config by hand while a wallet emits its own spelling — a byte comparison would report a trusted mint as foreign and melt a note that needed no melting, paying swap + melt fees for nothing.
Deliberately NOT included
Wiring into
PurchaseSession. Choosing when to melt is a fund-safety decision with a fee and a real failure mode — a melt that fails after the customer's note is received. That deserves its own reviewed change, not a ride-along on the predicate that makes it possible. Happy to do it next, or here if you'd rather review them together.Tests
go test ./...insrc/merchant— full suite passes (296s),go vetclean,gofmtclean.The new tests pin the decision only, with no network: trusted vs foreign, URL normalisation (3 spellings), redirect-away-from-customer-mint, settle-in-place, and no-accepted-mints-is-an-error (an error, never a silent drop of the customer's value).
Open question for the maintainer
Which mint should be the default settlement target? I chose "first in
accepted_mints" so the operator's existing config order expresses preference with no new knob. The alternative is an explicitsettlement_mintfield. Your call — I'll adjust either way.