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
+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);
}