Cross-mint hop: pay a seller at a mint the buyer holds no ecash at - #196
Merged
Conversation
A buyer funded only at mint A can pay a seller who accepts only mint B by
hopping through Lightning between the mints: a NUT-04 mint quote at B yields a
bolt11, a NUT-05 melt from A pays it, and the buyer ends up holding fresh ecash
at B for the ordinary send path. Lightning is connective tissue between mints
only, so the wire keeps one settlement shape.
This lands the decision half: `PayPlan::{Direct, Hop}` and `plan_payment`, a
pure function of the buyer's selected mint and the seller's accepted set. It
moves no money and touches no network, so whether we hopped is testable
directly rather than inferred from an outcome.
`realized_mint()` is the hop's TARGET, since that is where the ecash the seller
receives lives and the terms, attempt id and co-signed receipt must all agree on
one mint. Target selection is the first admissible accepted entry and must stay
deterministic: the attempt id derives from the realized mint, so a retry that
re-derived a different target would compute a different attempt id and defeat
pays-once.
Both legs pass the real-mint fence. Fencing the target matters as much as the
source — a hop ends with the buyer holding ecash there, so an unfenced target
would admit a real-sats mint while allow_real_mints is off. With no admissible
target the plan refuses fail-closed rather than landing somewhere unpermitted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…teral Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nce, gate-skew evidence Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HopCost totals the three things the buyer pays to deliver offer.amount across a hop — melt amount, Lightning fee reserve, and the source mint's input fee. All three must clear the cap before the melt fires; a fee that reaches the wire without passing the cap is the #185 class. The total is checked rather than saturating: a cost we cannot add up is a cost we decline to spend. HopJournal records the one fact cdk cannot know. cdk journals each half already (a WalletSaga carries the quote id, and check_melt_quote_status resumes off the persisted store from a cold process), but nothing in cdk knows the two quotes are one logical hop. Written before the melt, matching the budget ledger's write-before-effect discipline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The decision half planned a hop; this half carries one out. A melt at the buyer's funded mint pays a mint quote raised at the seller's, and the ordinary send path takes over from there unchanged. `crossmint_hop` owns the execution. Its whole reason for existing is the crash window: cdk persists each leg on its own, but nothing in cdk knows the two quotes are one hop, so that pairing is journalled before the melt and the persisted quote ids win over any freshly planned ones. The resume decision is taken from what the mints say, never inferred — unpaid melts, pending refuses, paid-but-unissued is the strand and says so on stderr, paid-and-issued completes without touching either mint. Payment planning replaces the old realized-mint resolver at all three of its callers, which is what makes the hop reachable at all: accept refused a no-overlap claim before the pay path ever saw it, and the award filter skipped those claims earlier still. `mint_unreachable_pay` is therefore unreachable on the pay path; the target fence is the refusal that covers it, and the tests that asserted the old refusal now pin the boundary between hopping and refusing from both sides. The accept-bind still seals the buyer's own funded mint. Sealing the hop target instead would re-plan at pay time as a direct payment from a mint the buyer holds nothing at; the target is re-derived from the seal plus the accepted set frozen beside it, so the attempt id stays stable without storing it twice. A hop the source wallet cannot fund refuses while planning, before the cap is charged — declining a claim must not cost budget.
A hop interrupted by a crash is not something the next pay attempt necessarily re-drives, so the buyer daemon sweeps for one at startup, before it serves. It opens both of the hop's mints and recovers each, because cdk's saga recovery filters to its own wallet's mint: recovering one wallet reports success having examined half the hop. A sweep that somehow covered one mint refuses to call the hop swept rather than reporting it done. The teeth pin what the old membership refusal used to guarantee, at the layer that replaced it. A cap one sat under the planned cost stops the hop before it melts rather than failing it afterwards; a cap sized to the delivered amount does not authorize the hop that delivers it; and no failing leg — either mint unreachable, melt in flight, melt failed, or the target refusing to issue — leaves a completion record or claims ecash. `cap_charge` names the choice between the two numbers so the choice itself has a test, rather than living inline where only a live trade would notice it changing.
`crossmint.rs` and `crossmint_hop.rs` are rustfmt-clean under edition 2024, and the hand-formatted spots in `authorize_pay.rs` now match what rustfmt wants, so the slice adds no formatting debt to a tree that already carries plenty.
The smoke is the only thing that can prove real Lightning routing between two mints, because test ecash structurally cannot hop. It is also a real-money spend plus a money-gate config change, so it refuses unless an authorization token names the exact amount and both mints, and it does not print that token on refusal — a refusal that echoed it would hand it straight back to whatever just tried to run this. It restores the real-mint fence on both homes from an EXIT trap, whether the trade succeeds or fails, and it asserts the thing the hop exists for: the seller receives exactly the offer amount while the buyer is charged more.
Both are scaffolding for shipping this change, not part of what ships. The argument for the design belongs in the PR description and the module docs, where a reader who has never seen this branch will actually find it; a PR-BODY.md checked into dev is a copy that goes stale the moment the PR is edited.
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.
Cross-mint hop: pay a seller at a mint the buyer holds nothing at
A buyer funded only at mint A could not pay a seller who accepts only mint B. Now it can: the buyer's own wallet raises a NUT-04 mint quote at B, melts at A to pay that invoice, and holds fresh ecash at B — where the existing send path takes over completely unchanged. Lightning is connective tissue between the two mints and nothing more, so the wire still carries exactly one settlement shape, and pays-once, the co-signed receipt, and amount-from-the-buyer-signed-offer are untouched by it.
Delivery is pinned; the buyer's cost floats
The reference pattern for this (agicash's
receive-cashu-token) solves the fee problem the other way round: it pins the token amount and lets the delivered amount float DOWN through a bounded convergence loop until the fees fit. That is precisely what our strongest money invariant forbids — the seller receives the amount they signed for.So the direction is inverted here. Delivery is pinned at
offer.amountand the buyer's cost floats up:Two consequences worth stating: the convergence loop disappears entirely (cost is one forward computation), and the fixed-delivery invariant holds by construction — the amount the seller receives is never a function of any fee reading.
⚠ A documented refusal disappears
mint_unreachable_payis unreachable on the pay path as of this slice. "The single-mint buyer wallet holds no balance at any accepted mint" was a refusal; it is now a hop. That is the feature, but it is also a safety property being removed, so here is what replaces it.The covering refusal is the target fence. A hop ends with the buyer holding ecash at the target, so an unfenced target would be a real-mint back door with
allow_real_mintsoff — a NEW entry point to money created by this slice.plan_paymentfences the source AND the target and refuses fail-closed when no accepted mint is admissible. Both halves of the boundary are pinned by tests sitting next to each other:pay_plan_hops_when_the_configured_mint_is_not_listed_but_the_target_is_admissible— the old refusal's fixture, now asserting the decision that replaced it.pay_plan_refuses_when_no_overlap_and_no_accepted_mint_is_admissible— no admissible landing still refuses.pay_plan_refuses_a_no_overlap_hop_under_the_default_posture— with the flag off the fence admits exactly one mint, so a hop is structurally unreachable in the default posture. The operator's opt-in is what makes two distinct admissible mints possible at all.authorize_pay_refuses_an_inadmissible_hop_with_zero_spend_and_no_pairing— the same refusal end-to-end through the real pay path: zero budget burned, no pairing left on disk for a later run to resume.The replacement invariant: the hop fails closed at every leg
With the membership refusal gone, the hop is what now stands between "cannot settle at the seller's mint" and a wrong or partial spend. Every leg refuses without spending:
planned_costplanned_costPendingFailedno_failing_leg_leaves_a_completion_record_behinddrives all five reachable failure legs and asserts, for each, that no completion record was written and no ecash was claimed.The decision seam turned out to be three seams
resolve_realized_minthad three production callers, and wiring only the pay path would have shipped this feature dead — the other two refuse a no-overlap claim before the pay path ever sees it:job_lifecycle::accept— sealed the realized mint and refused no-overlap ("never accepted into an unpayable bind"), so such a job never reachedauthorize_pay.buyer::lifecycle::claim_is_payable— the award filter. Its own comment claimed "the SAME resolution the pay path performs", so the buyer would never even award a claim it can now settle. That comment is rewritten to the new truth.All three now call
crossmint::plan_payment;resolve_realized_mintis deleted.Why the accept-bind still seals the buyer's own mint
The seal stays the buyer's selected (funded) mint, not the hop target. Sealing the target would break the feature: at pay time the sealed value would already be in the accepted set,
plan_paymentwould sayDirect, and the hop would never fire — a direct payment from a mint the buyer holds nothing at.Attempt-id stability is preserved without storing a second field. On the direct path the sealed value is byte-identical to before (the old resolver returned the buyer's own mint in every
Okbranch). For a hop, the realized mint is re-derived deterministically from two values that are both frozen at accept — the sealed selection and the accepted set beside it — so a config-default change after accept still cannot shift the attempt id.the_sealed_source_replans_into_the_same_planpins exactly that.Pays-once across two mints
cdk journals each leg on its own: a melt quote's state is recoverable from a cold process by quote id, and a mint quote that was paid but never issued can still be issued later. The one thing nothing in cdk knows is that the two quotes are one hop — so that pairing, and nothing more, is what gets journalled, before the melt.
That ordering is what makes the melt leg safe to re-enter, and it is stronger than marking a melt "initiated" once the mint reports it pending — that leaves a window where money is in flight and the flag says otherwise.
On a resumed attempt the persisted quote ids WIN over freshly planned ones. Raising a second melt quote for one attempt id is exactly the double-pay the journal exists to prevent; a pairing that disagrees with the persisted one is refused outright rather than reconciled.
The resume decision is taken from what the mints say, never from what we infer:
UnpaidPendingPaidPaidThe strand row is the one that must never pass in silence: a buyer whose sats left the source but whose ecash never arrived has money that is neither spent nor held.
Recovery runs on both mints, or refuses to call it recovery
cdk's
recover_incomplete_sagasfilters to its own wallet's mint. A two-mint operation recovered on one wallet silently skips the other mint's saga — it reports success having examined half the problem. The buyer daemon's startup sweep opens both mints and recovers each, andrequire_both_mints_recoveredturns a half-covered sweep into a refusal rather than a false "swept".The sweep exists because a hop interrupted by a crash is not something the next pay attempt necessarily re-drives: that attempt may never be retried, and the sats would sit melted at the source with no ecash anywhere and nothing looking for them. It reports unconditionally, including the pass that found nothing — silence would be indistinguishable from a sweep that has stopped running.
Teeth, and the mutations that prove they bite
586 workspace tests pass (
cargo test --workspace, rc=0) on the rebased tip. Seven mutations were each reverted after proving a specific tooth fails — not a blanket failure:Directa_second_pairing_for_one_attempt_is_refused_rather_than_meltedkill_between_melt_and_mint_pays_once_on_restart_and_reports_the_stranda_sweep_that_covered_one_mint_refuses_to_call_the_hop_sweptdelivered_sats, notplanned_costthe_cap_is_charged_the_hop_cost_and_the_direct_amountcap_chargeexists as a named function precisely so that last mutation has somewhere to bite; inline, only a live trade would have noticed it change.The kill-between-legs tooth is the strong form of pays-once: the run dies after the melt lands, a restart resumes from the journal, and the assertion is that the second run melts zero times, issues exactly once, and reports the strand.
Scoped out, named rather than hidden
// interim:at the charge site.Failedmelt dead-ends the attempt. Recovering it means a superseding melt quote against the still-unpaid target invoice, which turns the melt leg into a sequence and requires every pays-once argument to be re-made over that sequence. Marked// interim:in code.extra_mintsis dead config, which is why a hop needsallow_real_mintsto be reachable at all.Walletfaithfully enough to reproduce its per-mint saga filtering. Instead the property is a runtime refusal, and the guard that enforces it is toothed. That is the honest form when the dependency cannot be faked: enforce it at runtime and test the enforcement.No live cross-mint trade has been run
Test ecash structurally cannot hop: fake mints cannot pay each other's invoices over real Lightning. So control flow, the journal, budget accounting and recovery are proven hermetically; real Lightning routing between two mints is not. A live cross-mint smoke is a real-money spend plus a money-gate config change, and is gated on the repo owner. The smoke script is written ready-to-run for the moment that authorization arrives.
⚠ Gate evidence — this box's toolchain is older than the tree's
The newest Rust available here is 1.95.0; the tree is formatted and linted by a newer stable. On the pristine merge base
8326c83, before a single edit:cargo fmt --all -- --check→ rc=1, 733 diffs--all-targets -- -D warnings→ 57 errors across 20 filesCI runs neither fmt nor clippy —
.github/workflows/ci.ymlis build + test only, ondtolnay/rust-toolchain@stable. Whole-tree fmt and clippy on this box therefore measure toolchain skew, not this diff, so the evidence below is per-file against that same merge base:crossmint.rscrossmint_hop.rsauthorize_pay.rsjob_lifecycle.rspayment_wallet.rswallet_ops.rsbuyer/mod.rsbuyer/lifecycle.rsBoth new modules are fmt-clean and clippy-clean. No touched file gains a single fmt hunk or clippy hit;
authorize_pay.rsloses several because the superseded resolver went with them. Whole-crate clippy sits at 48 on this branch. Provisioning a matching toolchain fleet-wide is a separate call and deliberately does not ride this slice.Counting is per-hunk rather than per-byte on purpose: rustfmt prints the absolute path in every hunk header, so byte totals differ between two checkouts for reasons that have nothing to do with the code.
Gates run
Every feature combo the slice ships in is compiled, not just the money set — a cfg-gated module is invisible to a gate that never enables its cfg.
One gate-hygiene trap worth not repeating:
cargo test -p mobee-core crossmint::returns rc=0 with zero tests run, becausewallet/gatewayare not default features and the module is cfg-gated out. Assert the test count, not the rc.Rebased onto dev@8326c83
Rebased after #198 merged. Zero file overlap between the two changes, so the rebase was clean — but
every gate number above was re-measured on the rebased tip, not carried forward: a gate result
proves something about the tree it ran on, and that tree changed.
What moved: the suite is 586 rather than 577 (#198's own tests), and the per-file fmt/clippy base is
now
8326c83. What did not move: the per-file hunk and hit counts are identical, because #198touched none of the files this slice touches — verified against a worktree of the new base rather
than assumed from the non-overlap.