Skip to content

Commit ee04086

Browse files
committed
Merge branch 'deterministic-cu-dp' into hashpool6
2 parents fc05529 + ea05e76 commit ee04086

1 file changed

Lines changed: 35 additions & 8 deletions

File tree

  • crates/cashu/src/nuts/nut01

crates/cashu/src/nuts/nut01/mod.rs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -252,15 +252,15 @@ impl CurrencyUnit {
252252

253253
/// Generate deterministic derivation index for custom currency units
254254
fn custom_derivation_index(s: &str, reserved: u32) -> Option<u32> {
255-
// 1) NFC normalization: composes equivalent Unicode sequences (e.g., "e" + U+0301) into a single
256-
// canonical code point (e.g., "é") so visually/equivalently identical strings hash the same
257-
// 2) lowercase: avoids case-induced divergence ("USD" vs "usd")
258-
// 3) trim: removes accidental leading/trailing whitespace (" usd " vs "usd")
259-
let norm = s.nfc().collect::<String>().to_lowercase();
260-
let norm = norm.trim();
255+
// Canonicalize before hashing to ensure identical representations converge:
256+
// 1) trim ASCII whitespace so " usd " == "usd"
257+
// 2) NFC normalization so composed/decomposed Unicode forms align
258+
// 3) uppercase to guarantee case-insensitive matching while preserving spec intent
259+
let trimmed = s.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\r' | '\n'));
260+
let canonical = trimmed.nfc().collect::<String>().to_uppercase();
261261

262262
// use SHA-256 so that the same normalized string always yields the same digest
263-
let digest = Sha256::hash(norm.as_bytes());
263+
let digest = Sha256::hash(canonical.as_bytes());
264264

265265
// take 4 bytes in a fixed endianness to get a u32
266266
let x = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]) as u64;
@@ -425,7 +425,7 @@ mod tests {
425425
.derivation_index()
426426
.expect("custom units should always produce an index");
427427

428-
assert_eq!(idx, 278_131_397);
428+
assert_eq!(idx, 1_502_388_632);
429429
}
430430

431431
#[cfg(all(test, feature = "mint"))]
@@ -438,4 +438,31 @@ mod tests {
438438
let hardened_max = (1 << 31) - 1;
439439
assert!(idx >= 5 && idx <= hardened_max);
440440
}
441+
442+
#[cfg(all(test, feature = "mint"))]
443+
#[test]
444+
fn currency_unit_derivation_matches_nut_xx_vectors() {
445+
let vectors = [
446+
("sat", 0),
447+
("msat", 1),
448+
("auth", 4),
449+
("usd", 2),
450+
("eur", 3),
451+
("nuts", 1_502_388_632),
452+
(" NUTS ", 1_502_388_632),
453+
("eurc", 1_321_886_555),
454+
("cafe\u{0301}", 642_348_970),
455+
("CAFÉ", 642_348_970),
456+
("gbp", 1_076_107_758),
457+
("JPY", 1_137_986_283),
458+
];
459+
460+
for (input, expected) in vectors {
461+
let unit = CurrencyUnit::from_str(input).unwrap();
462+
let idx = unit
463+
.derivation_index()
464+
.expect("reserved and custom units should yield indices");
465+
assert_eq!(idx, expected, "input {input}");
466+
}
467+
}
441468
}

0 commit comments

Comments
 (0)