Demo images / Build & push demo images (push) Failing after 2m11s
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>
264 lines
11 KiB
Bash
Executable File
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"
|