Skip to content

Commit e797180

Browse files
authored
Merge pull request #3814 from erickcestari/validate-currency-code
fix(bolt12): Make CurrencyCode a validated wrapper type
2 parents 9253f51 + 388c5d5 commit e797180

File tree

4 files changed

+180
-10
lines changed

4 files changed

+180
-10
lines changed

lightning/src/offers/invoice_request.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,7 +1468,7 @@ mod tests {
14681468
#[cfg(c_bindings)]
14691469
use crate::offers::offer::OfferWithExplicitMetadataBuilder as OfferBuilder;
14701470
use crate::offers::offer::{
1471-
Amount, ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, Quantity,
1471+
Amount, CurrencyCode, ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, Quantity,
14721472
};
14731473
use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError};
14741474
use crate::offers::payer::PayerTlvStreamRef;
@@ -1997,7 +1997,10 @@ mod tests {
19971997
assert_eq!(tlv_stream.amount, None);
19981998

19991999
let invoice_request = OfferBuilder::new(recipient_pubkey())
2000-
.amount(Amount::Currency { iso4217_code: *b"USD", amount: 10 })
2000+
.amount(Amount::Currency {
2001+
iso4217_code: CurrencyCode::new(*b"USD").unwrap(),
2002+
amount: 10,
2003+
})
20012004
.build_unchecked()
20022005
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)
20032006
.unwrap()
@@ -2372,7 +2375,10 @@ mod tests {
23722375

23732376
let invoice_request = OfferBuilder::new(recipient_pubkey())
23742377
.description("foo".to_string())
2375-
.amount(Amount::Currency { iso4217_code: *b"USD", amount: 1000 })
2378+
.amount(Amount::Currency {
2379+
iso4217_code: CurrencyCode::new(*b"USD").unwrap(),
2380+
amount: 1000,
2381+
})
23762382
.build_unchecked()
23772383
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)
23782384
.unwrap()

