@@ -122,6 +122,7 @@ pub const MAX_AMOUNT_DIFFERENCE: Amount = Amount::from_sats(100);
122122
123123/// An error when parsing payment instructions into [`PaymentInstructions`].
124124#[ derive( Debug ) ]
125+ #[ cfg_attr( test, derive( PartialEq ) ) ]
125126pub enum ParseError {
126127 /// An invalid lightning BOLT 11 invoice was encountered
127128 InvalidBolt11 ( ParseOrSemanticError ) ,
@@ -637,6 +638,16 @@ pub trait HrnResolver {
637638 fn resolve_hrn < ' a > ( & ' a self , hrn : & ' a HumanReadableName ) -> HrnResolutionFuture < ' a > ;
638639}
639640
641+ /// An HRN "resolver" that never succeeds at resolving.
642+ #[ derive( Clone , Copy ) ]
643+ pub struct DummyHrnResolver ;
644+
645+ impl HrnResolver for DummyHrnResolver {
646+ fn resolve_hrn < ' a > ( & ' a self , _hrn : & ' a HumanReadableName ) -> HrnResolutionFuture < ' a > {
647+ Box :: pin ( async { Err ( "Human Readable Name resolution not supported" ) } )
648+ }
649+ }
650+
640651impl PaymentInstructions {
641652 /// Resolves a string into [`PaymentInstructions`].
642653 pub async fn parse < H : HrnResolver > (
@@ -654,3 +665,275 @@ impl PaymentInstructions {
654665 }
655666 }
656667}
668+
669+ #[ cfg( test) ]
670+ mod tests {
671+ use alloc:: format;
672+ use alloc:: str:: FromStr ;
673+ use alloc:: string:: ToString ;
674+
675+ use super :: * ;
676+
677+ const SAMPLE_INVOICE_WITH_FALLBACK : & str = "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0" ;
678+ const SAMPLE_INVOICE : & str = "lnbc20m1pn7qa2ndqqnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5kwzshmne5zw3lnfqdk8cv26mg9ndjapqzhcxn2wtn9d6ew5e2jfqsp5h3u5f0l522vs488h6n8zm5ca2lkpva532fnl2kp4wnvsuq445erq9qyysgqcqpcxqppz4395v2sjh3t5pzckgeelk9qf0z3fm9jzxtjqpqygayt4xyy7tpjvq5pe7f6727du2mg3t2tfe0cd53de2027ff7es7smtew8xx5x2spwuvkdz" ;
679+ const SAMPLE_OFFER : & str = "lno1qgs0v8hw8d368q9yw7sx8tejk2aujlyll8cp7tzzyh5h8xyppqqqqqqgqvqcdgq2qenxzatrv46pvggrv64u366d5c0rr2xjc3fq6vw2hh6ce3f9p7z4v4ee0u7avfynjw9q" ;
680+ const SAMPLE_BIP21 : & str = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz" ;
681+
682+ const SAMPLE_BIP21_WITH_INVOICE : & str = "bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6" ;
683+ #[ cfg( not( feature = "std" ) ) ]
684+ const SAMPLE_BIP21_WITH_INVOICE_ADDR : & str = "bc1qylh3u67j673h6y6alv70m0pl2yz53tzhvxgg7u" ;
685+ #[ cfg( not( feature = "std" ) ) ]
686+ const SAMPLE_BIP21_WITH_INVOICE_INVOICE : & str = "lnbc10u1p3pj257pp5yztkwjcz5ftl5laxkav23zmzekaw37zk6kmv80pk4xaev5qhtz7qdpdwd3xger9wd5kwm36yprx7u3qd36kucmgyp282etnv3shjcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqjcewm5cjwz4a6rfjx77c490yced6pemk0upkxhy89cmm7sct66k8gneanwykzgdrwrfje69h9u5u0w57rrcsysas7gadwmzxc8c6t0spjazup6" ;
687+
688+ const SAMPLE_BIP21_WITH_INVOICE_AND_LABEL : & str = "bitcoin:tb1p0vztr8q25czuka5u4ta5pqu0h8dxkf72mam89cpg4tg40fm8wgmqp3gv99?amount=0.000001&label=yooo&lightning=lntbs1u1pjrww6fdq809hk7mcnp4qvwggxr0fsueyrcer4x075walsv93vqvn3vlg9etesx287x6ddy4xpp5a3drwdx2fmkkgmuenpvmynnl7uf09jmgvtlg86ckkvgn99ajqgtssp5gr3aghgjxlwshnqwqn39c2cz5hw4cnsnzxdjn7kywl40rru4mjdq9qyysgqcqpcxqrpwurzjqfgtsj42x8an5zujpxvfhp9ngwm7u5lu8lvzfucjhex4pq8ysj5q2qqqqyqqv9cqqsqqqqlgqqqqqqqqfqzgl9zq04nzpxyvdr8vj3h98gvnj3luanj2cxcra0q2th4xjsxmtj8k3582l67xq9ffz5586f3nm5ax58xaqjg6rjcj2vzvx2q39v9eqpn0wx54" ;
689+
690+ #[ tokio:: test]
691+ async fn parse_address ( ) {
692+ let addr_str = "1andreas3batLhQa2FawWjeyjCqyBzypd" ;
693+ let parsed =
694+ PaymentInstructions :: parse ( & addr_str, Network :: Bitcoin , DummyHrnResolver , false )
695+ . await
696+ . unwrap ( ) ;
697+
698+ assert_eq ! ( parsed. methods. len( ) , 1 ) ;
699+ assert_eq ! ( parsed. methods[ 0 ] . amount( ) , None ) ;
700+ assert_eq ! ( parsed. recipient_description, None ) ;
701+ if let PaymentMethod :: OnChain { amount, address } = & parsed. methods [ 0 ] {
702+ assert_eq ! ( * amount, None ) ;
703+ assert_eq ! ( * address, Address :: from_str( addr_str) . unwrap( ) . assume_checked( ) ) ;
704+ } else {
705+ panic ! ( "Wrong method" ) ;
706+ }
707+ }
708+
709+ // Test a handful of ways a lightning invoice might be communicated
710+ async fn check_ln_invoice ( inv : & str ) -> Result < PaymentInstructions , ParseError > {
711+ assert ! ( inv. chars( ) . all( |c| c. is_ascii_lowercase( ) || c. is_digit( 10 ) ) , "{}" , inv) ;
712+ let resolver = DummyHrnResolver ;
713+ let raw = PaymentInstructions :: parse ( inv, Network :: Bitcoin , resolver, false ) . await ;
714+
715+ let ln_uri = format ! ( "lightning:{}" , inv) ;
716+ let uri = PaymentInstructions :: parse ( & ln_uri, Network :: Bitcoin , resolver, false ) . await ;
717+ assert_eq ! ( raw, uri) ;
718+
719+ let ln_uri = format ! ( "LIGHTNING:{}" , inv) ;
720+ let uri = PaymentInstructions :: parse ( & ln_uri, Network :: Bitcoin , resolver, false ) . await ;
721+ assert_eq ! ( raw, uri) ;
722+
723+ let ln_uri = ln_uri. to_uppercase ( ) ;
724+ let uri = PaymentInstructions :: parse ( & ln_uri, Network :: Bitcoin , resolver, false ) . await ;
725+ assert_eq ! ( raw, uri) ;
726+
727+ let btc_uri = format ! ( "bitcoin:?lightning={}" , inv) ;
728+ let uri = PaymentInstructions :: parse ( & btc_uri, Network :: Bitcoin , resolver, false ) . await ;
729+ assert_eq ! ( raw, uri) ;
730+
731+ let btc_uri = btc_uri. to_uppercase ( ) ;
732+ let uri = PaymentInstructions :: parse ( & btc_uri, Network :: Bitcoin , resolver, false ) . await ;
733+ assert_eq ! ( raw, uri) ;
734+
735+ let btc_uri = format ! ( "bitcoin:?req-lightning={}" , inv) ;
736+ let uri = PaymentInstructions :: parse ( & btc_uri, Network :: Bitcoin , resolver, false ) . await ;
737+ assert_eq ! ( raw, uri) ;
738+
739+ let btc_uri = btc_uri. to_uppercase ( ) ;
740+ let uri = PaymentInstructions :: parse ( & btc_uri, Network :: Bitcoin , resolver, false ) . await ;
741+ assert_eq ! ( raw, uri) ;
742+
743+ raw
744+ }
745+
746+ #[ cfg( not( feature = "std" ) ) ]
747+ #[ tokio:: test]
748+ async fn parse_invoice ( ) {
749+ let invoice = Bolt11Invoice :: from_str ( SAMPLE_INVOICE ) . unwrap ( ) ;
750+ let parsed = check_ln_invoice ( SAMPLE_INVOICE ) . await . unwrap ( ) ;
751+
752+ assert_eq ! ( parsed. methods. len( ) , 1 ) ;
753+ assert_eq ! (
754+ parsed. methods[ 0 ] . amount( ) ,
755+ invoice. amount_milli_satoshis( ) . map( Amount :: from_milli_sats) ,
756+ ) ;
757+ assert_eq ! ( parsed. recipient_description, Some ( String :: new( ) ) ) ;
758+ assert ! ( matches!( parsed. methods[ 0 ] . clone( ) , PaymentMethod :: LightningBolt11 ( _) ) ) ;
759+ }
760+
761+ #[ cfg( feature = "std" ) ]
762+ #[ tokio:: test]
763+ async fn parse_invoice ( ) {
764+ assert_eq ! ( check_ln_invoice( SAMPLE_INVOICE ) . await , Err ( ParseError :: InstructionsExpired ) ) ;
765+ }
766+
767+ #[ cfg( not( feature = "std" ) ) ]
768+ #[ tokio:: test]
769+ async fn parse_invoice_with_fallback ( ) {
770+ let invoice = Bolt11Invoice :: from_str ( SAMPLE_INVOICE_WITH_FALLBACK ) . unwrap ( ) ;
771+ let parsed = check_ln_invoice ( SAMPLE_INVOICE_WITH_FALLBACK ) . await . unwrap ( ) ;
772+
773+ assert_eq ! ( parsed. methods. len( ) , 2 ) ;
774+ assert_eq ! (
775+ parsed. methods[ 0 ] . amount( ) ,
776+ invoice. amount_milli_satoshis( ) . map( Amount :: from_milli_sats) ,
777+ ) ;
778+ assert_eq ! (
779+ parsed. methods[ 1 ] . amount( ) ,
780+ invoice. amount_milli_satoshis( ) . map( Amount :: from_milli_sats) ,
781+ ) ;
782+
783+ assert_eq ! ( parsed. recipient_description, None ) ; // no description for a description hash
784+ let is_bolt11 = |meth : & & PaymentMethod | matches ! ( meth, &&PaymentMethod :: LightningBolt11 ( _) ) ;
785+ assert_eq ! ( parsed. methods. iter( ) . filter( is_bolt11) . count( ) , 1 ) ;
786+ let is_onchain = |meth : & & PaymentMethod | matches ! ( meth, &&PaymentMethod :: OnChain { .. } ) ;
787+ assert_eq ! ( parsed. methods. iter( ) . filter( is_onchain) . count( ) , 1 ) ;
788+ }
789+
790+ #[ cfg( feature = "std" ) ]
791+ #[ tokio:: test]
792+ async fn parse_invoice_with_fallback ( ) {
793+ assert_eq ! (
794+ check_ln_invoice( SAMPLE_INVOICE_WITH_FALLBACK ) . await ,
795+ Err ( ParseError :: InstructionsExpired ) ,
796+ ) ;
797+ }
798+
799+ // Test a handful of ways a lightning offer might be communicated
800+ async fn check_ln_offer ( offer : & str ) -> Result < PaymentInstructions , ParseError > {
801+ assert ! ( offer. chars( ) . all( |c| c. is_ascii_lowercase( ) || c. is_digit( 10 ) ) , "{}" , offer) ;
802+ let resolver = DummyHrnResolver ;
803+ let raw = PaymentInstructions :: parse ( offer, Network :: Signet , resolver, false ) . await ;
804+
805+ let btc_uri = format ! ( "bitcoin:?lno={}" , offer) ;
806+ let uri = PaymentInstructions :: parse ( & btc_uri, Network :: Signet , resolver, false ) . await ;
807+ assert_eq ! ( raw, uri) ;
808+
809+ let btc_uri = btc_uri. to_uppercase ( ) ;
810+ let uri = PaymentInstructions :: parse ( & btc_uri, Network :: Signet , resolver, false ) . await ;
811+ assert_eq ! ( raw, uri) ;
812+
813+ let btc_uri = format ! ( "bitcoin:?req-lno={}" , offer) ;
814+ let uri = PaymentInstructions :: parse ( & btc_uri, Network :: Signet , resolver, false ) . await ;
815+ assert_eq ! ( raw, uri) ;
816+
817+ let btc_uri = btc_uri. to_uppercase ( ) ;
818+ let uri = PaymentInstructions :: parse ( & btc_uri, Network :: Signet , resolver, false ) . await ;
819+ assert_eq ! ( raw, uri) ;
820+
821+ raw
822+ }
823+
824+ #[ tokio:: test]
825+ async fn parse_offer ( ) {
826+ let offer = Offer :: from_str ( SAMPLE_OFFER ) . unwrap ( ) ;
827+ let amt_msats = match offer. amount ( ) {
828+ None => None ,
829+ Some ( offer:: Amount :: Bitcoin { amount_msats } ) => Some ( amount_msats) ,
830+ Some ( offer:: Amount :: Currency { .. } ) => panic ! ( ) ,
831+ } ;
832+ let parsed = check_ln_offer ( SAMPLE_OFFER ) . await . unwrap ( ) ;
833+
834+ assert_eq ! ( parsed. methods. len( ) , 1 ) ;
835+ assert_eq ! ( parsed. methods[ 0 ] . amount( ) , amt_msats. map( Amount :: from_milli_sats) ) ;
836+ assert_eq ! ( parsed. recipient_description, Some ( "faucet" . to_string( ) ) ) ;
837+ assert ! ( matches!( parsed. methods[ 0 ] . clone( ) , PaymentMethod :: LightningBolt12 ( _) ) ) ;
838+ }
839+
840+ #[ tokio:: test]
841+ async fn parse_bip_21 ( ) {
842+ let parsed =
843+ PaymentInstructions :: parse ( SAMPLE_BIP21 , Network :: Bitcoin , DummyHrnResolver , false )
844+ . await
845+ . unwrap ( ) ;
846+
847+ assert_eq ! ( parsed. methods. len( ) , 1 ) ;
848+ assert_eq ! ( parsed. methods[ 0 ] . amount( ) , Some ( Amount :: from_sats( 5_000_000_000 ) ) ) ;
849+ assert_eq ! ( parsed. recipient_description, None ) ;
850+ assert ! ( matches!(
851+ parsed. methods[ 0 ] . clone( ) ,
852+ PaymentMethod :: OnChain { amount: Some ( _) , .. }
853+ ) ) ;
854+ }
855+
856+ #[ cfg( not( feature = "std" ) ) ]
857+ #[ tokio:: test]
858+ async fn parse_bip_21_with_invoice ( ) {
859+ let parsed = PaymentInstructions :: parse (
860+ SAMPLE_BIP21_WITH_INVOICE ,
861+ Network :: Bitcoin ,
862+ DummyHrnResolver ,
863+ false ,
864+ )
865+ . await
866+ . unwrap ( ) ;
867+
868+ assert_eq ! ( parsed. methods. len( ) , 2 ) ;
869+ assert_eq ! ( parsed. onchain_payment_amount( ) , Some ( Amount :: from_milli_sats( 1_000_000 ) ) ) ;
870+ assert_eq ! ( parsed. ln_payment_amount( ) , Some ( Amount :: from_milli_sats( 1_000_000 ) ) ) ;
871+ assert_eq ! ( parsed. methods[ 0 ] . amount( ) , Some ( Amount :: from_milli_sats( 1_000_000 ) ) ) ;
872+ assert_eq ! ( parsed. recipient_description, Some ( "sbddesign: For lunch Tuesday" . to_string( ) ) ) ;
873+ if let PaymentMethod :: OnChain { amount, address } = & parsed. methods [ 0 ] {
874+ assert_eq ! ( * amount, Some ( Amount :: from_milli_sats( 1_000_000 ) ) ) ;
875+ assert_eq ! ( address. to_string( ) , SAMPLE_BIP21_WITH_INVOICE_ADDR ) ;
876+ } else {
877+ panic ! ( "Missing on-chain (or order changed)" ) ;
878+ }
879+ if let PaymentMethod :: LightningBolt11 ( inv) = & parsed. methods [ 1 ] {
880+ assert_eq ! ( inv. to_string( ) , SAMPLE_BIP21_WITH_INVOICE_INVOICE ) ;
881+ } else {
882+ panic ! ( "Missing invoice (or order changed)" ) ;
883+ }
884+ }
885+
886+ #[ cfg( feature = "std" ) ]
887+ #[ tokio:: test]
888+ async fn parse_bip_21_with_invoice ( ) {
889+ assert_eq ! (
890+ PaymentInstructions :: parse(
891+ SAMPLE_BIP21_WITH_INVOICE ,
892+ Network :: Bitcoin ,
893+ DummyHrnResolver ,
894+ false ,
895+ )
896+ . await ,
897+ Err ( ParseError :: InstructionsExpired ) ,
898+ ) ;
899+ }
900+
901+ #[ cfg( not( feature = "std" ) ) ]
902+ #[ tokio:: test]
903+ async fn parse_bip_21_with_invoice_with_label ( ) {
904+ let parsed = PaymentInstructions :: parse (
905+ SAMPLE_BIP21_WITH_INVOICE_AND_LABEL ,
906+ Network :: Signet ,
907+ DummyHrnResolver ,
908+ false ,
909+ )
910+ . await
911+ . unwrap ( ) ;
912+
913+ assert_eq ! ( parsed. methods. len( ) , 2 ) ;
914+ assert_eq ! ( parsed. onchain_payment_amount( ) , Some ( Amount :: from_milli_sats( 100_000 ) ) ) ;
915+ assert_eq ! ( parsed. ln_payment_amount( ) , Some ( Amount :: from_milli_sats( 100_000 ) ) ) ;
916+ assert_eq ! ( parsed. methods[ 0 ] . amount( ) , Some ( Amount :: from_milli_sats( 100_000 ) ) ) ;
917+ assert_eq ! ( parsed. recipient_description, Some ( "yooo" . to_string( ) ) ) ;
918+ assert ! ( matches!(
919+ parsed. methods[ 0 ] . clone( ) ,
920+ PaymentMethod :: OnChain { amount: Some ( _) , .. }
921+ ) ) ;
922+ assert ! ( matches!( parsed. methods[ 1 ] . clone( ) , PaymentMethod :: LightningBolt11 ( _) ) ) ;
923+ }
924+
925+ #[ cfg( feature = "std" ) ]
926+ #[ tokio:: test]
927+ async fn parse_bip_21_with_invoice_with_label ( ) {
928+ assert_eq ! (
929+ PaymentInstructions :: parse(
930+ SAMPLE_BIP21_WITH_INVOICE_AND_LABEL ,
931+ Network :: Signet ,
932+ DummyHrnResolver ,
933+ false ,
934+ )
935+ . await ,
936+ Err ( ParseError :: InstructionsExpired ) ,
937+ ) ;
938+ }
939+ }
0 commit comments