Files
archy/scripts/test-ecash-routes.sh
T
archipelagoandClaude Opus 5 e30516316b
Demo images / Build & push demo images (push) Failing after 2m11s
fix(ecash): give every node a backup phrase, and prove restore works
Running the route suite on this box surfaced that the backup was
unreachable here: `identity/master_seed.enc` is written during
onboarding, and any node onboarded before that step existed simply does
not have one. Reveal bailed with "this node has no encrypted seed
backup", and restore followed it down.

But the choice on such a node was never "derived phrase or independent
phrase" — it was "independent phrase or no backup at all", and a wallet
whose coins can be restored from words the operator holds beats one
whose coins die with a single file. So it now generates one, recorded as
`independent`, and every surface that shows it says plainly that
restoring the node will not bring the ecash back — only these words
will. `derivable_from_node_seed` lets the card say which kind you are
about to get *before* you write anything down.

Also: a mint that never implemented NUT-09 answered restore with a bare
404, which surfaced as "mint returned 404 with no further detail" —
true, and useless to someone trying to get their coins back. It now
names the limitation.

The route suite was reading `result.amount_sats` from mint-claim, which
answers with `minted_sats`. A working claim had been reporting as a
failure; that was one of the two reds carried over from yesterday.

The real gap, though, was that "recovered 0 sats" passes on a wallet
with nothing to find — exactly the shape of a backup that looks fine
until the day you need it. test-ecash-restore.sh does the test that
settles it: mint, **delete the wallet file**, restore, check the coins
came back. On this box: 87 sats before the wipe, 0 after, 61 recovered
from the phrase alone — every coin minted since the phrase existed, and
none of the 26 sats minted before it, which used random secrets and
never could come back. Testnet only, and it refuses to run otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:27:01 -04:00

