fix(ecash): give every node a backup phrase, and prove restore works
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>
This commit is contained in:
archipelago
2026-08-17 08:27:01 -04:00
co-authored by Claude Opus 5
parent cbbd20e22e
commit e30516316b
6 changed files with 241 additions and 16 deletions
+21 -8
View File
@@ -260,13 +260,16 @@ impl RpcHandler {
Ok(Some(seed)) => Some(seed.source()),
_ => None,
};
// Whether the node has an encrypted master seed decides whether the
// "set up" path can derive from it, which is what the operator is
// promised: your node's 24 words already cover your ecash.
// A phrase can always be established. Whether the node has an
// encrypted master seed decides only *which kind*: derived from it
// (the node's 24 words already cover the ecash), or independent (the
// phrase is the only copy). The UI needs both facts to set the right
// expectation before the operator commits to writing something down.
Ok(serde_json::json!({
"active": active,
"source": source,
"can_activate": crate::seed::seed_exists(data_dir),
"can_activate": true,
"derivable_from_node_seed": crate::seed::seed_exists(data_dir),
}))
}
@@ -306,12 +309,22 @@ impl RpcHandler {
}));
}
// No encrypted master seed to derive from — common on nodes onboarded
// before that step existed. The choice here is not "derived or
// independent", it is "independent or no backup at all", so we make
// one and label it honestly. Every surface that shows an
// `independent` phrase says the node's own recovery phrase does not
// cover it.
if !crate::seed::seed_exists(data_dir) {
password.zeroize();
anyhow::bail!(
"This node has no encrypted seed backup, so an ecash recovery \
phrase cannot be derived from it."
);
let seed = crate::wallet::nut13::establish_independent(data_dir).await?;
let words = seed.words();
return Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": true,
}));
}
// The backup passphrase may differ from the login password — same
@@ -648,6 +648,18 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
// NUT-09 is optional. A mint that never implemented it answers 404
// or 405, which `mint_error` would render as "mint returned 404
// with no further detail" — true, and useless to someone trying to
// get their coins back. Name the actual limitation instead.
if matches!(status.as_u16(), 404 | 405 | 501) {
anyhow::bail!(
"This mint does not support restoring from a backup phrase (NUT-09). \
Your coins are safe, but they can only be recovered from a wallet \
file backup while they stay at {}",
self.url
);
}
let body = res.text().await.unwrap_or_default();
return Err(mint_error("Restore", status, &body));
}
+32
View File
@@ -215,6 +215,38 @@ pub async fn establish_from_master(
Ok(EcashSeed::from_mnemonic(derived, SeedSource::NodeSeed))
}
/// Establish a wallet seed that is **not** derived from the node's master
/// seed, for a node that has no encrypted master seed to derive from.
///
/// Plenty of nodes are in that position: `identity/master_seed.enc` is written
/// during onboarding, and any node onboarded before that step existed simply
/// does not have one. The choice there is not "derived phrase or independent
/// phrase" — it is "independent phrase or **no backup at all**", and a wallet
/// whose coins can be restored from words the operator holds is strictly
/// better than one whose coins die with a single file.
///
/// The cost is stated plainly rather than hidden: the phrase is recorded as
/// [`SeedSource::Independent`], and every surface that shows it says that
/// restoring the node will *not* bring this wallet back — only these words
/// will. That is a real obligation on the operator, so it must never be the
/// silent default when derivation was possible; [`establish_from_master`] is
/// what a node with a master seed gets.
pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
if let Some(existing) = load_seed(data_dir).await? {
return Ok(existing);
}
// Same guarded generation path as the node's own seed: a named CSPRNG and
// the degenerate-entropy check, not a dependency's default (KEY-05).
let (mnemonic, _seed) = crate::seed::MasterSeed::generate()?;
write_seed(data_dir, &mnemonic, SeedSource::Independent).await?;
warn!(
"Established an INDEPENDENT ecash backup phrase: this node has no encrypted \
master seed to derive one from, so restoring the node will not restore this \
ecash wallet — only the phrase itself will."
);
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Independent))
}
/// Write the seed file at 0600, creating the wallet directory if needed.
async fn write_seed(
data_dir: &Path,
+20 -7
View File
@@ -22,6 +22,11 @@ type SeedStatus = {
active: boolean
source: 'node-seed' | 'independent' | null
can_activate: boolean
/** Whether a phrase can be *derived* from the node's recovery phrase. When
* false the wallet still gets a backup — it is just independent, and the
* operator has to keep it themselves. Saying which one they are about to
* get, before they write anything down, is the whole point of this flag. */
derivable_from_node_seed: boolean
}
const status = ref<SeedStatus | null>(null)
@@ -153,17 +158,28 @@ async function restoreFromPhrase() {
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
<p v-if="status?.active" class="text-sm text-white/60">
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm text-white/60">
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
phrase so the words you already wrote down cover your ecash too. Reveal it here
if you want to restore your ecash into another wallet (Minibits, Nutstash,
<span class="font-mono">cdk-cli</span>) without handing over the node's own seed.
</p>
<p v-else-if="status?.active" class="text-sm text-white/60">
Your ecash wallet has its own 24-word phrase. Reveal it to write it down, or to
restore your ecash into another wallet (Minibits, Nutstash,
<span class="font-mono">cdk-cli</span>).
</p>
<p v-else class="text-sm text-white/60">
Ecash is a bearer instrument: the coins live in a file on this node, and right now
nothing can bring them back if that file is lost. Setting up a backup phrase fixes
that for every coin minted from then on. It's derived from this node's recovery
phrase, so there's nothing new to write down.
that for every coin minted from then on.
<template v-if="status?.derivable_from_node_seed">
It's derived from this node's recovery phrase, so there's nothing new to write down.
</template>
<template v-else>
This node has no encrypted seed backup to derive from, so the phrase will be its
own you'll need to write these words down and keep them.
</template>
</p>
<p v-if="status?.source === 'independent'" class="mt-2 text-xs text-orange-300/90">
@@ -171,16 +187,13 @@ async function restoreFromPhrase() {
phrase — restoring the node will not bring the ecash back. Write these words down
separately.
</p>
<p v-if="!status?.active && !status?.can_activate" class="mt-2 text-xs text-orange-300/90">
This node has no encrypted seed backup, so a phrase can't be derived from it.
</p>
</div>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
:disabled="!status?.active && !status?.can_activate"
@click="openReveal"
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
</div>
+153
View File
@@ -0,0 +1,153 @@
#!/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"
+3 -1
View File
@@ -140,7 +140,9 @@ fi
if [ -n "$QUOTE" ]; then
res="$(rpc wallet.ecash-mint-claim "{\"quote_id\":\"$QUOTE\",\"amount_sats\":16}")"
claimed="$(printf '%s' "$res" | jqf result.amount_sats)"
# 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