feat(ecash): import a backup phrase from another NUT-13 wallet
Demo images / Build & push demo images (push) Failing after 2m2s

Bring-your-own, the open question the migration plan left. Point this
wallet at a phrase you already hold — Minibits, Nutstash, cdk-cli — and
its coins become restorable here, which is the other half of "these
words are portable".

Replacing an established phrase is the one genuinely lossy thing this
module can do, so it is treated that way. The coins already held stay
spendable: they are proofs, not derivations, and nothing here touches
`ecash.json`. But they were minted under the *old* phrase, so a restore
will no longer find them. Hence an explicit confirm, a prompt to reveal
and write down the current phrase first, and — most importantly — the
replaced phrase is archived beside the wallet, never overwritten. It may
be the last copy of the words a balance was minted under, and quietly
destroying that is precisely what this module exists to prevent.

Re-importing the phrase already in use is a no-op rather than a
replacement, so it archives nothing.

Counters are deliberately left alone. They are per-keyset and
seed-relative, so under a new seed they merely start high, which costs
nothing because a restore scans from zero regardless. Resetting them
would be the dangerous choice on the day someone imports the phrase they
were already using.

`imported` is its own provenance rather than reusing `independent`: both
mean the node's recovery phrase does not cover the wallet, but only one
of them means the operator already knows where else the words live.

