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>
154 lines
6.6 KiB
Bash
Executable File
154 lines
6.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Prove the ecash backup phrase actually brings coins back.
|
|
#
|
|
# The route check (test-ecash-routes.sh) can only confirm that
|
|
# `wallet.ecash-restore` returns without an error — and on a wallet with
|
|
# nothing to find, "recovered 0 sats" is a pass there. That is exactly the
|
|
# shape of a backup that looks fine until the day you need it. This script
|
|
# does the only test that settles it: mint coins, **delete the wallet file**,
|
|
# restore, and check the coins came back.
|
|
#
|
|
# ARCHY_PASSWORD='…' ./scripts/test-ecash-restore.sh
|
|
#
|
|
# Testnet only, and it refuses to run otherwise. It deletes a wallet file;
|
|
# doing that to real coins to prove a point is not a trade worth making, and
|
|
# a flag to override would eventually get used. The testnet purse is a
|
|
# separate file (`wallet/ecash.testnet.json`) holding valueless testnut coins,
|
|
# so the real one is never in scope.
|
|
#
|
|
# The original file is copied aside first and put back at the end on every
|
|
# exit path, so even a failed restore loses nothing.
|
|
|
|
set -uo pipefail
|
|
|
|
HOST="${ARCHY_HOST:-127.0.0.1}"
|
|
SCHEME="${ARCHY_SCHEME:-http}"
|
|
BASE="$SCHEME://$HOST"
|
|
WALLET="${ARCHY_DATA_DIR:-/var/lib/archipelago}/wallet/ecash.testnet.json"
|
|
MINT_SATS="${ECASH_RESTORE_MINT_SATS:-21}"
|
|
|
|
JAR="$(mktemp -t ecash-restore-XXXXXX.jar)"
|
|
BACKUP=""
|
|
ORIGINAL_NETWORK=""
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
ok() { PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$*"; }
|
|
bad() { FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n' "$*"; }
|
|
|
|
cleanup() {
|
|
# Put the wallet back before anything else — this is the only step whose
|
|
# failure could actually cost someone coins.
|
|
if [ -n "$BACKUP" ] && [ -f "$BACKUP" ]; then
|
|
sudo mv -f "$BACKUP" "$WALLET" && printf 'restored the testnet wallet file\n'
|
|
sudo systemctl restart archipelago >/dev/null 2>&1
|
|
fi
|
|
[ -n "$ORIGINAL_NETWORK" ] && rpc wallet.ecash-set-network "{\"network\":\"$ORIGINAL_NETWORK\"}" >/dev/null 2>&1
|
|
rm -f "$JAR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
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 300 -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('.'):
|
|
cur = cur.get(k) if isinstance(cur,dict) else None
|
|
print('' if cur is None else cur)" "$1"; }
|
|
|
|
[ -n "${ARCHY_PASSWORD:-}" ] || { echo "ARCHY_PASSWORD is not set."; exit 2; }
|
|
curl -s -c "$JAR" --max-time 30 -H 'Content-Type: application/json' -X POST "$BASE/rpc/v1" \
|
|
-d "{\"method\":\"auth.login\",\"params\":{\"password\":\"$ARCHY_PASSWORD\"}}" >/dev/null
|
|
grep -q session "$JAR" 2>/dev/null || { echo "Login failed."; exit 2; }
|
|
|
|
echo "== ecash restore proof =="
|
|
ORIGINAL_NETWORK="$(rpc wallet.ecash-network | jqf result.network)"
|
|
rpc wallet.ecash-set-network '{"network":"testnet"}' >/dev/null
|
|
NETWORK="$(rpc wallet.ecash-network | jqf result.network)"
|
|
if [ "$NETWORK" != "testnet" ]; then
|
|
echo "Refusing to run: could not switch to testnet (still '$NETWORK')."; exit 2
|
|
fi
|
|
MINT="$(rpc wallet.ecash-network | jqf result.mint_url)"
|
|
echo "mint: $MINT"
|
|
|
|
# The phrase has to exist *before* the coins are minted, or there is nothing
|
|
# to derive them from — which is the whole point being tested.
|
|
if [ "$(rpc wallet.ecash-seed-status | jqf result.active)" != "True" ]; then
|
|
rpc wallet.ecash-seed-reveal "{\"password\":\"$ARCHY_PASSWORD\"}" >/dev/null
|
|
fi
|
|
[ "$(rpc wallet.ecash-seed-status | jqf result.active)" = "True" ] \
|
|
&& ok "backup phrase is active" || { bad "no backup phrase — cannot prove anything"; exit 1; }
|
|
|
|
echo "== minting $MINT_SATS sats under the phrase =="
|
|
QUOTE="$(rpc wallet.ecash-mint "{\"amount_sats\":$MINT_SATS}" | jqf result.quote_id)"
|
|
[ -n "$QUOTE" ] || { bad "could not get a mint quote"; exit 1; }
|
|
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
|
|
MINTED="$(rpc wallet.ecash-mint-claim "{\"quote_id\":\"$QUOTE\",\"amount_sats\":$MINT_SATS}" | jqf result.minted_sats)"
|
|
[ "${MINTED:-0}" -gt 0 ] 2>/dev/null \
|
|
&& ok "minted ${MINTED} sats with NUT-13 secrets" \
|
|
|| { bad "mint-claim failed (quote state: $state) — nothing to recover"; exit 1; }
|
|
|
|
BEFORE="$(rpc wallet.ecash-balance | jqf result.cashu_sats)"
|
|
echo "balance: ${BEFORE} sats"
|
|
|
|
echo "== deleting the wallet file =="
|
|
BACKUP="${WALLET}.restore-proof.$$"
|
|
sudo cp -a "$WALLET" "$BACKUP" || { bad "could not back up $WALLET"; exit 1; }
|
|
sudo rm -f "$WALLET"
|
|
WIPED="$(rpc wallet.ecash-balance | jqf result.cashu_sats)"
|
|
[ "${WIPED:-1}" = "0" ] && ok "wallet is empty after the wipe" \
|
|
|| bad "balance is ${WIPED} after deleting the wallet — the wipe did not take"
|
|
|
|
echo "== restoring from the phrase alone =="
|
|
RES="$(rpc wallet.ecash-restore)"
|
|
ERR="$(printf '%s' "$RES" | jqf error.message)"
|
|
if [ -n "$ERR" ]; then
|
|
bad "ecash-restore: $ERR"
|
|
else
|
|
RECOVERED="$(printf '%s' "$RES" | jqf result.recovered_sats)"
|
|
PROOFS="$(printf '%s' "$RES" | jqf result.recovered_proofs)"
|
|
ok "restore returned ${RECOVERED} sats across ${PROOFS} coins"
|
|
|
|
# The assertion that matters. Coins minted *before* the phrase existed used
|
|
# random secrets and can never come back — so the bar is what this run
|
|
# minted, not the whole prior balance. Anything less means NUT-13 derivation
|
|
# and the mint disagree about what was signed.
|
|
if [ "${RECOVERED:-0}" -ge "${MINTED:-1}" ] 2>/dev/null; then
|
|
ok "every coin minted under the phrase came back (${RECOVERED} >= ${MINTED})"
|
|
else
|
|
bad "only ${RECOVERED} of the ${MINTED} sats minted under the phrase came back"
|
|
fi
|
|
|
|
FINAL="$(rpc wallet.ecash-balance | jqf result.cashu_sats)"
|
|
[ "${FINAL:-0}" = "${RECOVERED:-x}" ] \
|
|
&& ok "the restored balance is exactly what was recovered" \
|
|
|| bad "balance ${FINAL} does not match the ${RECOVERED} sats reported"
|
|
|
|
# A restore that invents coins is worse than one that finds none: the
|
|
# balance would read as spendable and every spend would fail at the mint.
|
|
rpc wallet.ecash-restore >/dev/null
|
|
AGAIN="$(rpc wallet.ecash-balance | jqf result.cashu_sats)"
|
|
[ "${AGAIN:-0}" = "${FINAL:-x}" ] \
|
|
&& ok "a second restore adds nothing (${AGAIN} sats)" \
|
|
|| bad "a second restore changed the balance: ${FINAL} -> ${AGAIN}"
|
|
fi
|
|
|
|
echo ""
|
|
echo "== $PASS passed, $FAIL failed =="
|
|
exit "$FAIL"
|