Skip to content

Commit dfb0deb

Browse files
committed
add test cases on cch order fee inclusion
1 parent 26c69f6 commit dfb0deb

File tree

2 files changed

+225
-10
lines changed

2 files changed

+225
-10
lines changed

crates/fiber-lib/src/cch/actor.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -656,9 +656,9 @@ impl<S: CchOrderStore> CchState<S> {
656656
..Default::default()
657657
};
658658
let add_invoice_resp = client
659-
.add_hold_invoice(req)
659+
.add_hold_invoice(req.clone())
660660
.await
661-
.map_err(|err| CchError::LndRpcError(err.to_string()))?
661+
.map_err(|err| CchError::LndRpcError(format!("{}, request: {:?}", err, req)))?
662662
.into_inner();
663663
let incoming_invoice = Bolt11Invoice::from_str(&add_invoice_resp.payment_request)?;
664664

crates/fiber-lib/src/cch/tests/actor_tests.rs

Lines changed: 223 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
361365
async 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
441451
fn 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

Comments
 (0)