264 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Headless exercise of every Cashu route the node exposes.
#
# Runs against the node's RPC exactly as the UI does, so it covers the real
# path: dispatcher -> handler -> wallet -> mint HTTP. Intended to be run on
# the node itself.
#
# ./scripts/test-ecash-routes.sh # uses testnet (safe)
# ECASH_TEST_NETWORK=mainnet ./scripts/... # REAL COINS, opt-in only
#
# Requires: the node's web password.
# ARCHY_PASSWORD='...' ./scripts/test-ecash-routes.sh
#
# Exit code is the number of failed checks, so CI can gate on it.
set -uo pipefail
HOST="${ARCHY_HOST:-127.0.0.1}"
SCHEME="${ARCHY_SCHEME:-http}"
BASE="$SCHEME://$HOST"
NETWORK="${ECASH_TEST_NETWORK:-testnet}"
JAR="$(mktemp -t ecash-routes-XXXXXX.jar)"
trap 'rm -f "$JAR"' EXIT
PASS=0
FAIL=0
ORIGINAL_NETWORK=""
log() { printf '%s\n' "$*"; }
ok() { PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$*"; }
bad() { FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n' "$*"; }
# rpc <method> [json-params] -> prints the full JSON-RPC response
rpc() {
local method="$1" params="${2:-}" body csrf
csrf="$(awk '/csrf_token/{print $NF}' "$JAR" 2>/dev/null | tail -1)"
if [ -n "$params" ]; then
body="{\"method\":\"$method\",\"params\":$params}"
else
body="{\"method\":\"$method\"}"
fi
curl -s --max-time 90 -b "$JAR" -H 'Content-Type: application/json' \
${csrf:+-H "X-CSRF-Token: $csrf"} \
-X POST "$BASE/rpc/v1" -d "$body"
}
jqf() { python3 -c "
import json,sys
try: d=json.load(sys.stdin)
except Exception: print(''); sys.exit()
cur=d
for k in sys.argv[1].split('.'):
if isinstance(cur,dict): cur=cur.get(k)
else: cur=None
print('' if cur is None else cur)" "$1"; }
err_of() { jqf error.message; }
login() {
local pw="${ARCHY_PASSWORD:-}"
if [ -z "$pw" ]; then
log "ARCHY_PASSWORD is not set — cannot authenticate."; exit 2
fi
curl -s -c "$JAR" --max-time 30 -H 'Content-Type: application/json' \
-X POST "$BASE/rpc/v1" \
-d "{\"method\":\"auth.login\",\"params\":{\"password\":\"$pw\"}}" >/dev/null
if ! grep -q session "$JAR" 2>/dev/null; then
log "Login failed — check ARCHY_PASSWORD."; exit 2
fi
}
log "== ecash route check =="
log "node: $BASE"
log "network: $NETWORK"
login
# ── network routes ────────────────────────────────────────────────────────
res="$(rpc wallet.ecash-network)"
ORIGINAL_NETWORK="$(printf '%s' "$res" | jqf result.network)"
[ -n "$ORIGINAL_NETWORK" ] && ok "ecash-network reports '$ORIGINAL_NETWORK'" \
|| bad "ecash-network: $(printf '%s' "$res" | err_of)"
# Always restore whatever the node was on, even on failure.
restore_network() {
if [ -n "$ORIGINAL_NETWORK" ]; then
rpc wallet.ecash-set-network "{\"network\":\"$ORIGINAL_NETWORK\"}" >/dev/null
log "restored network to '$ORIGINAL_NETWORK'"
fi
}
trap 'restore_network; rm -f "$JAR"' EXIT
res="$(rpc wallet.ecash-set-network "{\"network\":\"$NETWORK\"}")"
[ "$(printf '%s' "$res" | jqf result.network)" = "$NETWORK" ] \
&& ok "switched to $NETWORK" || bad "set-network: $(printf '%s' "$res" | err_of)"
res="$(rpc wallet.ecash-set-network '{"network":"bogus"}')"
[ -n "$(printf '%s' "$res" | err_of)" ] \
&& ok "unknown network rejected" || bad "unknown network was accepted"
MINT="$(rpc wallet.ecash-network | jqf result.mint_url)"
log "mint: $MINT"
# ── read routes ───────────────────────────────────────────────────────────
res="$(rpc wallet.ecash-balance)"
START_BAL="$(printf '%s' "$res" | jqf result.cashu_sats)"
[ -n "$START_BAL" ] && ok "ecash-balance = ${START_BAL} sats" \
|| bad "ecash-balance: $(printf '%s' "$res" | err_of)"
res="$(rpc wallet.ecash-history)"
[ -z "$(printf '%s' "$res" | err_of)" ] && ok "ecash-history" \
|| bad "ecash-history: $(printf '%s' "$res" | err_of)"
# ── mint reachability (the route every other one depends on) ──────────────
if curl -s --max-time 20 "$MINT/v1/keysets" | grep -q keysets; then
ok "mint keysets reachable"
else
bad "mint $MINT unreachable — remaining checks will fail"
fi
# ── mint quote (invoice issuance) ─────────────────────────────────────────
res="$(rpc wallet.ecash-mint '{"amount_sats":16}')"
QUOTE="$(printf '%s' "$res" | jqf result.quote_id)"
[ -n "$QUOTE" ] && ok "ecash-mint issued quote ${QUOTE:0:12}…" \
|| bad "ecash-mint: $(printf '%s' "$res" | err_of)"
# The test mint settles its own invoices, but not instantly — poll the quote
# state at the mint before claiming, or the claim races the settlement and
# fails with "Quote not paid". On mainnet the invoice is real and nobody pays
# it here, so staying unpaid is the expected outcome, not a failure.
if [ -n "$QUOTE" ] && [ "$NETWORK" = testnet ]; then
for _ in $(seq 1 20); do
state="$(curl -s --max-time 15 "$MINT/v1/mint/quote/bolt11/$QUOTE" \
| python3 -c "import json,sys; print((json.load(sys.stdin) or {}).get('state',''))" 2>/dev/null)"
[ "$state" = PAID ] && break
sleep 3
done
[ "$state" = PAID ] && ok "test mint settled the quote" \
|| log " (quote still $state — claim will likely fail)"
fi
if [ -n "$QUOTE" ]; then
res="$(rpc wallet.ecash-mint-claim "{\"quote_id\":\"$QUOTE\",\"amount_sats\":16}")"
# The handler answers with `minted_sats`; reading `amount_sats` here made a
# working claim look like a failure (it was one of the two reds on 2026-08-17).
claimed="$(printf '%s' "$res" | jqf result.minted_sats)"
if [ -n "$claimed" ]; then
ok "ecash-mint-claim minted ${claimed} sats"
elif [ "$NETWORK" = mainnet ]; then
ok "ecash-mint-claim correctly unpaid on mainnet"
else
bad "ecash-mint-claim: $(printf '%s' "$res" | err_of)"
fi
fi
BAL="$(rpc wallet.ecash-balance | jqf result.cashu_sats)"
log "balance after mint: ${BAL:-?} sats"
# ── send + receive round trip (the route that broke) ──────────────────────
if [ "${BAL:-0}" -ge 4 ] 2>/dev/null; then
res="$(rpc wallet.ecash-send '{"amount_sats":4}')"
TOKEN="$(printf '%s' "$res" | jqf result.token)"
if [ -n "$TOKEN" ]; then
ok "ecash-send produced a token (${#TOKEN} chars, ${TOKEN:0:7}…)"
# cashuB (V4) is what we emit now; cashuA remains valid but is only the
# fallback, so seeing it here means V4 encoding silently failed.
case "$TOKEN" in
cashuB*) ok "token is cashuB (V4)" ;;
cashuA*) bad "token is cashuA — cashuB encoding fell back, check the journal" ;;
*) bad "token has an unknown prefix: ${TOKEN:0:8}" ;;
esac
res="$(rpc wallet.ecash-receive "{\"token\":\"$TOKEN\"}")"
got="$(printf '%s' "$res" | jqf result.received_sats)"
[ -n "$got" ] && ok "ecash-receive redeemed ${got} sats" \
|| bad "ecash-receive: $(printf '%s' "$res" | err_of)"
# Double-spend must be refused, not silently accepted.
res="$(rpc wallet.ecash-receive "{\"token\":\"$TOKEN\"}")"
[ -n "$(printf '%s' "$res" | err_of)" ] \
&& ok "double-redeem refused" || bad "double-redeem was ACCEPTED"
else
bad "ecash-send: $(printf '%s' "$res" | err_of)"
fi
else
log " (skipping send/receive — balance ${BAL:-0} sats too low)"
fi
# ── NUT-13 backup phrase + restore ────────────────────────────────────────
# The wallet's backup story: without a phrase the coins live in exactly one
# file and nothing can bring them back, so "is it active" is the check that
# matters most here.
res="$(rpc wallet.ecash-seed-status)"
SEED_ACTIVE="$(printf '%s' "$res" | jqf result.active)"
if [ -n "$SEED_ACTIVE" ]; then
ok "ecash-seed-status reports active=$SEED_ACTIVE"
else
bad "ecash-seed-status: $(printf '%s' "$res" | err_of)"
fi
# Reveal doubles as activation on a node that predates NUT-13 — the password
# prompt is the only moment the encrypted master seed can be opened. It never
# overwrites an established phrase, so this is safe to run repeatedly.
res="$(rpc wallet.ecash-seed-reveal "{\"password\":\"$ARCHY_PASSWORD\"}")"
WORDS="$(printf '%s' "$res" | jqf result.word_count)"
if [ "$WORDS" = "24" ]; then
ok "ecash-seed-reveal returned 24 words (source: $(printf '%s' "$res" | jqf result.source))"
else
bad "ecash-seed-reveal: $(printf '%s' "$res" | err_of)"
fi
# Reveal must stay gated. A wrong password returning words would make the
# phrase readable by anyone with a session.
res="$(rpc wallet.ecash-seed-reveal '{"password":"definitely-not-the-password"}')"
[ -z "$(printf '%s' "$res" | jqf result.word_count)" ] \
&& ok "seed reveal refuses a wrong password" || bad "SEED REVEALED WITH A WRONG PASSWORD"
# The phrase must be stable: a second reveal returning different words would
# mean the wallet re-derived a new one and orphaned every coin minted so far.
w1="$(rpc wallet.ecash-seed-reveal "{\"password\":\"$ARCHY_PASSWORD\"}" | jqf result.words)"
w2="$(rpc wallet.ecash-seed-reveal "{\"password\":\"$ARCHY_PASSWORD\"}" | jqf result.words)"
if [ -n "$w1" ] && [ "$w1" = "$w2" ]; then
ok "the backup phrase is stable across reveals"
else
bad "the backup phrase CHANGED between reveals"
fi
# Restore is additive and idempotent, so it is safe against a live wallet.
# Running it twice must not double the balance — that would mean re-adding
# coins already held.
res="$(rpc wallet.ecash-restore)"
if [ -z "$(printf '%s' "$res" | err_of)" ]; then
ok "ecash-restore scanned $(printf '%s' "$res" | jqf result.keysets_scanned) keyset(s), recovered $(printf '%s' "$res" | jqf result.recovered_sats) sats"
AFTER_FIRST="$(rpc wallet.ecash-balance | jqf result.cashu_sats)"
rpc wallet.ecash-restore >/dev/null
AFTER_SECOND="$(rpc wallet.ecash-balance | jqf result.cashu_sats)"
if [ "$AFTER_FIRST" = "$AFTER_SECOND" ]; then
ok "restore is idempotent (balance steady at ${AFTER_FIRST} sats)"
else
bad "restore is NOT idempotent: ${AFTER_FIRST} -> ${AFTER_SECOND} sats"
fi
else
bad "ecash-restore: $(printf '%s' "$res" | err_of)"
fi
# ── malformed input handling ──────────────────────────────────────────────
res="$(rpc wallet.ecash-receive '{"token":"not-a-token"}')"
[ -n "$(printf '%s' "$res" | err_of)" ] \
&& ok "garbage token rejected" || bad "garbage token was accepted"
# ── melt quote (spend to Lightning) ───────────────────────────────────────
if [ -n "${ECASH_TEST_BOLT11:-}" ]; then
res="$(rpc wallet.ecash-melt "{\"bolt11\":\"$ECASH_TEST_BOLT11\"}")"
mq="$(printf '%s' "$res" | jqf result.quote_id)"
[ -n "$mq" ] && ok "ecash-melt quoted ${mq:0:12}…" \
|| bad "ecash-melt: $(printf '%s' "$res" | err_of)"
else
log " (skipping melt — set ECASH_TEST_BOLT11 to a payable invoice)"
fi
log ""
log "== $PASS passed, $FAIL failed =="
exit "$FAIL"