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();
+138 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
@@ -20,7 +20,7 @@ import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
type SeedStatus = {
active: boolean
source: 'node-seed' | 'independent' | null
source: 'node-seed' | 'independent' | 'imported' | 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
@@ -105,6 +105,60 @@ async function copyRevealedWords() {
} catch { /* clipboard unavailable */ }
}
// ── Import ─────────────────────────────────────────────────────────────────
// Bring-your-own: point this wallet at another NUT-13 wallet's derivation, so
// coins held in Minibits, Nutstash or cdk-cli become restorable here.
//
// Replacing an established phrase is the one lossy thing on this screen. The
// coins already held stay spendable — they are proofs, not derivations — but
// they were minted under the old phrase, so a restore will no longer find
// them. Hence the explicit confirmation, and the reminder to write the
// current phrase down first.
const showImportModal = ref(false)
const importWords = ref('')
const importPassword = ref('')
const importCode = ref('')
const importConfirm = ref(false)
const importing = ref(false)
const importError = ref('')
const importDone = ref(false)
const importWordCount = computed(
() => importWords.value.trim().split(/\s+/).filter(Boolean).length,
)
function openImport() {
importWords.value = ''
importPassword.value = ''
importCode.value = ''
importConfirm.value = false
importError.value = ''
importDone.value = false
showImportModal.value = true
}
async function submitImport() {
if (importing.value || !importPassword.value || importWordCount.value === 0) return
importing.value = true
importError.value = ''
try {
const params: Record<string, string | boolean> = {
words: importWords.value.trim(),
password: importPassword.value,
confirm: importConfirm.value,
}
if (importCode.value) params.code = importCode.value
await rpcClient.call({ method: 'wallet.ecash-seed-import', params })
importDone.value = true
importWords.value = ''
await loadStatus()
} catch (e: unknown) {
importError.value = e instanceof Error ? e.message : 'Import failed'
} finally {
importing.value = false
}
}
// ── Restore ────────────────────────────────────────────────────────────────
// The other half of the backup. Safe to run against a working wallet: the
// backend skips coins already held and never re-adds spent ones, so this is
@@ -182,10 +236,10 @@ async function restoreFromPhrase() {
</template>
</p>
<p v-if="status?.source === 'independent'" class="mt-2 text-xs text-orange-300/90">
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
This wallet's phrase was <strong>not</strong> derived from the node's recovery
phrase — restoring the node will not bring the ecash back. Write these words down
separately.
phrase{{ status?.source === 'imported' ? ' it was imported' : '' }}, so restoring
the node will not bring the ecash back. Only these words will.
</p>
</div>
@@ -216,7 +270,86 @@ async function restoreFromPhrase() {
<p v-if="restoreMsg" role="status" aria-live="polite" class="mt-3 text-xs alert-success px-3 py-2 rounded-lg">{{ restoreMsg }}</p>
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
</div>
<div class="mt-4 pt-4 border-t border-white/10">
<div class="flex items-start justify-between gap-4">
<p class="text-sm text-white/60 min-w-0">
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
Point this wallet at a phrase you already have — from Minibits, Nutstash or
<span class="font-mono">cdk-cli</span> — so its coins can be restored here.
</p>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="openImport"
>Import</button>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="showImportModal"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="showImportModal = false"
>
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="import-ecash-seed-title">
<h3 id="import-ecash-seed-title" class="text-lg font-semibold text-white mb-1">Import an ecash phrase</h3>
<template v-if="importDone">
<p class="text-sm text-white/70 my-4">
Imported. This wallet now derives its coins from that phrase — run
<span class="text-white/90 font-medium">Restore</span> to pull in the coins it
owns at your mint.
</p>
<button type="button" @click="showImportModal = false" class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-4">
Paste the 24-word phrase from the other wallet. The coins already in this wallet
stay spendable either way.
</p>
<form @submit.prevent="submitImport" class="space-y-3">
<div>
<label class="block text-xs text-white/60 mb-1">
Recovery phrase
<span class="text-white/30">({{ importWordCount }} word{{ importWordCount === 1 ? '' : 's' }})</span>
</label>
<textarea v-model="importWords" rows="3" spellcheck="false" autocapitalize="none" autocomplete="off" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono focus:outline-none focus:border-white/30" placeholder="abandon abandon abandon …"></textarea>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Password</label>
<input v-model="importPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="importCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<label v-if="status?.active" class="flex items-start gap-2 text-xs text-orange-300/90 bg-orange-500/10 border border-orange-400/20 rounded-lg px-3 py-2">
<input type="checkbox" v-model="importConfirm" class="mt-0.5 shrink-0" />
<span>
Replace this wallet's current phrase. Coins minted under the old one stay
spendable but a restore will no longer find them reveal and write the
current phrase down first. The old phrase is archived on the node, not deleted.
</span>
</label>
<p v-if="importError" role="alert" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ importError }}</p>
<div class="flex gap-2 pt-1">
<button type="button" @click="showImportModal = false" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
<button
type="submit"
:disabled="importing || !importPassword || importWordCount === 0 || (status?.active && !importConfirm)"
class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50"
>{{ importing ? 'Importing…' : 'Import' }}</button>
</div>
</form>
</template>
</div>
</div>
</Teleport>
<Teleport to="body">
<div