@@ -358,7 +358,24 @@ async fn setup_test_harness() -> TestHarness {
358358 setup_test_harness_with_store ( MockCchOrderStore :: new ( ) ) . await
359359}
360360
361+ async fn setup_test_harness_with_config ( config : CchConfig ) -> TestHarness {
362+ setup_test_harness_with_config_and_store ( config, MockCchOrderStore :: new ( ) ) . await
363+ }
364+
361365async fn setup_test_harness_with_store ( store : MockCchOrderStore ) -> TestHarness {
366+ let config = CchConfig {
367+ lnd_rpc_url : "https://127.0.0.1:10009" . to_string ( ) ,
368+ wrapped_btc_type_script_args : "0x" . to_string ( ) ,
369+ min_outgoing_invoice_expiry_delta_seconds : 60 ,
370+ ..Default :: default ( )
371+ } ;
372+ setup_test_harness_with_config_and_store ( config, store) . await
373+ }
374+
375+ async fn setup_test_harness_with_config_and_store (
376+ config : CchConfig ,
377+ store : MockCchOrderStore ,
378+ ) -> TestHarness {
362379 let event_port = Arc :: new ( OutputPort :: < CchTrackingEvent > :: default ( ) ) ;
363380
364381 let mock_state = MockNetworkState {
@@ -371,13 +388,6 @@ async fn setup_test_harness_with_store(store: MockCchOrderStore) -> TestHarness
371388 . await
372389 . expect ( "spawn mock network actor" ) ;
373390
374- let config = CchConfig {
375- lnd_rpc_url : "https://127.0.0.1:10009" . to_string ( ) ,
376- wrapped_btc_type_script_args : "0x" . to_string ( ) ,
377- min_outgoing_invoice_expiry_delta_seconds : 60 ,
378- ..Default :: default ( )
379- } ;
380-
381391 let args = CchArgs {
382392 config,
383393 tracker : TaskTracker :: new ( ) ,
@@ -439,13 +449,18 @@ fn create_test_lightning_invoice_with_payment_hash(
439449
440450/// Create a test Fiber invoice for testing
441451fn create_test_fiber_invoice ( payment_hash : Hash256 ) -> CkbInvoice {
452+ create_test_fiber_invoice_with_amount ( payment_hash, 100000 )
453+ }
454+
455+ /// Create a test Fiber invoice with a specific amount
456+ fn create_test_fiber_invoice_with_amount ( payment_hash : Hash256 , amount : u128 ) -> CkbInvoice {
442457 // Create a deterministic keypair for tests
443458 let private_key = SecretKey :: from_slice ( & [ 42u8 ; 32 ] ) . unwrap ( ) ;
444459 let public_key = secp256k1:: PublicKey :: from_secret_key ( & Secp256k1 :: new ( ) , & private_key) ;
445460
446461 let mut invoice = CkbInvoice {
447462 currency : Currency :: Fibb ,
448- amount : Some ( 100000 ) ,
463+ amount : Some ( amount ) ,
449464 signature : None ,
450465 data : InvoiceData {
451466 payment_hash,
@@ -922,3 +937,203 @@ async fn test_receive_btc_rejects_ckb_invoice_without_udt() {
922937 other => panic ! ( "Expected WrappedBTCTypescriptMismatch, got {:?}" , other) ,
923938 }
924939}
940+
941+ /// Tests that receive_btc rejects an invoice where amount + fee overflows i64 in msat.
942+ /// The total_msat = (amount_sats + fee_sats) * 1000 must fit in i64.
943+ #[ tokio:: test]
944+ async fn test_receive_btc_amount_too_large ( ) {
945+ let harness = setup_test_harness ( ) . await ;
946+
947+ let ( _preimage, payment_hash) = create_valid_preimage_pair ( 170 ) ;
948+ // i64::MAX / 1000 + 1 = 9_223_372_036_854_776 sats, which makes total_msat overflow i64
949+ let large_amount: u128 = ( i64:: MAX / 1_000 ) as u128 + 1 ;
950+ let invoice = create_test_fiber_invoice_with_amount ( payment_hash, large_amount) ;
951+
952+ let result = call ! (
953+ harness. actor,
954+ CchMessage :: ReceiveBTC ,
955+ crate :: cch:: ReceiveBTC {
956+ fiber_pay_req: invoice. to_string( ) ,
957+ }
958+ )
959+ . expect ( "actor call failed" ) ;
960+
961+ assert ! ( result. is_err( ) ) ;
962+ let err = result. unwrap_err ( ) ;
963+ assert ! (
964+ matches!( err, CchError :: ReceiveBTCOrderAmountTooLarge ) ,
965+ "expected ReceiveBTCOrderAmountTooLarge, got: {:?}" ,
966+ err
967+ ) ;
968+ }
969+
970+ /// Tests that receive_btc rejects an invoice where amount_sats + fee_sats overflows u128.
971+ #[ tokio:: test]
972+ async fn test_receive_btc_amount_overflow_u128 ( ) {
973+ let harness = setup_test_harness ( ) . await ;
974+
975+ let ( _preimage, payment_hash) = create_valid_preimage_pair ( 171 ) ;
976+ // u128::MAX will cause amount_sats * fee_rate to wrap and checked_add/checked_mul to fail
977+ let invoice = create_test_fiber_invoice_with_amount ( payment_hash, u128:: MAX ) ;
978+
979+ let result = call ! (
980+ harness. actor,
981+ CchMessage :: ReceiveBTC ,
982+ crate :: cch:: ReceiveBTC {
983+ fiber_pay_req: invoice. to_string( ) ,
984+ }
985+ )
986+ . expect ( "actor call failed" ) ;
987+
988+ assert ! ( result. is_err( ) ) ;
989+ let err = result. unwrap_err ( ) ;
990+ assert ! (
991+ matches!( err, CchError :: ReceiveBTCOrderAmountTooLarge ) ,
992+ "expected ReceiveBTCOrderAmountTooLarge, got: {:?}" ,
993+ err
994+ ) ;
995+ }
996+
997+ /// Tests that the send_btc proxy Fiber invoice includes the fee in its amount.
998+ ///
999+ /// In the SendBTC flow, the hub creates a Fiber invoice (the proxy invoice) for the
1000+ /// user to pay. Its amount must be `ceil(btc_amount_msat / 1000) + fee_sats` so
1001+ /// the hub collects enough to cover the outgoing Lightning payment plus its fee.
1002+ #[ tokio:: test]
1003+ async fn test_send_btc_proxy_invoice_includes_fee ( ) {
1004+ let config = CchConfig {
1005+ lnd_rpc_url : "https://127.0.0.1:10009" . to_string ( ) ,
1006+ wrapped_btc_type_script_args : "0x" . to_string ( ) ,
1007+ min_outgoing_invoice_expiry_delta_seconds : 60 ,
1008+ base_fee_sats : 1_000 , // 1000 sat base fee to make the fee clearly visible
1009+ fee_rate_per_million_sats : 10_000 , // 1% proportional fee
1010+ ..Default :: default ( )
1011+ } ;
1012+ let harness = setup_test_harness_with_config ( config) . await ;
1013+
1014+ // The lightning invoice has 100_000_000 msat = 100_000 sats
1015+ let ( order, _preimage) = harness. create_send_btc_order_with_preimage ( ) . await . unwrap ( ) ;
1016+ let btc_amount_sats: u128 = 100_000 ; // 100_000_000 msat / 1000
1017+
1018+ // fee_sats = amount_msat * fee_rate / 1_000_000_000 + base_fee
1019+ // = 100_000_000 * 10_000 / 1_000_000_000 + 1_000
1020+ // = 1_000 + 1_000
1021+ // = 2_000
1022+ let expected_fee: u128 = 2_000 ;
1023+ assert_eq ! (
1024+ order. fee_sats, expected_fee,
1025+ "fee_sats should be calculated from rate + base"
1026+ ) ;
1027+
1028+ // The proxy invoice amount must include the fee
1029+ let expected_total = btc_amount_sats + expected_fee;
1030+ assert_eq ! (
1031+ order. amount_sats, expected_total,
1032+ "proxy invoice amount should be btc_amount + fee"
1033+ ) ;
1034+
1035+ // Verify the Fiber invoice stored in the order also has the correct amount
1036+ let fiber_invoice = match & order. incoming_invoice {
1037+ CchInvoice :: Fiber ( inv) => inv. clone ( ) ,
1038+ other => panic ! ( "expected Fiber invoice, got: {:?}" , other) ,
1039+ } ;
1040+ assert_eq ! (
1041+ fiber_invoice. amount( ) ,
1042+ Some ( expected_total) ,
1043+ "Fiber proxy invoice amount should include the fee"
1044+ ) ;
1045+ }
1046+
1047+ /// Tests that the receive_btc order correctly calculates fee_sats and total_msat
1048+ /// that would be used for the LND hold invoice.
1049+ ///
1050+ /// Note: We cannot directly test the LND hold invoice creation since it requires
1051+ /// an LND server. Instead we verify that the fee calculation and amount validation
1052+ /// pass correctly (the call fails only at LND), confirming the hold invoice would
1053+ /// be created with `value_msat = (amount_sats + fee_sats) * 1000`.
1054+ #[ tokio:: test]
1055+ async fn test_receive_btc_fee_calculation ( ) {
1056+ use crate :: ckb:: contracts:: { get_script_by_contract, Contract } ;
1057+ use crate :: fiber:: hash_algorithm:: HashAlgorithm ;
1058+ use crate :: invoice:: CkbScript ;
1059+
1060+ let config = CchConfig {
1061+ lnd_rpc_url : "https://127.0.0.1:10009" . to_string ( ) ,
1062+ wrapped_btc_type_script_args : "0x" . to_string ( ) ,
1063+ min_outgoing_invoice_expiry_delta_seconds : 60 ,
1064+ base_fee_sats : 500 ,
1065+ fee_rate_per_million_sats : 5_000 , // 0.5% proportional fee
1066+ ..Default :: default ( )
1067+ } ;
1068+ let harness = setup_test_harness_with_config ( config) . await ;
1069+
1070+ let ( _preimage, payment_hash) = create_valid_preimage_pair ( 180 ) ;
1071+ let amount_sats: u128 = 200_000 ;
1072+
1073+ // Build a Fiber invoice with the correct UDT type script and SHA256 hash algorithm
1074+ // to pass all validations before the LND call.
1075+ let wrapped_btc_type_script = get_script_by_contract ( Contract :: SimpleUDT , & [ ] ) ;
1076+ let private_key = SecretKey :: from_slice ( & [ 42u8 ; 32 ] ) . unwrap ( ) ;
1077+ let public_key = secp256k1:: PublicKey :: from_secret_key ( & Secp256k1 :: new ( ) , & private_key) ;
1078+ let mut invoice = CkbInvoice {
1079+ currency : Currency :: Fibb ,
1080+ amount : Some ( amount_sats) ,
1081+ signature : None ,
1082+ data : InvoiceData {
1083+ payment_hash,
1084+ timestamp : SystemTime :: now ( )
1085+ . duration_since ( UNIX_EPOCH )
1086+ . unwrap ( )
1087+ . as_millis ( ) ,
1088+ attrs : vec ! [
1089+ Attribute :: FinalHtlcMinimumExpiryDelta ( 12 ) ,
1090+ Attribute :: Description ( "test" . to_string( ) ) ,
1091+ Attribute :: ExpiryTime ( Duration :: from_secs( 3600 ) ) ,
1092+ Attribute :: PayeePublicKey ( public_key) ,
1093+ Attribute :: UdtScript ( CkbScript ( wrapped_btc_type_script) ) ,
1094+ Attribute :: HashAlgorithm ( HashAlgorithm :: Sha256 ) ,
1095+ ] ,
1096+ } ,
1097+ } ;
1098+ invoice
1099+ . update_signature ( |hash| Secp256k1 :: new ( ) . sign_ecdsa_recoverable ( hash, & private_key) )
1100+ . unwrap ( ) ;
1101+
1102+ // receive_btc will fail at the LND call, but all prior validations
1103+ // (amount, fee, UDT script, hash algorithm) should pass.
1104+ let result = call ! (
1105+ harness. actor,
1106+ CchMessage :: ReceiveBTC ,
1107+ crate :: cch:: ReceiveBTC {
1108+ fiber_pay_req: invoice. to_string( ) ,
1109+ }
1110+ )
1111+ . expect ( "actor call failed" ) ;
1112+
1113+ // The call should fail due to LND being unavailable, not due to amount validation.
1114+ // This confirms the fee calculation and overflow checks passed successfully,
1115+ // meaning the hold invoice would have been created with the correct total_msat.
1116+ let err = result. unwrap_err ( ) ;
1117+
1118+ // fee_sats = 200_000 * 5_000 / 1_000_000 + 500 = 1_000 + 500 = 1_500
1119+ // total_msat = (200_000 + 1_500) * 1_000 = 201_500_000
1120+ let expected_fee: u128 = 1_500 ;
1121+ let expected_total_msat: i64 = ( ( amount_sats + expected_fee) * 1_000 ) as i64 ;
1122+ assert_eq ! ( expected_total_msat, 201_500_000 ) ;
1123+
1124+ match err {
1125+ CchError :: LndRpcError ( msg) => {
1126+ assert ! (
1127+ msg. contains( & format!( "value_msat: {}" , expected_total_msat) ) ,
1128+ "hold invoice request should contain value_msat={}, got: {}" ,
1129+ expected_total_msat,
1130+ msg
1131+ ) ;
1132+ }
1133+ other => panic ! (
1134+ "expected LND connection error (no LND server), got: {:?}. \
1135+ If this is an amount error, the fee calculation may be wrong.",
1136+ other
1137+ ) ,
1138+ }
1139+ }
0 commit comments