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:
archipelago
2026-08-17 04:06:30 -04:00
co-authored by Claude Fable 5
parent 9ccc325a4d
commit 2277fc4684
2 changed files with 190 additions and 2 deletions
+106
View File
@@ -215,12 +215,56 @@ impl CashuToken {
if proof.c.is_empty() {
anyhow::bail!("Proof has empty C");
}
validate_keyset_id(&proof.id)?;
}
}
Ok(())
}
}
/// NUT-02 keyset ID: hex for either 8 bytes (v1, the `00…` short form) or
/// 33 bytes (v2, version-byte + hash).
///
/// Checked when a token is decoded rather than left to the mint. Forwarding
/// an out-of-spec id produced a swap the mint rejected with a bare
/// `422 Unprocessable Entity`, which reached the operator as "check server
/// logs" with nothing actionable in the UI (framework-pt, 2026-08-17,
/// mint.minibits.cash: `inputs[0].id: NUT02: ID length invalid`). A local
/// check can say which keyset format the token uses and that this wallet
/// cannot spend it, before any network call.
fn validate_keyset_id(id: &str) -> Result<()> {
let bytes = hex::decode(id).map_err(|_| {
anyhow::anyhow!(
"Token uses a keyset id that is not hex ({id:?}) — this wallet supports \
NUT-02 v1 (8-byte) and v2 (33-byte) hex keyset ids"
)
})?;
match bytes.len() {
// 8 bytes is v1's whole id, and also what a wallet that predates v2
// leaves behind when it truncates one. Both are accepted here; the
// truncated case is repaired against the mint's keyset list at swap
// time (see MintClient::resolve_truncated_keyset_ids).
8 | 33 => Ok(()),
n => anyhow::bail!(
"Token uses an unsupported keyset id format: {n}-byte id {id:?}. NUT-02 \
defines 8-byte (v1) and 33-byte (v2) ids; the mint will reject a swap \
carrying this one"
),
}
}
/// Is this the first 8 bytes of a NUT-02 **v2** keyset id rather than a
/// complete v1 one?
///
/// A v1 id is 8 bytes beginning with the version byte `0x00`; a v2 id is 33
/// bytes beginning with `0x01`. So an 8-byte id that starts with `0x01` is a
/// v2 id some wallet cut to the old length — it cannot be spent as-is, but
/// the full id can be recovered from the mint because the short form is a
/// prefix of it.
pub fn is_truncated_v2_keyset_id(id: &str) -> bool {
id.len() == 16 && id.starts_with("01") && hex::decode(id).is_ok()
}
/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec
/// default) and falling back to other alphabets some implementations use.
fn decode_token_base64(payload: &str) -> Result<Vec<u8>, base64::DecodeError> {
@@ -473,4 +517,66 @@ mod tests {
};
assert!(proof.c_as_pubkey().is_ok());
}
#[test]
fn keyset_ids_of_both_nut02_versions_are_accepted() {
// v1: 8 bytes / 16 hex chars.
assert!(validate_keyset_id("009a1f293253e41e").is_ok());
// v2: 33 bytes / 66 hex chars (version byte + 32-byte hash).
let v2 = format!("01{}", "ab".repeat(32));
assert!(validate_keyset_id(&v2).is_ok());
}
#[test]
fn a_truncated_v2_keyset_id_is_recognised_as_repairable() {
// The real case: a Minibits token carried the first 8 bytes of the
// mint's 33-byte v2 keyset id (2026-08-17).
let short = "01fc0ec0e59cd6fa";
let full = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
assert!(is_truncated_v2_keyset_id(short));
assert!(full.starts_with(short), "short form must prefix the full id");
// It must survive token validation so the swap path can repair it,
// rather than being rejected as malformed.
assert!(validate_keyset_id(short).is_ok());
// A genuine v1 id (version byte 00) is not "truncated".
assert!(!is_truncated_v2_keyset_id("009a1f293253e41e"));
// Neither is a complete v2 id.
assert!(!is_truncated_v2_keyset_id(full));
}
#[test]
fn an_out_of_spec_keyset_id_is_rejected_locally_with_its_length() {
// 9 bytes — what a legacy base64 keyset id decodes to, and neither
// NUT-02 length. The mint answers this with a bare 422, so the
// message has to come from here.
let err = validate_keyset_id("00112233445566778899")
.expect_err("9-byte keyset id must be rejected");
let msg = err.to_string();
assert!(msg.contains("10-byte") || msg.contains("unsupported keyset id"), "{msg}");
// Non-hex ids (the original base64 keyset format) are named as such
// rather than reported as a length problem.
let err = validate_keyset_id("I2yN+iRYfkzT").expect_err("base64 id must be rejected");
assert!(err.to_string().contains("not hex"), "{err}");
}
#[test]
fn a_token_carrying_an_unsupported_keyset_id_fails_to_decode() {
let token = CashuToken {
token: vec![TokenEntry {
mint: "https://mint.example.com".to_string(),
proofs: vec![Proof {
amount: 1,
id: "I2yN+iRYfkzT".to_string(),
secret: "s".to_string(),
c: "02".to_string(),
}],
}],
memo: None,
unit: None,
};
// The whole point: this must fail here, not at the mint.
assert!(token.validate().is_err());
}
}
+84 -2
View File
@@ -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);
}