fix(ecash): sign with the mint's SAT keyset, not whichever came first
`get_active_sat_keyset` picked the first keyset with a non-empty key map,
and `MintKeyset` had no `unit` field to filter on — so on a multi-unit mint
the wallet signed sat-denominated mint/swap requests against a usd or eur
keyset. The mint refuses that with `11013 Unit unsupported`, which is
exactly what claiming minted coins hit against testnut.cashu.space (it
serves usd, eur, msat and sat keysets). Minibits is sat-only, so this
latent bug never surfaced in production — the test-mint switch found it on
its first run.
MintKeyset now carries `unit` and `active`, both defaulted so a sat-only
mint that omits them still parses, and selection filters to sat and prefers
an active keyset.
Also: pin BIP-39 seed derivation to the specification's own test vectors.
The node's entire identity hangs off `Mnemonic::to_seed("")`, and the
`bip39` crate is no longer version-pinned (the exact pin had to be relaxed
so `cashu` could resolve). A bump that changed derivation would silently
re-key every node on the fleet and orphan every backup; both vectors —
empty passphrase and the NFKD-exercising passphrase arm — now fail the
suite instead. Verified byte-identical under the newly resolved 2.2.2.
And the route script polls the mint's quote state before claiming: the test
mint settles its own invoices, but not instantly, so claiming immediately
raced the settlement and reported a spurious "Quote not paid".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
be2cfb8293
commit
26638aa621
@@ -1033,4 +1033,43 @@ mod tests {
|
|||||||
"release-root public key KAT"
|
"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::<Vec<_>>()
|
||||||
|
.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,10 +314,28 @@ pub struct KeysetInfo {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MintKeyset {
|
pub struct MintKeyset {
|
||||||
pub id: String,
|
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.
|
/// Map of amount (as string) to hex-encoded public key.
|
||||||
pub keys: std::collections::HashMap<String, String>,
|
pub keys: std::collections::HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_unit() -> String {
|
||||||
|
"sat".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
impl MintKeyset {
|
impl MintKeyset {
|
||||||
/// Get the mint's public key for a given denomination amount.
|
/// Get the mint's public key for a given denomination amount.
|
||||||
pub fn key_for_amount(&self, amount: u64) -> Result<PublicKey> {
|
pub fn key_for_amount(&self, amount: u64) -> Result<PublicKey> {
|
||||||
|
|||||||
@@ -213,13 +213,20 @@ impl MintClient {
|
|||||||
/// Get the active keyset for the "sat" unit.
|
/// Get the active keyset for the "sat" unit.
|
||||||
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
|
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
|
||||||
let keysets = self.get_keys().await?;
|
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
|
keysets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|k| {
|
.filter(|k| !k.keys.is_empty() && k.unit.eq_ignore_ascii_case("sat"))
|
||||||
// Find active sat keyset — check keys map is non-empty
|
// Prefer a keyset the mint will still sign with.
|
||||||
!k.keys.is_empty()
|
.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) ──
|
// ── Mint quotes (NUT-04) ──
|
||||||
|
|||||||
@@ -123,8 +123,21 @@ QUOTE="$(printf '%s' "$res" | jqf result.quote_id)"
|
|||||||
[ -n "$QUOTE" ] && ok "ecash-mint issued quote ${QUOTE:0:12}…" \
|
[ -n "$QUOTE" ] && ok "ecash-mint issued quote ${QUOTE:0:12}…" \
|
||||||
|| bad "ecash-mint: $(printf '%s' "$res" | err_of)"
|
|| bad "ecash-mint: $(printf '%s' "$res" | err_of)"
|
||||||
|
|
||||||
# The test mint pays its own quotes, so the claim can be attempted directly.
|
# The test mint settles its own invoices, but not instantly — poll the quote
|
||||||
# On mainnet this is expected to stay unpaid — that is not a failure.
|
# 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
|
if [ -n "$QUOTE" ]; then
|
||||||
res="$(rpc wallet.ecash-mint-claim "{\"quote_id\":\"$QUOTE\",\"amount_sats\":16}")"
|
res="$(rpc wallet.ecash-mint-claim "{\"quote_id\":\"$QUOTE\",\"amount_sats\":16}")"
|
||||||
claimed="$(printf '%s' "$res" | jqf result.amount_sats)"
|
claimed="$(printf '%s' "$res" | jqf result.amount_sats)"
|
||||||
|
|||||||
Reference in New Issue
Block a user