diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index 7ac95127..9f2fad60 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -1033,4 +1033,43 @@ mod tests { "release-root public key KAT" ); } + + /// The node's whole identity hangs off `Mnemonic::to_seed("")`, so this + /// pins that derivation to the BIP-39 specification vectors rather than to + /// whatever the `bip39` crate happens to do today. + /// + /// It exists because the crate is not version-pinned any more: the exact + /// `=2.1.0` pin was relaxed to `"2.1"` in 2026-08 so the `cashu` crate + /// could resolve (the pin transitively froze `unicode-normalization` at a + /// version with no common solution). A bump that changed derivation would + /// silently re-key every node on the fleet and orphan every existing + /// backup, which no amount of code review reliably catches — this does. + #[test] + fn seed_derivation_matches_the_bip39_specification_vectors() { + let words = "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about" + .split_whitespace() + .collect::>() + .join(" "); + let mnemonic: bip39::Mnemonic = words.parse().expect("valid test mnemonic"); + + // Empty passphrase — exactly how MasterSeed::from_mnemonic derives. + assert_eq!( + hex::encode(mnemonic.to_seed("")), + "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\ + 9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4" + .replace(['\n', ' '], ""), + "BIP-39 seed derivation changed — every node's keys would move" + ); + + // With a passphrase, where NFKD normalisation actually participates; + // this is the arm a `unicode-normalization` change could disturb. + assert_eq!( + hex::encode(mnemonic.to_seed("TREZOR")), + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553\ + 1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + .replace(['\n', ' '], ""), + "BIP-39 passphrase normalisation changed" + ); + } } diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index c0be9e48..607b0be2 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -314,10 +314,28 @@ pub struct KeysetInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MintKeyset { pub id: String, + /// Currency unit this keyset signs for ("sat", "usd", "eur", "msat"…). + /// + /// Defaulted rather than required: a mint that omits it is sat-only in + /// practice, and refusing to parse would break wallets against mints that + /// predate multi-unit support. + #[serde(default = "default_unit")] + pub unit: String, + /// Whether the mint will still sign with this keyset. + #[serde(default = "default_true")] + pub active: bool, /// Map of amount (as string) to hex-encoded public key. pub keys: std::collections::HashMap, } +fn default_unit() -> String { + "sat".to_string() +} + +fn default_true() -> bool { + true +} + impl MintKeyset { /// Get the mint's public key for a given denomination amount. pub fn key_for_amount(&self, amount: u64) -> Result { diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 6053be32..e021a696 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -213,13 +213,20 @@ impl MintClient { /// Get the active keyset for the "sat" unit. pub async fn get_active_sat_keyset(&self) -> Result { let keysets = self.get_keys().await?; + // Must be a *sat* keyset, not merely the first one with keys. A + // multi-unit mint answers /v1/keys with usd/eur/msat keysets too, and + // whichever came first would then sign sat-denominated requests — + // the mint rejects that with `11013 Unit unsupported` (seen against + // testnut.cashu.space, 2026-08-17). Sat-only mints omit the field + // entirely and default to "sat", so this stays correct for them. keysets .into_iter() - .find(|k| { - // Find active sat keyset — check keys map is non-empty - !k.keys.is_empty() + .filter(|k| !k.keys.is_empty() && k.unit.eq_ignore_ascii_case("sat")) + // Prefer a keyset the mint will still sign with. + .max_by_key(|k| k.active) + .ok_or_else(|| { + anyhow::anyhow!("No active sat keyset found at mint {}", self.url) }) - .ok_or_else(|| anyhow::anyhow!("No active keyset found at mint {}", self.url)) } // ── Mint quotes (NUT-04) ── diff --git a/scripts/test-ecash-routes.sh b/scripts/test-ecash-routes.sh index 1b3d4983..7fa64268 100755 --- a/scripts/test-ecash-routes.sh +++ b/scripts/test-ecash-routes.sh @@ -123,8 +123,21 @@ QUOTE="$(printf '%s' "$res" | jqf result.quote_id)" [ -n "$QUOTE" ] && ok "ecash-mint issued quote ${QUOTE:0:12}…" \ || bad "ecash-mint: $(printf '%s' "$res" | err_of)" -# The test mint pays its own quotes, so the claim can be attempted directly. -# On mainnet this is expected to stay unpaid — that is not a failure. +# The test mint settles its own invoices, but not instantly — poll the quote +# state at the mint before claiming, or the claim races the settlement and +# fails with "Quote not paid". On mainnet the invoice is real and nobody pays +# it here, so staying unpaid is the expected outcome, not a failure. +if [ -n "$QUOTE" ] && [ "$NETWORK" = testnet ]; then + 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 + [ "$state" = PAID ] && ok "test mint settled the quote" \ + || log " (quote still $state — claim will likely fail)" +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)"