diff --git a/scripts/crossmint-smoke.sh b/scripts/crossmint-smoke.sh index cdbe2ce3..a39b0d57 100755 --- a/scripts/crossmint-smoke.sh +++ b/scripts/crossmint-smoke.sh @@ -3,146 +3,477 @@ # Live cross-mint hop smoke — a small REAL-SATS trade between two different mints. # # ┌───────────────────────────────────────────────────────────────────────────────────────────┐ -# │ THIS SCRIPT SPENDS REAL MONEY AND REQUIRES A MONEY-GATE CONFIG CHANGE. │ -# │ It refuses to run without an explicit authorization token naming the exact amount and │ -# │ both mints, so it cannot be started by an agent, a retry, or a stray shell history entry. │ +# │ THE REAL STAGES SPEND REAL MONEY. They refuse to run without an authorization token naming │ +# │ the exact amount and both mints, so they cannot be started by an agent, a retry, or a stray │ +# │ shell history entry. │ # └───────────────────────────────────────────────────────────────────────────────────────────┘ # -# Why this exists at all: test ecash structurally CANNOT hop. Fake mints cannot pay each other's -# invoices over real Lightning, so the hermetic teeth prove control flow, the journal, budget -# accounting and recovery — but not real LN routing between two mints. This is the only thing that -# proves that leg, and it is the reason the slice ships with that caveat stated rather than hidden. +# Why this exists: test ecash structurally CANNOT hop. A test mint auto-settles its own quotes and +# cannot pay another mint's invoice over real Lightning — measured, not assumed: on testnut a melt +# of a freshly issued invoice returns `wallet error: Payment failed` (rc=2). So the hermetic teeth +# prove control flow, the journal, budget accounting and recovery; only real sats prove LN routing +# between two mints. That is the caveat the slice ships with, stated rather than hidden. # -# Usage (after authorization): -# CROSSMINT_SMOKE_AUTH="::" ./scripts/crossmint-smoke.sh +# ── Stages ────────────────────────────────────────────────────────────────────────────────────── +# --self-test parsers only. No network, no home, no money. +# --dry-run testnut. Proves the gates, home discipline, env config, balance math, +# and that a failed melt halts the script. Cannot prove a SUCCESSFUL +# melt (see above). +# --fund REAL SATS. Raises a mint quote at the SOURCE and prints a bolt11 for a +# human to pay. Nothing is at risk until that invoice is paid. +# --fund-complete issues the ecash once the invoice is paid. +# --stage-a REAL SATS. Routing + fee probe: the hop performed by hand in three +# wallet commands — no daemon, no relay, no seller, no hop code. Answers +# the only question hermetic tests cannot: does LN route source -> +# target, and what does it cost? Exits 3 if the fee will not fit. +# --stage-b refuses; the two-sided trade rig is its own slice. # -# The token must match the three values below exactly. Change the values, and the token stops -# matching — which is the point: authorization is for one specific spend, not for the script. +# Stage A before Stage B is deliberate. The unknowns in A are EXTERNAL (two mints, LN routing, +# fees); the unknowns in B 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. +# +# Usage — fund and probe are separate invocations against the SAME throwaway wallet, so RUN_DIR must +# be the same for both (it defaults to a stable path): +# +# export SOURCE_MINT=... TARGET_MINT=... +# export CROSSMINT_SMOKE_AUTH="::" +# ./scripts/crossmint-smoke.sh --fund +# # pay the printed invoice, then: +# ./scripts/crossmint-smoke.sh --fund-complete +# ./scripts/crossmint-smoke.sh --stage-a +# +# The token names the TOTAL FUNDED EXPOSURE and must match exactly. Change the values and the token +# stops matching — authorization is for one specific spend, not for the script. set -euo pipefail # ── The authorized spend ──────────────────────────────────────────────────────────────────────── -# Fill these in from the authorization, then hand the operator the matching CROSSMINT_SMOKE_AUTH. -AMOUNT_SATS="${AMOUNT_SATS:-}" # what the seller receives (the buyer pays this plus hop fees) -SOURCE_MINT="${SOURCE_MINT:-}" # the mint the buyer holds sats at -TARGET_MINT="${TARGET_MINT:-}" # the mint the seller accepts — MUST differ from SOURCE_MINT -BUYER_HOME="${BUYER_HOME:-}" # buyer home whose config gets allow_real_mints -SELLER_HOME="${SELLER_HOME:-}" # seller home advertising TARGET_MINT +DELIVERY_SATS="${DELIVERY_SATS:-21}" # what the seller receives; the buyer pays this plus hop fees +FUND_SATS="${FUND_SATS:-50}" # total real sats put at risk; the auth token names THIS number +PROBE_SATS="${PROBE_SATS:-5}" # Stage A probe. Small: its job is to measure, not to deliver +EXPOSURE_CAP_SATS="${EXPOSURE_CAP_SATS:-50}" +SOURCE_MINT="${SOURCE_MINT:-}" # the mint the buyer holds sats at +TARGET_MINT="${TARGET_MINT:-}" # the mint the seller accepts — MUST differ from SOURCE_MINT -MOBEE="${MOBEE:-./target/release/mobee}" +MOBEE="${MOBEE:-/srv/forge/workspaces/.crossmint-target/release/mobee}" +RUN_DIR="${RUN_DIR:-/tmp/crossmint-smoke-run}" + +# The marker that makes a directory a legitimate target. Bootstrap WRITES into MOBEE_HOME, and an +# unset MOBEE_HOME falls back to ~/.mobee — a real home with a real key and wallet. So no mobee +# command in this script is reachable except through mobee_at(), which refuses any directory this +# run did not itself create and mark. +MARKER=".crossmint-smoke-throwaway" die() { echo "crossmint-smoke: $*" >&2; exit 1; } +say() { echo "$*"; } +rule() { echo "--- $* ---"; } + +# ── Parsers ───────────────────────────────────────────────────────────────────────────────────── +# Factored out and self-tested because the live formats they consume are the ones the dry run +# CANNOT produce: testnut auto-settles, so `status=needs_payment` never appears against a test +# mint. Proving them against fixtures is the only honest coverage available before real sats. + +# `mint= role= balance_sats=` lines, then `total_sats=`. +parse_balance_for_mint() { # + printf '%s\n' "$1" | command grep -F "mint=$2 " | command sed -n 's/.*balance_sats=\([0-9]*\).*/\1/p' | command head -1 +} +# `status=needs_payment amount_sats= mint= quote_id= (...)` +# +# Match everything up to whitespace rather than an allow-list of characters. A previous version +# used [A-Za-z0-9-], which silently TRUNCATED real quote ids at the first underscore +# (lcLc0JwHHCIG_UIwQ8... -> lcLc0JwHHCIG) and cost two live mint-complete failures. Quote ids are +# opaque: testnut issues UUIDs, the real mints issue base64url with _ and -. Never enumerate the +# characters of an identifier you do not define. +# +# The failure surfaced as `quote has no stored amount; pass --amount to complete it`, which +# is why it took two hops to place. That message means the id was NOT FOUND in this home — the +# lookup missed, and the code guesses you need --amount. It has no way to know your id is a +# truncated prefix. Confirmed both ways: `complete_mint_by_id` only reaches that error when the +# quote lookup returns None, and the live leg-1 recovery succeeded with the FULL id and NO +# --amount. An error's suggested remedy is a hypothesis, not a diagnosis. +parse_quote_id() { printf '%s\n' "$1" | command sed -n 's/.*quote_id=\([^ ]*\).*/\1/p' | command head -1; } +# `paid_sats= fee_sats= balance_sats= mint=` +parse_field() { printf '%s\n' "$1" | command sed -n "s/.*$2=\([0-9]*\).*/\1/p" | command head -1; } + +# A mint that auto-funds (i.e. a TEST mint) prints a `minted_sats=...` line where a real mint +# prints a bolt11. Both are non-empty, so a bare emptiness check would happily feed that line into +# `wallet melt`. Check the shape, so the failure names the real cause. +is_bolt11() { case "${1:-}" in ln[bt]c*) return 0 ;; *) return 1 ;; esac; } + +self_test() { + local fail=0 got + check() { #