fix(ecash): give every node a backup phrase, and prove restore works
Demo images / Build & push demo images (push) Failing after 2m11s
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:
co-authored by
Claude Opus 5
parent
cbbd20e22e
commit
e30516316b
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Executable
+153
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user