Skip to content

Cross-mint hop: pay a seller at a mint the buyer holds no ecash at - #196

Merged
orveth merged 9 commits into
devfrom
crossmint/hop
Jul 27, 2026
Merged

Cross-mint hop: pay a seller at a mint the buyer holds no ecash at#196
orveth merged 9 commits into
devfrom
crossmint/hop

Conversation

@orveth

@orveth orveth commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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.amount and the buyer's cost floats up:

planned_cost = melt.amount + melt.fee_reserve + input_fee

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_pay is 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_mints off — a NEW entry point to money created by this slice. plan_payment fences 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:

leg what fails what happens
plan fence rejects source or target refuse before the gate — zero charge
plan mint quote / melt quote / fee read fails or times out refuse before the gate — zero charge
plan source wallet cannot cover planned_cost refuse before the gate — zero charge
gate cap below planned_cost the effect never runs, so no melt is attempted at all
melt source mint reports Pending refuse, never melt again (money may be in flight)
melt source mint reports Failed refuse; nothing left the wallet (see #194)
mint target unreachable or refuses to issue refuse; no completion record, no ecash claimed
mint target issues an amount ≠ the pinned delivery refuse rather than carry a short amount into the send

no_failing_leg_leaves_a_completion_record_behind drives 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_mint had 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:

  1. job_lifecycle::accept — sealed the realized mint and refused no-overlap ("never accepted into an unpayable bind"), so such a job never reached authorize_pay.
  2. 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_mint is 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_payment would say Direct, 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 Ok branch). 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_plan pins 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:

melt at source mint at target action
Unpaid nothing left; melt (the mint's own answer, not a guess)
Pending money in flight; refuse, never melt again
Paid not issued the strand — issue at the target, and say so LOUDLY
Paid issued complete without touching either mint

The 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_sagas filters 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, and require_both_mints_recovered turns 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:

mutation tooth that fails
overlap no longer plans Direct the two decision teeth
target fence dropped the two fence teeth
input fee dropped from the cap total the cap-arithmetic tooth
persisted pairing no longer wins a_second_pairing_for_one_attempt_is_refused_rather_than_melted
strand completed but not reported kill_between_melt_and_mint_pays_once_on_restart_and_reports_the_strand
recovery guard checks only the source a_sweep_that_covered_one_mint_refuses_to_call_the_hop_swept
cap charged delivered_sats, not planned_cost the_cap_is_charged_the_hop_cost_and_the_direct_amount

cap_charge exists 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

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 -- --checkrc=1, 733 diffs
  • money-combo clippy --all-targets -- -D warnings57 errors across 20 files

CI runs neither fmt nor clippy.github/workflows/ci.yml is build + test only, on dtolnay/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:

file fmt hunks (base → branch) clippy hits (base → branch)
crossmint.rs new file → 0 new file → 0
crossmint_hop.rs new file → 0 new file → 0
authorize_pay.rs 28 → 26 13 → 4
job_lifecycle.rs 53 → 53 5 → 5
payment_wallet.rs 29 → 29 0 → 0
wallet_ops.rs 13 → 13 1 → 1
buyer/mod.rs 135 → 135 3 → 3
buyer/lifecycle.rs 33 → 33 1 → 1

Both new modules are fmt-clean and clippy-clean. No touched file gains a single fmt hunk or clippy hit; authorize_pay.rs loses 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

cargo test --workspace                                                        rc=0   586 lib tests
cargo test -p mobee-core --no-default-features \
           --features gateway,git-delivery,wallet                             rc=0   586 lib tests
cargo test -p mobee-core --features acp                                       rc=0   170 tests
cargo build -p mobee --release                                                rc=0
rustfmt --edition 2024 --check   (per file, table above)                      rc=0 on both new modules
cargo clippy  (money combo, --all-targets -D warnings)                        0 hits on either new module

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, because wallet/gateway are 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 #198
touched none of the files this slice touches — verified against a worktree of the new base rather
than assumed from the non-overlap.

worker-mobee-crossmint and others added 9 commits July 27, 2026 15:26
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.
@orveth
orveth merged commit 2ab64b7 into dev Jul 27, 2026
2 checks passed
@orveth
orveth deleted the crossmint/hop branch July 27, 2026 22:57
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