@@ -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;
11151169tlv_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) ]
12601332mod 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) ]
20662225mod bolt12_tests {
20672226 use super :: { Bolt12ParseError , Bolt12SemanticError , Offer } ;
0 commit comments