Cross-mint smoke: a script that runs, and states what it cannot prove - #204
Merged
Conversation
The smoke script's command surface had never been executed. Against the
real binary every invocation returns rc=1: `budget`, `config` and `job`
are not commands at all; `--home` is a post-subcommand flag on `wallet`
alone; `wallet balances` is `balance` and has no `--json`; `buyer restart`
does not exist; and the binary is not at ./target (CARGO_TARGET_DIR is
redirected). A script in that state does not fail at step 0 — it fails
partway, and on the real-money path that means after the melt.
The script now targets the surface the binary has, and is staged so the
external unknowns are measured before our own code is involved:
--self-test parsers against fixtures. The `status=needs_payment` branch
only ever appears on a non-test mint, so fixtures are the
only coverage available before real sats.
--dry-run testnut: gates, home discipline, env config, balance math,
and that a melt which cannot route returns non-zero — the
property every `|| die` guarding a real melt depends on.
--stage-a the hop by hand in three wallet commands. Answers what no
hermetic test can: does LN route source -> target, and what
does it cost. Ends in a fee-fit verdict against the cap.
--stage-b refuses, naming what it still needs, rather than pretending.
Safety is structural rather than procedural. Config is supplied entirely
by environment, and env overrides are never persisted: a home booted with
MOBEE_ALLOW_REAL_MINTS=true still reads `allow_real_mints = false` on
disk, so the real-mint fence is process-scoped and cannot be left open.
Bootstrap does write into MOBEE_HOME, and an unset MOBEE_HOME resolves to
~/.mobee, so every invocation is funnelled through one wrapper that
refuses any directory this run did not itself create and mark.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage A created a fresh empty home and then asserted a source balance, so the first real run would have died at "buyer holds 0 sats" — the funding step was missing entirely. Paying the invoice happens out of band and by a human, so funding is two invocations (`--fund` prints the bolt11, `--fund-complete` issues once it is paid) sharing one throwaway wallet. That means a home has to be reusable across invocations: `ensure_home` reuses one carrying our marker and still refuses any directory this tooling did not create. The authorization token now names the TOTAL FUNDED EXPOSURE rather than each step's amount — one human decision about how many real sats are at risk, which is the shape the spend was approved in. A mint that auto-funds prints `minted_sats=...` where a real mint prints a bolt11. Both are non-empty, so an emptiness check would feed that line straight into `wallet melt`; `is_bolt11` checks the shape instead, and the self-test pins both directions. Verified: self-test 11/11, dry-run rc=0, and --fund / --fund-complete / --stage-a / --stage-b each rc=1 without matching auth, creating no home before the gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Live Stage A hit this twice. `parse_quote_id` matched [A-Za-z0-9-], which has no underscore, so a real quote id truncated at the first one: lcLc0JwHHCIG_UIwQ8cvPB-LXgiVfSxE6ZO6Tq1b became lcLc0JwHHCIG. The result is still shaped like an id — it is a PREFIX of the real one — so nothing failed at the parse. It failed two steps later, at mint-complete, with "no stored amount". Both times the full id recovered it; no sats lost. Match to whitespace instead. Quote ids are opaque: testnut issues UUIDs, the real mints issue base64url. Never enumerate the characters of an identifier you do not define. The self-test is why this shipped. Its fixture was a UUID — copied from testnut, the mint the dry run CAN reach — so a fixture standing in for the unreachable real format inherited the format of the reachable one, and proved the parser against a character set that could not fail. Both shapes are pinned now, the base64url one verbatim from the live run, and the old class is confirmed red against it (12-char prefix of a 40-char id). `--amount` is now passed on both mint-complete calls: a quote raised by a different process leaves no stored amount in this home, which is the error the live recovery actually hit. The strand message prints the exact recovery command with the full id and the env fence, and says to copy the id from that line rather than from earlier output. Verified: self-test 13/13, dry-run rc=0, refusal gates rc=1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit explained the live failure with a mechanism I made up: that a quote raised by a different process leaves no stored amount in this home. The run data refutes it — leg-1 recovery used the full id with NO --amount and succeeded, so the quote was stored all along under its full id. The source agrees. `complete_mint_by_id` only reaches "has no stored amount; pass --amount" when the quote lookup returns None. The message means THE ID WAS NOT FOUND; the code then guesses you need --amount, having no way to know the id is a truncated prefix. An error's suggested remedy is a hypothesis, not a diagnosis — and I took the remedy as the cause, which is inference wearing the clothes of a record. The character class is the whole cure. --amount stays as a cross-check, which is what it actually is: when the quote IS found, mint-complete refuses a completion whose amount differs from the stored one, so passing it pins that we are completing the quote we raised. Comments at both call sites now say that rather than claiming to fix anything. Verified: self-test 13/13, dry-run rc=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
What
The cross-mint smoke script, rewritten against the CLI the binary actually has, and staged.
Why
The script shipped in #196 had never been run. Against the real binary, every invocation in it returns
rc=1:mobee budget status --jsonbudgetcommandmobee config set …configcommandmobee job post/settle/showjobcommand — the trade path is daemon RPC / MCP (post_job,award,collect,get_job)mobee --home X …--homeis a post-subcommand flag, onwalletonly; everything else readsMOBEE_HOMEmobee wallet balances --jsonbalance, and there is no--jsonmobee buyer restartbuyer/buyer serve/buyer statusMOBEE=./target/release/mobeeCARGO_TARGET_DIRis redirected; nothing is at./targetconfig set default_mintaccepted_mintsentryA script in that state does not fail at step 0. It fails partway — and on the real-money path, partway means after the melt, with sats gone from the source and nothing issued at the target. That is the worst failure geometry the slice has.
Staging
The external unknowns are measured before our own code is involved:
--self-test— parsers against fixtures. Thestatus=needs_paymentbranch appears only on a non-test mint, so fixtures are the only coverage available before real sats. Runs on every mode, including both real ones.--dry-run— testnut. Proves gates, home discipline, env config, funding, balance parsing, bolt11 issue, and that a melt which cannot route returns non-zero — the property every|| dieguarding a real melt depends on.--stage-a— the hop by hand in three wallet commands (wallet mintat target →wallet meltat source →wallet mint-completeat target). No daemon, no relay, no seller, no hop code. Answers the one question no hermetic test can: does Lightning route source → target, and what does it cost? Ends in a fee-fit verdict against the exposure cap.--stage-b— refuses, naming exactly what it still needs, rather than pretending.Stage A before Stage B is the point: A's unknowns are external (two mints, LN routing, fees), B's are ours. Run together, a failure tells you neither. Run in order, an A-failure means the mint pair and a B-failure means our code.
Safety is structural, not procedural
MOBEE_ALLOW_REAL_MINTS=truestill readsallow_real_mints = falsein itsconfig.toml, and re-reading without the env reverts. The real-mint flip is process-scoped and evaporates on exit. The previous design restored the fence in anEXITtrap with|| trueon both restores — a restore that can fail silently.~/.mobeefallback is unreachable. Bootstrap does write intoMOBEE_HOME, and an unsetMOBEE_HOMEresolves to~/.mobee, which on this box holds a real key and wallet. Every invocation is funnelled through one wrapper that refuses any directory this run did not itself create and mark, refuses an empty home, and refuses anything resolving into~/.mobee. Both refusals are asserted in the dry run.MOBEE_TOTAL_BUDGET_SATSbounds the spend in the budget gate, which charges the hop's full cost (delivery + fee reserve + input fee) — independent of anything the script asserts afterwards.<amount>:<source>:<target>. The expected value is never echoed on refusal.Evidence
Built and run on the merged tree.
dev@2ab64b7is #196 squashed with nothing else on top, and its tree hash is identical to the branch it was built from (d3f58c4f…), so the binary under test is the merged-tree binary.Refusal paths, each verified:
What this still does not prove
A successful melt,
mint-completeafter real payment, and cross-mint LN routing. Those are structurally impossible against a test mint — measured, not assumed: on testnut a melt of a freshly issued invoice returnswallet error: Payment failed(rc=2). They are exactly what Stage A exists to measure, and it is gudnuf-gated.Follow-up in this PR: funding, and shape-checking what gets melted
Stage A created a fresh empty home and then asserted a source balance — so the first real run would have died at
buyer holds 0 sats. The funding step was missing entirely.Paying the invoice happens out of band and by a human, so funding is two invocations sharing one throwaway wallet:
--fundprints the bolt11,--fund-complete <quote_id>issues once it is paid. That makes a home reusable across invocations, somake_homebecameensure_home— it reuses one carrying our marker, and still refuses any directory this tooling did not create.The authorization token now names the total funded exposure rather than each step's amount: one human decision about how many real sats are at risk, which is the shape the spend was approved in.
A mint that auto-funds prints
minted_sats=…where a real mint prints a bolt11. Both are non-empty, so an emptiness check would feed that line straight intowallet melt.is_bolt11checks the shape instead, and the self-test pins both directions.Re-verified after the change rather than carrying the earlier green forward:
Two operational notes for whoever runs it:
RUN_DIRmust be set to a persistent path — it defaults to/tmp, and real ecash lives in that home, so a/tmpclean burns it. And the mint pair pre-flighted read-only: both HTTP 200, NUT-4 and NUT-5 present,"disabled":false, no advertised min/max.Stage A ran for real, and found a bug in this script
Executed by the orchestrator under gudnuf's authorization, funded from turtle-ln. It routed: melt at btcforplebs → mint at cubabitcoin, 5 sats delivered, fee 2. Final balances 8 source + 5 target = 13/15. Fee-fit: 21 + ~2 ≈ 23 against a 50 cap, so Stage B is viable on this pair.
The strand path fired for real — twice — and recovered both times with zero sats lost. The recovery text was load-bearing and it held.
The bug: quote ids truncated at the underscore
parse_quote_idmatched[A-Za-z0-9-]. No underscore. SolcLc0JwHHCIG_UIwQ8cvPB-LXgiVfSxE6ZO6Tq1bbecamelcLc0JwHHCIG.The character class is the whole cure. It now matches to whitespace. Quote ids are opaque — testnut issues UUIDs, the real mints issue base64url — and enumerating the characters of an identifier you don't define is the mistake.
Two things made it sail two hops past the parse:
quote <id> has no stored amount; pass --amount to complete it. Readingcomplete_mint_by_id, that branch is reachable only when the quote lookup returnsNone— it means the id was not found, and the code then guesses you need--amount, having no way to know the id is truncated. Confirmed live: leg-1 recovery succeeded with the full id and no--amount.--amountis now passed at both call sites as a cross-check, not a fix — when the quote is found, mint-complete refuses a completion whose amount differs from the stored one, so it pins that we're completing the quote we raised.Why the self-test didn't catch it
The fixture was a UUID, copied from testnut — the mint the dry run can reach. So a fixture standing in for a format I had explicitly documented as unreachable inherited the format of the reachable world, and proved the parser against a character set that could not fail.
Both shapes are pinned now, the base64url one verbatim from the live run, and the old class is confirmed red against it (12-char prefix of a 40-char id).
🤖 Generated with Claude Code