fix(ecash): resolve short v2 keyset ids before verifying a cashuB payment
The cashu crate's V4 (cashuB) encoder always writes a NUT-02 v2 keyset id in its short 8-byte form (serialize_v4_keyset_id narrows to ShortKeysetId unconditionally), which is spec-compliant: the receiver must expand it against the mint's keyset list before spending. The payment-receive loop in ecash.rs called MintClient::swap() directly with the short id still attached, so mint.minibits.cash (whose active keyset is v2) rejected every cashuB payment with `422 inputs[0].id: NUT02: ID length invalid` — hence "seller doesn't accept your Cashu mint" on any peer purchase. MintClient::receive_token() already resolves this via resolve_truncated_keyset_ids(); expose it pub(crate) and call it from the ecash.rs loop too. The only other swap() call sites either run after resolution or operate on our own full-id proofs. Adds a test documenting that the short form is what crosses the wire, so serialize_v4 is not "fixed" to defeat it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -489,6 +489,45 @@ pub fn amount_to_denominations(mut amount: u64) -> Vec<u64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A v4 (cashuB) token always carries a v2 keyset id in its short
|
||||||
|
/// (8-byte) form — confirmed against the real cashu 0.17.5 crate
|
||||||
|
/// (`TokenV4Token`'s `serialize_v4_keyset_id` unconditionally narrows to
|
||||||
|
/// `ShortKeysetId`) and live against mint.minibits.cash (2026-09-18).
|
||||||
|
/// That is spec-compliant, not a bug here: a receiver MUST resolve the
|
||||||
|
/// short id against the mint's keyset list before spending it (see
|
||||||
|
/// `MintClient::resolve_truncated_keyset_ids`, and its missing call site
|
||||||
|
/// that this exact round trip caught in `ecash.rs`'s payment-receive
|
||||||
|
/// path). This test documents that the short form is what actually
|
||||||
|
/// crosses the wire, so nobody re-"fixes" serialize_v4 to defeat it.
|
||||||
|
#[test]
|
||||||
|
fn v4_round_trip_shortens_a_v2_keyset_id_by_design() {
|
||||||
|
let real_v2_id = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
|
||||||
|
assert_eq!(real_v2_id.len(), 66);
|
||||||
|
let token = CashuToken {
|
||||||
|
token: vec![TokenEntry {
|
||||||
|
mint: "https://mint.minibits.cash/Bitcoin".to_string(),
|
||||||
|
proofs: vec![Proof {
|
||||||
|
amount: 2,
|
||||||
|
id: real_v2_id.to_string(),
|
||||||
|
secret: "abcdef1234567890".to_string(),
|
||||||
|
// secp256k1 generator point G — a genuinely valid
|
||||||
|
// compressed pubkey (the other tests' placeholder C
|
||||||
|
// value is not, and serialize_v4 is the first path
|
||||||
|
// here that actually parses it).
|
||||||
|
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||||
|
.to_string(),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
memo: None,
|
||||||
|
unit: Some("sat".to_string()),
|
||||||
|
};
|
||||||
|
let v4 = token.serialize_v4().expect("serialize_v4 should accept a real v2 id");
|
||||||
|
let decoded = CashuToken::deserialize(&v4).unwrap();
|
||||||
|
let got_id = &decoded.token[0].proofs[0].id;
|
||||||
|
assert_eq!(got_id, "01fc0ec0e59cd6fa", "expected the short (8-byte) v2 form on the wire");
|
||||||
|
assert!(is_truncated_v2_keyset_id(got_id));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_serialize_deserialize_roundtrip() {
|
fn test_serialize_deserialize_roundtrip() {
|
||||||
let token = CashuToken {
|
let token = CashuToken {
|
||||||
|
|||||||
@@ -1363,7 +1363,18 @@ pub async fn verify_and_receive_payment(
|
|||||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||||
let target_amounts = amount_to_denominations(entry_total);
|
let target_amounts = amount_to_denominations(entry_total);
|
||||||
|
|
||||||
match client.swap(&entry.proofs, &target_amounts).await {
|
// The reference cashu crate's V4 (cashuB) encoder always writes a
|
||||||
|
// NUT-02 v2 keyset id in its short (8-byte) form — confirmed live
|
||||||
|
// against mint.minibits.cash (2026-09-18): every cashuB payment
|
||||||
|
// carrying that mint's active v2 keyset failed verification with a
|
||||||
|
// bare 422 "NUT02: ID length invalid" because this call skipped
|
||||||
|
// straight to swap() with the short id still attached. MintClient's
|
||||||
|
// own receive_token() already resolves this correctly; this is the
|
||||||
|
// same fix, just not routed through it (the loop here also tracks
|
||||||
|
// received_total/mint-scoped errors that receive_token() doesn't).
|
||||||
|
let proofs = client.resolve_truncated_keyset_ids(&entry.proofs).await;
|
||||||
|
|
||||||
|
match client.swap(&proofs, &target_amounts).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
wallet.add_proofs(&entry.mint, result.new_proofs);
|
||||||
|
|||||||
@@ -783,7 +783,7 @@ impl MintClient {
|
|||||||
/// verification at the mint and no coins move. Anything already valid, or
|
/// verification at the mint and no coins move. Anything already valid, or
|
||||||
/// with no unambiguous match, is passed through untouched so the mint's
|
/// with no unambiguous match, is passed through untouched so the mint's
|
||||||
/// own error is what the operator sees.
|
/// own error is what the operator sees.
|
||||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
pub(crate) 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));
|
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||||
if !needs_repair {
|
if !needs_repair {
|
||||||
return proofs.to_vec();
|
return proofs.to_vec();
|
||||||
|
|||||||
Reference in New Issue
Block a user