The buyer's pre-award mint gate is documented as the #126 mandatory guard — "never auto-award what we cannot pay" — but under the configuration that actually ships, its mint leg reduces to a URL syntax check, and no test exercises it in that configuration.
Surfaced by the closed-issue audit while auditing #248. Every citation below was re-read in-tree at df223afd (v0.3.0 = origin/main tip) before filing.
The gate
crates/maxplayer-core/src/buyer/lifecycle.rs:161 — claim_is_payable ends with:
let listed: Vec<String> = request.mints.iter().map(|mint| mint.to_string()).collect();
plan_payment(filters.buyer_mint, &listed, filters.allow_real_mints).is_ok()
The field it reads carries this doc (lifecycle.rs:40-42):
The buyer's own paying mint (config default). A claim whose creq lists no mint the buyer can settle at is skipped — the #126 mandatory guard: never auto-award what we cannot pay.
And the call site is prefaced (lifecycle.rs:182-184):
This is the SAME planning the pay path performs, so a claim that passes here is one the buyer can actually pay, by whichever of those two routes.
Why it is vacuous under the shipped default
allow_real_mints ships true — home.rs:844 default_allow_real_mints() -> bool, wired at home.rs:762-763, and home.rs:841-843 explains why: without it the shipped default config would refuse its own default mint.
With that value, home::mint_allowed (home.rs:856-864) is:
if allow_real_mints {
mint_url.strip_prefix("https://").is_some_and(|host| !host.is_empty())
}
So inside plan_payment (crossmint.rs:94): the buyer-mint fence passes for any https:// URL; if the seller's list contains the buyer's mint it returns Direct; otherwise it hops to the first accepted mint the fence admits — which, with the fence reduced to "is an https URL", is simply the first one listed.
plan_payment(...).is_ok() is therefore true for essentially any well-formed https mint list. The gate cannot skip a claim on mint grounds under the shipped configuration.
It never consults a balance. AwardFilters (lifecycle.rs:33-49) has five fields — offer_amount_sats, max_sats, buyer_mint, allow_real_mints, requested_agent. There is no balance, and no accessor reaches one. "Can pay" is decided without reference to holdings.
The test is green for the wrong reason
select_skips_claim_with_no_payable_mint (lifecycle.rs:1247) is the named regression test. It passes a seller list of ["https://foreign.testnut.example"] and asserts no claim is selected.
It is green because of the fence branch, not the mint-overlap branch: the shared fixture filters() at lifecycle.rs:1149-1157 sets allow_real_mints: false (:1154).
Flip that one field to the shipped value and the same case inverts — https://foreign.testnut.example is a well-formed https URL, so the fence admits it, plan_payment hops to it, and the claim becomes selectable.
allow_real_mints appears exactly three times in the whole file: the struct field (:44), the call (:185), and that single fixture line (:1154). So no test in this file exercises the gate under the configuration that ships. The branch that matters in production has no coverage at all.
Why this is worth more than a coverage note
post_job auto-awards, so the award is the payment decision — it is the moment the spend is committed, and every acceptance criterion after it is advisory. A pre-award gate is the last point where a claim can be declined for free. This one is named, documented, and asserted-upon as if it were that check, while under the shipped default it decides only that a string starts with https://.
Pairs with #248, and they are two halves of one hole. #248 is the seller side: classify_offer (run.rs:1848) and the complete SkipReason enum (run.rs:285-311) contain no mint predicate, so the seller cannot refuse an unpayable route either. Under the shipped default, neither side performs a real mint/balance check before the seller does the work. The failure lands after delivery: the buyer refuses at pay time and the seller has already spent the compute.
No sats are lost — the pay path re-derives and fails closed. The cost is a committed award, wasted seller compute, and a guard whose name ends the search for anyone asking whether that check exists.
Suggested shape
- Add a case to
lifecycle.rs that runs claim_is_payable with allow_real_mints: true — the shipped value — and asserts the intended skip. That single test is what would have caught this.
- Decide what the gate should actually assert under real mints. If "can pay" is meant to include holdings,
AwardFilters needs a balance input and the check needs to consult it; if it is only meant to assert route-existence, the doc comments at :40-42 and :182-184 should say so, because they currently promise more than the code delivers.
- Whichever is chosen, the fixture should not be the only thing standing between a money-path guard and a green suite.
Verification bound: this is a code and test read at df223afd, plus the inverted-case reasoning above. I did not run the suite with the flipped fixture — that run is the natural red-prove and should accompany the fix.
The buyer's pre-award mint gate is documented as the #126 mandatory guard — "never auto-award what we cannot pay" — but under the configuration that actually ships, its mint leg reduces to a URL syntax check, and no test exercises it in that configuration.
Surfaced by the closed-issue audit while auditing #248. Every citation below was re-read in-tree at
df223afd(v0.3.0 =origin/maintip) before filing.The gate
crates/maxplayer-core/src/buyer/lifecycle.rs:161—claim_is_payableends with:The field it reads carries this doc (
lifecycle.rs:40-42):And the call site is prefaced (
lifecycle.rs:182-184):Why it is vacuous under the shipped default
allow_real_mintsshipstrue—home.rs:844default_allow_real_mints() -> bool, wired athome.rs:762-763, andhome.rs:841-843explains why: without it the shipped default config would refuse its own default mint.With that value,
home::mint_allowed(home.rs:856-864) is:So inside
plan_payment(crossmint.rs:94): the buyer-mint fence passes for anyhttps://URL; if the seller's list contains the buyer's mint it returnsDirect; otherwise it hops to the first accepted mint the fence admits — which, with the fence reduced to "is an https URL", is simply the first one listed.plan_payment(...).is_ok()is therefore true for essentially any well-formed https mint list. The gate cannot skip a claim on mint grounds under the shipped configuration.It never consults a balance.
AwardFilters(lifecycle.rs:33-49) has five fields —offer_amount_sats,max_sats,buyer_mint,allow_real_mints,requested_agent. There is no balance, and no accessor reaches one. "Can pay" is decided without reference to holdings.The test is green for the wrong reason
select_skips_claim_with_no_payable_mint(lifecycle.rs:1247) is the named regression test. It passes a seller list of["https://foreign.testnut.example"]and asserts no claim is selected.It is green because of the fence branch, not the mint-overlap branch: the shared fixture
filters()atlifecycle.rs:1149-1157setsallow_real_mints: false(:1154).Flip that one field to the shipped value and the same case inverts —
https://foreign.testnut.exampleis a well-formed https URL, so the fence admits it,plan_paymenthops to it, and the claim becomes selectable.allow_real_mintsappears exactly three times in the whole file: the struct field (:44), the call (:185), and that single fixture line (:1154). So no test in this file exercises the gate under the configuration that ships. The branch that matters in production has no coverage at all.Why this is worth more than a coverage note
post_jobauto-awards, so the award is the payment decision — it is the moment the spend is committed, and every acceptance criterion after it is advisory. A pre-award gate is the last point where a claim can be declined for free. This one is named, documented, and asserted-upon as if it were that check, while under the shipped default it decides only that a string starts withhttps://.Pairs with #248, and they are two halves of one hole. #248 is the seller side:
classify_offer(run.rs:1848) and the completeSkipReasonenum (run.rs:285-311) contain no mint predicate, so the seller cannot refuse an unpayable route either. Under the shipped default, neither side performs a real mint/balance check before the seller does the work. The failure lands after delivery: the buyer refuses at pay time and the seller has already spent the compute.No sats are lost — the pay path re-derives and fails closed. The cost is a committed award, wasted seller compute, and a guard whose name ends the search for anyone asking whether that check exists.
Suggested shape
lifecycle.rsthat runsclaim_is_payablewithallow_real_mints: true— the shipped value — and asserts the intended skip. That single test is what would have caught this.AwardFiltersneeds a balance input and the check needs to consult it; if it is only meant to assert route-existence, the doc comments at:40-42and:182-184should say so, because they currently promise more than the code delivers.Verification bound: this is a code and test read at
df223afd, plus the inverted-case reasoning above. I did not run the suite with the flipped fixture — that run is the natural red-prove and should accompany the fix.