diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 2009d559..c7ee3a27 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -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 diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index e533b44e..696296f7 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -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)); } diff --git a/core/archipelago/src/wallet/nut13.rs b/core/archipelago/src/wallet/nut13.rs index 952d62e6..9d4bc266 100644 --- a/core/archipelago/src/wallet/nut13.rs +++ b/core/archipelago/src/wallet/nut13.rs @@ -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 { + 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, diff --git a/neode-ui/src/components/EcashSeedBackup.vue b/neode-ui/src/components/EcashSeedBackup.vue index 9b47b45d..da608178 100644 --- a/neode-ui/src/components/EcashSeedBackup.vue +++ b/neode-ui/src/components/EcashSeedBackup.vue @@ -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(null) @@ -153,17 +158,28 @@ async function restoreFromPhrase() {

Ecash backup phrase

-

+

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, cdk-cli) without handing over the node's own seed.

+

+ 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, + cdk-cli). +

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. + +

@@ -171,16 +187,13 @@ async function restoreFromPhrase() { phrase — restoring the node will not bring the ecash back. Write these words down separately.

-

- This node has no encrypted seed backup, so a phrase can't be derived from it. -

+
diff --git a/scripts/test-ecash-restore.sh b/scripts/test-ecash-restore.sh new file mode 100755 index 00000000..ca0c6f76 --- /dev/null +++ b/scripts/test-ecash-restore.sh @@ -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" diff --git a/scripts/test-ecash-routes.sh b/scripts/test-ecash-routes.sh index 16b821ee..769b46ce 100755 --- a/scripts/test-ecash-routes.sh +++ b/scripts/test-ecash-routes.sh @@ -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