lightning/src/offers/merkle.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ mod tests {
287287
use crate::ln::inbound_payment::ExpandedKey;
288288
use crate::offers::invoice_request::{InvoiceRequest, UnsignedInvoiceRequest};
289289
use crate::offers::nonce::Nonce;
290-
use crate::offers::offer::{Amount, OfferBuilder};
290+
use crate::offers::offer::{Amount, CurrencyCode, OfferBuilder};
291291
use crate::offers::parse::Bech32Encode;
292292
use crate::offers::signer::Metadata;
293293
use crate::offers::test_utils::recipient_pubkey;
@@ -355,7 +355,10 @@ mod tests {
355355
// BOLT 12 test vectors
356356
let invoice_request = OfferBuilder::new(recipient_pubkey)
357357
.description("A Mathematical Treatise".into())
358-
.amount(Amount::Currency { iso4217_code: *b"USD", amount: 100 })
358+
.amount(Amount::Currency {
359+
iso4217_code: CurrencyCode::new(*b"USD").unwrap(),
360+
amount: 100,
361+
})
359362
.build_unchecked()
360363
// Override the payer metadata and signing pubkey to match the test vectors
361364
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)

lightning/src/offers/offer.rs

Lines changed: 164 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -999,7 +999,9 @@ impl OfferContents {
999999
let (currency, amount) = match &self.amount {
10001000
None => (None, None),
10011001
Some(Amount::Bitcoin { amount_msats }) => (None, Some(*amount_msats)),
1002-
Some(Amount::Currency { iso4217_code, amount }) => (Some(iso4217_code), Some(*amount)),
1002+
Some(Amount::Currency { iso4217_code, amount }) => {
1003+
(Some(iso4217_code.as_bytes()), Some(*amount))
1004+
},
10031005
};
10041006

10051007
let features = {
@@ -1076,7 +1078,59 @@ pub enum Amount {
10761078
}
10771079

10781080
/// An ISO 4217 three-letter currency code (e.g., USD).
1079-
pub type CurrencyCode = [u8; 3];
1081+
///
1082+
/// Currency codes must be exactly 3 ASCII uppercase letters.
1083+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1084+
pub struct CurrencyCode([u8; 3]);
1085+
1086+
impl CurrencyCode {
1087+
/// Creates a new `CurrencyCode` from a 3-byte array.
1088+
///
1089+
/// Returns an error if the bytes are not valid UTF-8 or not all ASCII uppercase.
1090+
pub fn new(code: [u8; 3]) -> Result<Self, CurrencyCodeError> {
1091+
if !code.iter().all(|c| c.is_ascii_uppercase()) {
1092+
return Err(CurrencyCodeError);
1093+
}
1094+
1095+
Ok(Self(code))
1096+
}
1097+
1098+
/// Returns the currency code as a byte array.
1099+
pub fn as_bytes(&self) -> &[u8; 3] {
1100+
&self.0
1101+
}
1102+
1103+
/// Returns the currency code as a string slice.
1104+
pub fn as_str(&self) -> &str {
1105+
core::str::from_utf8(&self.0).expect("currency code is always valid UTF-8")
1106+
}
1107+
}
1108+
1109+
impl FromStr for CurrencyCode {
1110+
type Err = CurrencyCodeError;
1111+
1112+
fn from_str(s: &str) -> Result<Self, Self::Err> {
1113+
if s.len() != 3 {
1114+
return Err(CurrencyCodeError);
1115+
}
1116+
1117+
let mut code = [0u8; 3];
1118+
code.copy_from_slice(s.as_bytes());
1119+
Self::new(code)
1120+
}
1121+
}
1122+
1123+
impl AsRef<[u8]> for CurrencyCode {
1124+
fn as_ref(&self) -> &[u8] {
1125+
&self.0
1126+
}
1127+
}
1128+
1129+
impl core::fmt::Display for CurrencyCode {
1130+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1131+
f.write_str(self.as_str())
1132+
}
1133+
}
10801134

10811135
/// Quantity of items supported by an [`Offer`].
10821136
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -1115,7 +1169,7 @@ const OFFER_ISSUER_ID_TYPE: u64 = 22;
11151169
tlv_stream!(OfferTlvStream, OfferTlvStreamRef<'a>, OFFER_TYPES, {
11161170
(2, chains: (Vec<ChainHash>, WithoutLength)),
11171171
(OFFER_METADATA_TYPE, metadata: (Vec<u8>, WithoutLength)),
1118-
(6, currency: CurrencyCode),
1172+
(6, currency: [u8; 3]),
11191173
(8, amount: (u64, HighZeroBytesDroppedBigSize)),
11201174
(10, description: (String, WithoutLength)),
11211175
(12, features: (OfferFeatures, WithoutLength)),
@@ -1209,7 +1263,11 @@ impl TryFrom<FullOfferTlvStream> for OfferContents {
12091263
},
12101264
(None, Some(amount_msats)) => Some(Amount::Bitcoin { amount_msats }),
12111265
(Some(_), None) => return Err(Bolt12SemanticError::MissingAmount),
1212-
(Some(iso4217_code), Some(amount)) => Some(Amount::Currency { iso4217_code, amount }),
1266+
(Some(currency_bytes), Some(amount)) => {
1267+
let iso4217_code = CurrencyCode::new(currency_bytes)
1268+
.map_err(|_| Bolt12SemanticError::InvalidCurrencyCode)?;
1269+
Some(Amount::Currency { iso4217_code, amount })
1270+
},
12131271
};
12141272

12151273
if amount.is_some() && description.is_none() {
@@ -1256,6 +1314,20 @@ impl core::fmt::Display for Offer {
12561314
}
12571315
}
12581316

1317+
/// An error indicating that a currency code is invalid.
1318+
///
1319+
/// A valid currency code must follow the ISO 4217 standard:
1320+
/// - Exactly 3 characters in length.
1321+
/// - Consist only of uppercase ASCII letters (A–Z).
1322+
#[derive(Clone, Debug, PartialEq, Eq)]
1323+
pub struct CurrencyCodeError;
1324+
1325+
impl core::fmt::Display for CurrencyCodeError {
1326+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1327+
write!(f, "invalid currency code: must be 3 uppercase ASCII letters (ISO 4217)")
1328+
}
1329+
}
1330+
12591331
#[cfg(test)]
12601332
mod tests {
12611333
#[cfg(not(c_bindings))]
@@ -1273,6 +1345,7 @@ mod tests {
12731345
use crate::ln::inbound_payment::ExpandedKey;
12741346
use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT};
12751347
use crate::offers::nonce::Nonce;
1348+
use crate::offers::offer::CurrencyCode;
12761349
use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError};
12771350
use crate::offers::test_utils::*;
12781351
use crate::types::features::OfferFeatures;
@@ -1541,7 +1614,8 @@ mod tests {
15411614
#[test]
15421615
fn builds_offer_with_amount() {
15431616
let bitcoin_amount = Amount::Bitcoin { amount_msats: 1000 };
1544-
let currency_amount = Amount::Currency { iso4217_code: *b"USD", amount: 10 };
1617+
let currency_amount =
1618+
Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 10 };
15451619

15461620
let offer = OfferBuilder::new(pubkey(42)).amount_msats(1000).build().unwrap();
15471621
let tlv_stream = offer.as_tlv_stream();
@@ -1820,6 +1894,36 @@ mod tests {
18201894
Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount)
18211895
),
18221896
}
1897+
1898+
let mut tlv_stream = offer.as_tlv_stream();
1899+
tlv_stream.0.amount = Some(1000);
1900+
tlv_stream.0.currency = Some(b"\xFF\xFE\xFD"); // invalid UTF-8 bytes
1901+
1902+
let mut encoded_offer = Vec::new();
1903+
tlv_stream.write(&mut encoded_offer).unwrap();
1904+
1905+
match Offer::try_from(encoded_offer) {
1906+
Ok(_) => panic!("expected error"),
1907+
Err(e) => assert_eq!(
1908+
e,
1909+
Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidCurrencyCode)
1910+
),
1911+
}
1912+
1913+
let mut tlv_stream = offer.as_tlv_stream();
1914+
tlv_stream.0.amount = Some(1000);
1915+
tlv_stream.0.currency = Some(b"usd"); // invalid ISO 4217 code
1916+
1917+
let mut encoded_offer = Vec::new();
1918+
tlv_stream.write(&mut encoded_offer).unwrap();
1919+
1920+
match Offer::try_from(encoded_offer) {
1921+
Ok(_) => panic!("expected error"),
1922+
Err(e) => assert_eq!(
1923+
e,
1924+
Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidCurrencyCode)
1925+
),
1926+
}
18231927
}
18241928

