Skip to content

Cross-mint smoke: a script that runs, and states what it cannot prove - #204

Open
orveth wants to merge 2 commits into
devfrom
crossmint/smoke-rewrite
Open

Cross-mint smoke: a script that runs, and states what it cannot prove#204
orveth wants to merge 2 commits into
devfrom
crossmint/smoke-rewrite

Conversation

@orveth

@orveth orveth commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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:

script invoked reality
mobee budget status --json no budget command
mobee config set … no config command
mobee job post/settle/show no job command — the trade path is daemon RPC / MCP (post_job, award, collect, get_job)
mobee --home X … --home is a post-subcommand flag, on wallet only; everything else reads MOBEE_HOME
mobee wallet balances --json it is balance, and there is no --json
mobee buyer restart only buyer / buyer serve / buyer status
MOBEE=./target/release/mobee CARGO_TARGET_DIR is redirected; nothing is at ./target
config set default_mint no such field — the buyer's mint is derived as the first accepted_mints entry

A 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. The status=needs_payment branch 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 || die guarding a real melt depends on.
  • --stage-a — the hop by hand in three wallet commands (wallet mint at target → wallet melt at source → wallet mint-complete at 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

  • The fence cannot be left open. Config comes entirely from the environment, and env overrides are never persisted — measured: a home booted with MOBEE_ALLOW_REAL_MINTS=true still reads allow_real_mints = false in its config.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 an EXIT trap with || true on both restores — a restore that can fail silently.
  • The ~/.mobee fallback is unreachable. Bootstrap does write into MOBEE_HOME, and an unset MOBEE_HOME resolves 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.
  • The exposure cap is a runtime gate. MOBEE_TOTAL_BUDGET_SATS bounds 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.
  • Authorization names the spend. The real stages require a token matching <amount>:<source>:<target>. The expected value is never echoed on refusal.

Evidence

Built and run on the merged tree. dev@2ab64b7 is #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.

cargo build -p mobee --release   rc=0
--self-test                      rc=0   (11/11 parser checks)
--dry-run                        rc=0   (DRY RUN PASSED)

Refusal paths, each verified:

--stage-a, no auth                 rc=1
--stage-a, wrong token             rc=1   expected token NOT echoed
--stage-a, identical mints         rc=1   "that is a direct payment, not a hop"
--stage-b                          rc=1   refuses, names what it needs

What this still does not prove

A successful melt, mint-complete after 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 returns wallet 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: --fund prints the bolt11, --fund-complete <quote_id> issues once it is paid. That makes a home reusable across invocations, so make_home became ensure_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 into wallet melt. is_bolt11 checks the shape instead, and the self-test pins both directions.

Re-verified after the change rather than carrying the earlier green forward:

--self-test                        rc=0   11/11
--dry-run                          rc=0   DRY RUN PASSED
--fund / --fund-complete /
--stage-a / --stage-b              rc=1   each, without matching auth
                                          and creating no home before the gate

Two operational notes for whoever runs it: RUN_DIR must be set to a persistent path — it defaults to /tmp, and real ecash lives in that home, so a /tmp clean 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.

🤖 Generated with Claude Code

worker:mobee-crossmint and others added 2 commits July 27, 2026 16:06
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>
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