Files
archy/scripts/test-ecash-restore.sh
T

154 lines
6.6 KiB
Bash
Raw Normal View History

#!/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"