18251929
#[test]
@@ -2062,6 +2166,61 @@ mod tests {
20622166
}
20632167
}
20642168

2169+
#[cfg(test)]
2170+
mod currency_code_tests {
2171+
use super::CurrencyCode;
2172+
2173+
#[test]
2174+
fn creates_valid_currency_codes() {
2175+
let usd = CurrencyCode::new(*b"USD").unwrap();
2176+
assert_eq!(usd.as_str(), "USD");
2177+
assert_eq!(usd.as_bytes(), b"USD");
2178+
2179+
let eur = CurrencyCode::new(*b"EUR").unwrap();
2180+
assert_eq!(eur.as_str(), "EUR");
2181+
assert_eq!(eur.as_bytes(), b"EUR");
2182+
}
2183+
2184+
#[test]
2185+
fn rejects_invalid_utf8() {
2186+
let invalid_utf8 = [0xFF, 0xFE, 0xFD];
2187+
assert!(CurrencyCode::new(invalid_utf8).is_err());
2188+
}
2189+
2190+
#[test]
2191+
fn rejects_lowercase_letters() {
2192+
assert!(CurrencyCode::new(*b"usd").is_err());
2193+
assert!(CurrencyCode::new(*b"Eur").is_err());
2194+
}
2195+
2196+
#[test]
2197+
fn rejects_non_letters() {
2198+
assert!(CurrencyCode::new(*b"US1").is_err());
2199+
assert!(CurrencyCode::new(*b"U$D").is_err());
2200+
}
2201+
2202+
#[test]
2203+
fn from_str_validates_length() {
2204+
assert!("US".parse::<CurrencyCode>().is_err());
2205+
assert!("USDA".parse::<CurrencyCode>().is_err());
2206+
2207+
assert!("USD".parse::<CurrencyCode>().is_ok());
2208+
}
2209+
2210+
#[test]
2211+
fn works_with_real_currency_codes() {
2212+
let codes = ["USD", "EUR", "GBP", "JPY", "CNY"];
2213+
2214+
for code_str in &codes {
2215+
let code1 = CurrencyCode::new(code_str.as_bytes().try_into().unwrap()).unwrap();
2216+
let code2 = code_str.parse::<CurrencyCode>().unwrap();
2217+
2218+
assert_eq!(code1, code2);
2219+
assert_eq!(code1.as_str(), *code_str);
2220+
}
2221+
}
2222+
}
2223+
20652224
#[cfg(test)]
20662225
mod bolt12_tests {
20672226
use super::{Bolt12ParseError, Bolt12SemanticError, Offer};

lightning/src/offers/parse.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ pub enum Bolt12SemanticError {
149149
MissingAmount,
150150
/// The amount exceeded the total bitcoin supply or didn't match an expected amount.
151151
InvalidAmount,
152+
/// The currency code did not contain valid ASCII uppercase letters.
153+
InvalidCurrencyCode,
152154
/// An amount was provided but was not sufficient in value.
153155
InsufficientAmount,
154156
/// An amount was provided but was not expected.

0 commit comments

Comments
 (0)