15 NUT-13 tests green, 1000 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 10:09:19 -04:00
co-authored by Claude Opus 5
parent c1a79fdd69
commit ee40880ce5
4 changed files with 356 additions and 5 deletions
@@ -271,6 +271,7 @@ impl RpcHandler {
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
+39
View File
@@ -354,6 +354,45 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-seed-import` — adopt a phrase from another NUT-13 wallet.
///
/// Gated like every other route that touches key material. Replacing an
/// established phrase additionally needs `confirm: true`, because coins
/// minted under the old one stop being restorable from words — they stay
/// spendable, but a restore will not find them. The old phrase is archived
/// beside the wallet rather than overwritten.
pub(super) async fn handle_wallet_ecash_seed_import(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use zeroize::Zeroize;
let params = params.unwrap_or_default();
let words = params
.get("words")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("A recovery phrase is required"))?
.to_string();
let confirm = params
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut password = self
.verify_reveal_auth(&params, "the ecash seed")
.await?;
password.zeroize();
let seed =
crate::wallet::nut13::import_mnemonic(&self.config.data_dir, &words, confirm).await?;
Ok(serde_json::json!({
"source": seed.source(),
"word_count": seed.words().len(),
}))
}
/// `wallet.ecash-restore` — rebuild the wallet's coins from its NUT-13
/// phrase by asking a mint which re-derived secrets it has signed.
///
+178
View File
@@ -89,6 +89,19 @@ pub enum SeedSource {
/// NUT-13 wallet, but restoring the node from its recovery phrase will
/// *not* bring it back — only these words will.
Independent,
/// Supplied by the operator, from another NUT-13 wallet. Same caveat as
/// `Independent` — the node's recovery phrase does not cover it — but it
/// is worth telling apart, because these words exist somewhere else too
/// and the operator already knows where.
Imported,
}
impl SeedSource {
/// Does restoring the *node* from its recovery phrase bring this wallet
/// back? Only a derived phrase can promise that.
pub fn covered_by_node_seed(&self) -> bool {
matches!(self, Self::NodeSeed)
}
}
/// A loaded ecash wallet seed, ready to derive secrets from.
@@ -247,6 +260,69 @@ pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Independent))
}
/// Adopt a phrase the operator supplies, from another NUT-13 wallet.
///
/// This is the "bring your own" path: it points the wallet at someone else's
/// derivation, which is what makes coins held in Minibits, Nutstash or
/// `cdk-cli` restorable here.
///
/// Replacing a phrase is the one genuinely lossy thing this module can do.
/// Coins already in `wallet/ecash.json` stay spendable — they are proofs, not
/// derivations, and nothing here touches them — but they were minted under
/// the *old* phrase, so a future restore will no longer find them. The old
/// phrase is therefore archived rather than overwritten, and replacing an
/// established one needs `confirm`. An operator who imports by mistake must
/// not lose the only copy of the words their balance was minted under.
///
/// Counters are deliberately left alone. They are per-keyset and seed-
/// relative, so under a new seed they merely start high — which costs nothing,
/// since a restore scans from zero regardless. Resetting them would be the
/// dangerous choice if the imported phrase turned out to be the one already
/// in use.
pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> {
let mnemonic: bip39::Mnemonic = words.split_whitespace().collect::<Vec<_>>().join(" ").parse()
.map_err(|e| anyhow::anyhow!(
"That is not a valid BIP-39 recovery phrase: {e}. Check for typos — every word must come from the BIP-39 word list, and the phrase as a whole carries a checksum."
))?;
if let Some(existing) = load_seed(data_dir).await? {
if existing.mnemonic == mnemonic {
// Importing the phrase already in use: nothing to do, and
// certainly nothing to archive.
return Ok(existing);
}
if !confirm {
anyhow::bail!(
"This wallet already has a backup phrase. Importing a different one means coins minted under the current phrase will no longer be restorable from words — they stay spendable, but a restore will not find them. Reveal and write down the current phrase first, then confirm to replace it."
);
}
archive_seed(data_dir).await?;
}
write_seed(data_dir, &mnemonic, SeedSource::Imported).await?;
warn!("Ecash backup phrase REPLACED by an imported one (the previous phrase, if any, was archived)");
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Imported))
}
/// Move the current seed file aside, timestamped, before it is replaced.
///
/// Never deleted and never overwritten: this file may be the last copy of the
/// words a balance was minted under, and the whole point of the module is that
/// such a thing is not casually destroyed.
async fn archive_seed(data_dir: &Path) -> Result<()> {
let from = seed_path(data_dir);
if !from.exists() {
return Ok(());
}
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json"));
fs::rename(&from, &to)
.await
.with_context(|| format!("Could not archive the previous ecash phrase to {}", to.display()))?;
warn!("Previous ecash phrase archived to {}", to.display());
Ok(())
}
/// Write the seed file at 0600, creating the wallet directory if needed.
async fn write_seed(
data_dir: &Path,
@@ -524,6 +600,108 @@ mod tests {
);
}
/// A phrase from another wallet must derive that wallet's secrets — that
/// is the entire point of importing one.
#[tokio::test]
async fn an_imported_phrase_derives_the_other_wallets_secrets() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Stand in for the other wallet: a known phrase and what it derives.
let theirs: bip39::Mnemonic = TEST_MNEMONIC.parse().unwrap();
let expected =
EcashSeed::from_mnemonic(theirs.clone(), SeedSource::Imported)
.derive_output(V1_KEYSET, 3)
.unwrap();
let imported = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(imported.source(), SeedSource::Imported);
assert!(!imported.source().covered_by_node_seed());
assert_eq!(imported.derive_output(V1_KEYSET, 3).unwrap().0, expected.0);
// And it is what the wallet uses from now on.
let reloaded = load_seed(d).await.unwrap().expect("persisted");
assert_eq!(reloaded.words(), imported.words());
}
#[tokio::test]
async fn importing_over_an_established_phrase_needs_confirmation() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let original = establish_from_master(d, &master).await.unwrap();
let original_words = original.words();
// Refused without confirmation — replacing a phrase silently would
// orphan every coin minted under it.
let (other, _) = MasterSeed::generate().unwrap();
let err = import_mnemonic(d, &other.to_string(), false)
.await
.expect_err("must not replace without confirmation");
assert!(err.to_string().contains("already has a backup phrase"), "{err}");
assert_eq!(
load_seed(d).await.unwrap().unwrap().words(),
original_words,
"a refused import must change nothing"
);
// Confirmed: replaced, and the old phrase archived rather than lost.
import_mnemonic(d, &other.to_string(), true).await.unwrap();
assert_eq!(
load_seed(d).await.unwrap().unwrap().words(),
other.words().map(|w| w.to_string()).collect::<Vec<_>>()
);
let archived: Vec<_> = std::fs::read_dir(d.join("wallet"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("cashu_seed.replaced-"))
.collect();
assert_eq!(archived.len(), 1, "the replaced phrase must be kept");
}
/// Re-importing the phrase already in use is a no-op, not a replacement —
/// it must not archive anything or churn the file.
#[tokio::test]
async fn importing_the_current_phrase_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let first = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
let again = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(first.words(), again.words());
let archived = std::fs::read_dir(d.join("wallet"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("cashu_seed.replaced-"))
.count();
assert_eq!(archived, 0);
}
#[tokio::test]
async fn a_malformed_phrase_is_refused_with_something_actionable() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Right shape, wrong checksum — the commonest real mistake.
let bad = TEST_MNEMONIC.replace(" art", " abandon");
let err = import_mnemonic(d, &bad, false).await.expect_err("checksum");
assert!(err.to_string().contains("BIP-39"), "{err}");
assert!(!seed_exists(d), "a rejected phrase must not be written");
assert!(import_mnemonic(d, "not a phrase", false).await.is_err());
assert!(import_mnemonic(d, "", false).await.is_err());
}
/// Whitespace and casing vary wildly in what people paste out of other
/// wallets; the words are what matter.
#[tokio::test]
async fn a_pasted_phrase_survives_untidy_whitespace() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let messy = format!(" {} ", TEST_MNEMONIC.replace(' ', "\n "));
let imported = import_mnemonic(d, &messy, false).await.unwrap();
assert_eq!(imported.words().len(), 24);
assert_eq!(imported.words().join(" "), TEST_MNEMONIC);
}
#[tokio::test]
async fn the_seed_file_is_owner_only() {
let dir = tempfile::tempdir().unwrap();