fix(ecash): redeem tokens whose keyset id was truncated to the old length
A Minibits token could not be redeemed on framework-pt: the mint answered POST /v1/swap with a bare 422, which the RPC sanitizer turned into "Operation failed. Check server logs for details." The journal had the real reason: inputs[0].id: NUT02: ID length invalid, expected 8 bytes (short/v1) or 33 bytes (v2) The token carried keyset id 01fc0ec0e59cd6fa — exactly the first 8 bytes of the mint's active 33-byte id 01fc0ec0e59cd6fa01b7a88f…a821. NUT-02 v2 ids are 33 bytes behind a 0x01 version byte; the sending wallet cut it to the 8 bytes that were the whole id under v1. The mint reads the version, expects 33 bytes, and rejects it — so the length complaint is right even though 8 bytes is legal for a 0x00-prefixed v1 id. The id only names which keyset signed a proof, and the short form is a prefix of the full one, so it can be repaired: before swapping, any 8-byte 0x01-prefixed id is expanded against GET /v1/keysets (new MintClient::get_keysets — it lists inactive keysets too, and coins from a retired keyset stay spendable). Preferring the active keyset on a prefix tie. Attempting this is safe: an id naming the wrong keyset fails signature verification at the mint and no coins move. Anything already valid, or with no unambiguous match, is passed through so the mint's own error still reaches the operator. Token decoding now also checks keyset ids locally, so an id that is not hex or is neither NUT-02 length fails with a message naming the format instead of a raw 422 from the mint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9ccc325a4d
commit
2277fc4684
@@ -9,7 +9,8 @@
|
||||
|
||||
use super::bdhke;
|
||||
use super::cashu::{
|
||||
amount_to_denominations, BlindSignature, BlindedMessageRequest, CashuToken, MintKeyset, Proof,
|
||||
amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest,
|
||||
CashuToken, KeysetInfo, MintKeyset, Proof,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -184,6 +185,31 @@ impl MintClient {
|
||||
Ok(keysets)
|
||||
}
|
||||
|
||||
/// List the mint's keysets (NUT-02 `GET /v1/keysets`) — ids and status
|
||||
/// only, no public keys. Unlike `/v1/keys` this includes *inactive*
|
||||
/// keysets, which a received token may well reference: coins from a
|
||||
/// retired keyset stay spendable.
|
||||
pub async fn get_keysets(&self) -> Result<Vec<KeysetInfo>> {
|
||||
let url = format!("{}/v1/keysets", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch mint keysets")?;
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint keysets request failed: {}", res.status());
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint keysets")?;
|
||||
let keysets: Vec<KeysetInfo> = serde_json::from_value(
|
||||
body.get("keysets")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse keyset list")?;
|
||||
Ok(keysets)
|
||||
}
|
||||
|
||||
/// Get the active keyset for the "sat" unit.
|
||||
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
|
||||
let keysets = self.get_keys().await?;
|
||||
@@ -483,6 +509,61 @@ impl MintClient {
|
||||
|
||||
/// Receive a CashuToken by swapping its proofs for fresh ones.
|
||||
/// This prevents double-spend and ensures only we can spend the new proofs.
|
||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||
///
|
||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||
/// wallets written against the original 8-byte format truncate it when
|
||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
||||
/// bytes, and rejects the swap — reported as
|
||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||
/// a Minibits-issued token, 2026-08-17).
|
||||
///
|
||||
/// The id only names which keyset signed the proof, so restoring the full
|
||||
/// id the mint advertises is exactly what the sender meant. It is also
|
||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
||||
/// verification at the mint and no coins move. Anything already valid, or
|
||||
/// with no unambiguous match, is passed through untouched so the mint's
|
||||
/// own error is what the operator sees.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||
if !needs_repair {
|
||||
return proofs.to_vec();
|
||||
}
|
||||
|
||||
let known = match self.get_keysets().await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
||||
return proofs.to_vec();
|
||||
}
|
||||
};
|
||||
|
||||
proofs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut p| {
|
||||
if !is_truncated_v2_keyset_id(&p.id) {
|
||||
return p;
|
||||
}
|
||||
// Prefer an active keyset when a prefix somehow matches more
|
||||
// than one; ambiguity beyond that is left to the mint.
|
||||
let mut matches = known
|
||||
.iter()
|
||||
.filter(|k| k.id.len() == 66 && k.id.starts_with(&p.id))
|
||||
.collect::<Vec<_>>();
|
||||
matches.sort_by_key(|k| !k.active);
|
||||
if let Some(full) = matches.first() {
|
||||
debug!(
|
||||
"Expanded truncated v2 keyset id {} to {} for swap",
|
||||
p.id, full.id
|
||||
);
|
||||
p.id = full.id.clone();
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn receive_token(&self, token: &CashuToken) -> Result<Vec<Proof>> {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
@@ -498,7 +579,8 @@ impl MintClient {
|
||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(total);
|
||||
|
||||
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
||||
let result = self.swap(&proofs, &target_amounts).await?;
|
||||
all_new_proofs.extend(result.new_proofs);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user