Skip to content

money path: the buyer's pre-award mint gate is vacuous under the shipped allow_real_mints default, and no test exercises it there #676

Description

@orveth

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:161claim_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 truehome.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

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions