Skip to content

Commit 9cc79e9

Browse files
authored
Feat: Enable 1-sat HTLCs for virtual channels (#75)
* update virtual-channel dust limits * Update rust-lightning
1 parent 80ed938 commit 9cc79e9

8 files changed

Lines changed: 226 additions & 34 deletions

File tree

src/core_types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub(crate) const UTXO_SIZE_SAT: u32 = 32000;
88
pub(crate) const MIN_CHANNEL_CONFIRMATIONS: u8 = 6;
99
pub(crate) const DUST_LIMIT_MSAT: u64 = 546000;
1010
pub(crate) const HTLC_MIN_MSAT: u64 = 3_000_000;
11+
pub(crate) const VIRTUAL_HTLC_MIN_MSAT: u64 = 1_000;
1112
pub(crate) const MAX_SWAP_FEE_MSAT: u64 = HTLC_MIN_MSAT;
1213
pub(crate) const DEFAULT_FINAL_CLTV_EXPIRY_DELTA: u32 = 14;
1314

src/ldk.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ use crate::bitcoind::BitcoindClient;
104104
use crate::chain_backend::ChainBackend;
105105
use crate::core_types::{
106106
HTLCStatus, NodeKeySource, SwapStatus, UnlockRequest, DUST_LIMIT_MSAT, FEE_RATE, HTLC_MIN_MSAT,
107-
MIN_CHANNEL_CONFIRMATIONS,
107+
MIN_CHANNEL_CONFIRMATIONS, VIRTUAL_HTLC_MIN_MSAT,
108108
};
109109
use crate::database::RlnDatabase;
110110
use crate::disk::{self, FilesystemLogger};
@@ -1162,9 +1162,16 @@ impl AsyncOrderInvoiceProvider for AsyncOrderRecipientInvoiceProvider {
11621162
) -> Result<AsyncOrderOutboundInvoiceResultWire, JsonRpcErrorWire> {
11631163
let hash_index = Self::parse_u64_field(&params.hash_index, "hash_index")?;
11641164
let amount_msat = params.amount_msat;
1165-
if amount_msat < HTLC_MIN_MSAT {
1165+
let htlc_min_msat = if self.channel_manager.list_channels().iter().any(|channel| {
1166+
channel.counterparty.node_id == sender_node_id && channel.trusted_no_broadcast
1167+
}) {
1168+
VIRTUAL_HTLC_MIN_MSAT
1169+
} else {
1170+
HTLC_MIN_MSAT
1171+
};
1172+
if amount_msat < htlc_min_msat {
11661173
return Err(JsonRpcErrorWire::invalid_params(format!(
1167-
"amt_msat cannot be less than {HTLC_MIN_MSAT}"
1174+
"amt_msat cannot be less than {htlc_min_msat}"
11681175
)));
11691176
}
11701177
if matches!(params.asset_amount, Some(0)) {

src/routes.rs

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ use crate::{
101101
core_types::{
102102
HTLCStatus, SwapStatus, UnlockRequest as CoreUnlockRequest,
103103
DEFAULT_FINAL_CLTV_EXPIRY_DELTA, DUST_LIMIT_MSAT, FEE_RATE, HTLC_MIN_MSAT,
104-
MAX_SWAP_FEE_MSAT, MIN_CHANNEL_CONFIRMATIONS, UTXO_SIZE_SAT,
104+
MAX_SWAP_FEE_MSAT, MIN_CHANNEL_CONFIRMATIONS, UTXO_SIZE_SAT, VIRTUAL_HTLC_MIN_MSAT,
105105
},
106106
rgb::{check_rgb_proxy_endpoint, get_rgb_channel_info_optional},
107107
};
@@ -121,8 +121,6 @@ const OPENCHANNEL_MAX_SAT: u64 = 16777215;
121121
const OPENCHANNEL_MIN_RGB_AMT: u64 = 1;
122122
const VIRTUAL_OPEN_MODE_TRUSTED_NO_BROADCAST: &str = "trusted_no_broadcast";
123123

124-
const INVOICE_MIN_MSAT: u64 = HTLC_MIN_MSAT;
125-
126124
#[derive(Deserialize, Serialize)]
127125
pub(crate) struct AddressResponse {
128126
pub(crate) address: String,
@@ -2853,9 +2851,10 @@ pub(crate) async fn keysend(
28532851
};
28542852

28552853
let amt_msat = payload.amt_msat;
2856-
if amt_msat < HTLC_MIN_MSAT {
2854+
let htlc_min_msat = unlocked_state.htlc_min_msat_for_peer(dest_pubkey);
2855+
if amt_msat < htlc_min_msat {
28572856
return Err(APIError::InvalidAmount(format!(
2858-
"amt_msat cannot be less than {HTLC_MIN_MSAT}"
2857+
"amt_msat cannot be less than {htlc_min_msat}"
28592858
)));
28602859
}
28612860

@@ -3472,10 +3471,15 @@ pub(crate) async fn ln_invoice(
34723471
None
34733472
};
34743473

3475-
if contract_id.is_some() && payload.amt_msat.unwrap_or(0) < INVOICE_MIN_MSAT {
3476-
return Err(APIError::InvalidAmount(format!(
3477-
"amt_msat cannot be less than {INVOICE_MIN_MSAT} when transferring an RGB asset"
3478-
)));
3474+
if let Some(contract_id) = &contract_id {
3475+
// Only lower the floor when the asset is held in a virtual channel; a regular channel
3476+
// carrying this asset keeps the standard floor.
3477+
let invoice_min_msat = unlocked_state.htlc_min_msat_for_asset(contract_id);
3478+
if payload.amt_msat.unwrap_or(0) < invoice_min_msat {
3479+
return Err(APIError::InvalidAmount(format!(
3480+
"amt_msat cannot be less than {invoice_min_msat} when transferring an RGB asset"
3481+
)));
3482+
}
34793483
}
34803484

34813485
let created_at = get_current_timestamp();
@@ -4156,7 +4160,11 @@ pub(crate) async fn open_channel(
41564160
} else {
41574161
payload.public
41584162
},
4159-
our_htlc_minimum_msat: HTLC_MIN_MSAT,
4163+
our_htlc_minimum_msat: if is_virtual_open {
4164+
VIRTUAL_HTLC_MIN_MSAT
4165+
} else {
4166+
HTLC_MIN_MSAT
4167+
},
41604168
minimum_depth: if is_virtual_open {
41614169
0
41624170
} else {
@@ -4297,6 +4305,7 @@ pub(crate) async fn open_channel(
42974305
Some(config),
42984306
consignment_endpoint,
42994307
payload.push_asset_amount,
4308+
is_virtual_open,
43004309
)
43014310
.map_err(|e| {
43024311
if let Some(temp_id_str) = rgb_metadata_temp_id_str.as_deref() {
@@ -4693,19 +4702,23 @@ pub(crate) async fn send_payment(
46934702
invoice.amount_milli_satoshis().unwrap_or(0)
46944703
};
46954704

4705+
// A trusted never-broadcast (virtual) channel to the payee allows a sub-dust asset
4706+
// HTLC; otherwise the standard floor applies.
4707+
let send_min_msat =
4708+
unlocked_state.htlc_min_msat_for_peer(invoice.recover_payee_pub_key());
46964709
let rgb_payment = match (invoice.rgb_contract_id(), invoice.rgb_amount()) {
46974710
(Some(rgb_contract_id), Some(rgb_amount)) => {
4698-
if amt_msat < INVOICE_MIN_MSAT {
4711+
if amt_msat < send_min_msat {
46994712
return Err(APIError::InvalidAmount(format!(
4700-
"amt_msat in invoice sending an RGB asset cannot be less than {INVOICE_MIN_MSAT}"
4713+
"amt_msat in invoice sending an RGB asset cannot be less than {send_min_msat}"
47014714
)));
47024715
}
47034716
Some((rgb_contract_id, rgb_amount))
47044717
},
47054718
(Some(rgb_contract_id), None) => {
4706-
if amt_msat < INVOICE_MIN_MSAT {
4719+
if amt_msat < send_min_msat {
47074720
return Err(APIError::InvalidAmount(format!(
4708-
"amt_msat in invoice sending an RGB asset cannot be less than {INVOICE_MIN_MSAT}"
4721+
"amt_msat in invoice sending an RGB asset cannot be less than {send_min_msat}"
47094722
)));
47104723
}
47114724
if let Some(asset_id) = payload.asset_id.as_ref() {

src/sdk/mod.rs

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::core_types::async_order::{
99
AsyncOrderNewRequest, AsyncOrderNewResponse, AsyncOrderOutboundInvoiceRequest,
1010
AsyncOrderOutboundInvoiceResponse,
1111
};
12-
use crate::core_types::{FEE_RATE, MIN_CHANNEL_CONFIRMATIONS};
12+
use crate::core_types::{FEE_RATE, MIN_CHANNEL_CONFIRMATIONS, VIRTUAL_HTLC_MIN_MSAT};
1313
use crate::error::APIError;
1414
use crate::ldk::{
1515
clear_rgb_payment_pending, start_ldk, write_rgb_payment_info_file, InvoiceType, PaymentInfo,
@@ -100,7 +100,6 @@ const SDK_OPENRGBCHANNEL_MIN_SAT: u64 = SDK_HTLC_MIN_MSAT / 1000 * 10 + 10;
100100
const SDK_OPENCHANNEL_MIN_SAT: u64 = 5506;
101101
const SDK_OPENCHANNEL_MAX_SAT: u64 = 16_777_215;
102102
const SDK_OPENCHANNEL_MIN_RGB_AMT: u64 = 1;
103-
const SDK_INVOICE_MIN_MSAT: u64 = SDK_HTLC_MIN_MSAT;
104103
const SDK_UTXO_NUM: u8 = 4;
105104
const SDK_UTXO_SIZE_SAT: u32 = 32_000;
106105
const SDK_DUST_LIMIT_MSAT: u64 = 546_000;
@@ -2480,9 +2479,10 @@ pub(crate) async fn keysend(
24802479
};
24812480

24822481
let amt_msat = request.amt_msat;
2483-
if amt_msat < SDK_HTLC_MIN_MSAT {
2482+
let htlc_min_msat = unlocked_state.htlc_min_msat_for_peer(dest_pubkey);
2483+
if amt_msat < htlc_min_msat {
24842484
return Err(APIError::InvalidAmount(format!(
2485-
"amt_msat cannot be less than {SDK_HTLC_MIN_MSAT}"
2485+
"amt_msat cannot be less than {htlc_min_msat}"
24862486
)));
24872487
}
24882488

@@ -2836,7 +2836,11 @@ pub(crate) async fn open_channel(
28362836
} else {
28372837
request.public
28382838
},
2839-
our_htlc_minimum_msat: SDK_HTLC_MIN_MSAT,
2839+
our_htlc_minimum_msat: if is_virtual_open {
2840+
VIRTUAL_HTLC_MIN_MSAT
2841+
} else {
2842+
SDK_HTLC_MIN_MSAT
2843+
},
28402844
minimum_depth: if is_virtual_open {
28412845
0
28422846
} else {
@@ -2958,6 +2962,7 @@ pub(crate) async fn open_channel(
29582962
Some(config),
29592963
consignment_endpoint,
29602964
request.push_asset_amount,
2965+
is_virtual_open,
29612966
)
29622967
.map_err(|e| {
29632968
if let Some(temp_id_str) = rgb_metadata_temp_id_str.as_deref() {
@@ -3095,19 +3100,21 @@ pub(crate) async fn send_payment(
30953100
invoice.amount_milli_satoshis().unwrap_or(0)
30963101
};
30973102

3103+
// A trusted never-broadcast (virtual) channel to the payee allows a sub-dust asset HTLC.
3104+
let send_min_msat = unlocked_state.htlc_min_msat_for_peer(invoice.recover_payee_pub_key());
30983105
let rgb_payment = match (invoice.rgb_contract_id(), invoice.rgb_amount()) {
30993106
(Some(rgb_contract_id), Some(rgb_amount)) => {
3100-
if amt_msat < SDK_INVOICE_MIN_MSAT {
3107+
if amt_msat < send_min_msat {
31013108
return Err(APIError::InvalidAmount(format!(
3102-
"amt_msat in invoice sending an RGB asset cannot be less than {SDK_INVOICE_MIN_MSAT}"
3109+
"amt_msat in invoice sending an RGB asset cannot be less than {send_min_msat}"
31033110
)));
31043111
}
31053112
Some((rgb_contract_id, rgb_amount))
31063113
}
31073114
(Some(rgb_contract_id), None) => {
3108-
if amt_msat < SDK_INVOICE_MIN_MSAT {
3115+
if amt_msat < send_min_msat {
31093116
return Err(APIError::InvalidAmount(format!(
3110-
"amt_msat in invoice sending an RGB asset cannot be less than {SDK_INVOICE_MIN_MSAT}"
3117+
"amt_msat in invoice sending an RGB asset cannot be less than {send_min_msat}"
31113118
)));
31123119
}
31133120
if let Some(asset_id) = request.asset_id.as_ref() {
@@ -3683,11 +3690,16 @@ pub(crate) async fn create_ln_invoice(
36833690
None
36843691
};
36853692

3686-
if contract_id.is_some() && amt_msat.unwrap_or(0) < SDK_INVOICE_MIN_MSAT {
3687-
return Err(APIError::InvalidAmount(format!(
3688-
"amt_msat cannot be less than {} when transferring an RGB asset",
3689-
SDK_INVOICE_MIN_MSAT
3690-
)));
3693+
if let Some(contract_id) = &contract_id {
3694+
// Only lower the floor when the asset is held in a virtual channel; a regular channel
3695+
// carrying this asset keeps the standard floor.
3696+
let invoice_min_msat = unlocked_state.htlc_min_msat_for_asset(contract_id);
3697+
if amt_msat.unwrap_or(0) < invoice_min_msat {
3698+
return Err(APIError::InvalidAmount(format!(
3699+
"amt_msat cannot be less than {} when transferring an RGB asset",
3700+
invoice_min_msat
3701+
)));
3702+
}
36913703
}
36923704

36933705
let created_at = get_current_timestamp();

src/test/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use tokio::io::AsyncReadExt;
3333
use tokio::net::{TcpListener, TcpStream};
3434
use tracing_test::traced_test;
3535

36-
use crate::core_types::{HTLCStatus, SwapStatus, FEE_RATE, HTLC_MIN_MSAT};
36+
use crate::core_types::{HTLCStatus, SwapStatus, FEE_RATE, HTLC_MIN_MSAT, VIRTUAL_HTLC_MIN_MSAT};
3737
use crate::disk::LDK_LOGS_FILE;
3838
use crate::error::{APIError, APIErrorResponse};
3939
use crate::kv_store::SeaOrmKvStore;

src/test/virtual_channels.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,3 +1428,137 @@ async fn virtual_close_succeeds_after_client_returns_full_btc_and_rgb() {
14281428
)
14291429
.await;
14301430
}
1431+
1432+
#[tokio::test]
1433+
#[traced_test]
1434+
#[serial_test::serial]
1435+
async fn virtual_one_sat_htlc_routes_both_directions() {
1436+
initialize();
1437+
1438+
let test_storage_root = format!("{TEST_DIR_BASE}one_sat_htlc/");
1439+
let host_node_peer_port = next_peer_port();
1440+
let client_node_peer_port = next_peer_port();
1441+
1442+
let (host_node_address, _host_node_password) = start_node_with_virtual_options(
1443+
&format!("{test_storage_root}host_node"),
1444+
host_node_peer_port,
1445+
false,
1446+
true,
1447+
vec![],
1448+
)
1449+
.await;
1450+
let host_node_info = node_info(host_node_address).await;
1451+
1452+
fund_and_create_utxos(host_node_address, None).await;
1453+
let issued_asset_id = issue_asset_nia_with_amounts(host_node_address, vec![500, 500])
1454+
.await
1455+
.asset_id;
1456+
1457+
let negative_payload = LNInvoiceRequest {
1458+
amt_msat: Some(VIRTUAL_HTLC_MIN_MSAT),
1459+
expiry_sec: 3600,
1460+
asset_id: Some(issued_asset_id.clone()),
1461+
asset_amount: Some(1),
1462+
payment_hash: None,
1463+
description_hash: None,
1464+
min_final_cltv_expiry_delta: None,
1465+
};
1466+
let negative_response = reqwest::Client::new()
1467+
.post(format!("http://{host_node_address}/lninvoice"))
1468+
.json(&negative_payload)
1469+
.send()
1470+
.await
1471+
.unwrap();
1472+
check_response_is_nok(
1473+
negative_response,
1474+
reqwest::StatusCode::BAD_REQUEST,
1475+
&format!("cannot be less than {HTLC_MIN_MSAT}"),
1476+
"InvalidAmount",
1477+
)
1478+
.await;
1479+
1480+
let (client_node_address, _client_node_password) = start_node_with_virtual_options(
1481+
&format!("{test_storage_root}client_node"),
1482+
client_node_peer_port,
1483+
false,
1484+
true,
1485+
vec![bitcoin::secp256k1::PublicKey::from_str(&host_node_info.pubkey).unwrap()],
1486+
)
1487+
.await;
1488+
let client_node_info = node_info(client_node_address).await;
1489+
1490+
let funded_rgb_amount = 100;
1491+
let opened_virtual_channel = open_virtual_channel(
1492+
host_node_address,
1493+
&client_node_info.pubkey,
1494+
Some(client_node_peer_port),
1495+
Some(100_000),
1496+
Some(10_000),
1497+
Some(funded_rgb_amount),
1498+
Some(&issued_asset_id),
1499+
None,
1500+
)
1501+
.await;
1502+
assert_eq!(
1503+
opened_virtual_channel.virtual_open_mode.as_deref(),
1504+
Some("trusted_no_broadcast")
1505+
);
1506+
assert!(opened_virtual_channel.ready);
1507+
assert!(opened_virtual_channel.is_usable);
1508+
1509+
let host_to_client_rgb_amount = 50;
1510+
let rgb_invoice_in = ln_invoice(
1511+
client_node_address,
1512+
Some(VIRTUAL_HTLC_MIN_MSAT),
1513+
Some(&issued_asset_id),
1514+
Some(host_to_client_rgb_amount),
1515+
3600,
1516+
)
1517+
.await
1518+
.invoice;
1519+
1520+
let decoded_in = Bolt11Invoice::from_str(&rgb_invoice_in).unwrap();
1521+
assert_eq!(
1522+
decoded_in.amount_milli_satoshis(),
1523+
Some(VIRTUAL_HTLC_MIN_MSAT)
1524+
);
1525+
send_payment_with_status(host_node_address, rgb_invoice_in, HTLCStatus::Succeeded).await;
1526+
wait_for_ln_balance(
1527+
host_node_address,
1528+
&issued_asset_id,
1529+
funded_rgb_amount - host_to_client_rgb_amount,
1530+
)
1531+
.await;
1532+
wait_for_ln_balance(
1533+
client_node_address,
1534+
&issued_asset_id,
1535+
host_to_client_rgb_amount,
1536+
)
1537+
.await;
1538+
1539+
let client_to_host_rgb_amount = 30;
1540+
let rgb_invoice_out = ln_invoice(
1541+
host_node_address,
1542+
Some(VIRTUAL_HTLC_MIN_MSAT),
1543+
Some(&issued_asset_id),
1544+
Some(client_to_host_rgb_amount),
1545+
3600,
1546+
)
1547+
.await
1548+
.invoice;
1549+
send_payment_with_status(client_node_address, rgb_invoice_out, HTLCStatus::Succeeded).await;
1550+
wait_for_ln_balance(
1551+
host_node_address,
1552+
&issued_asset_id,
1553+
funded_rgb_amount - host_to_client_rgb_amount + client_to_host_rgb_amount,
1554+
)
1555+
.await;
1556+
wait_for_ln_balance(
1557+
client_node_address,
1558+
&issued_asset_id,
1559+
host_to_client_rgb_amount - client_to_host_rgb_amount,
1560+
)
1561+
.await;
1562+
1563+
shutdown(&[host_node_address, client_node_address]).await;
1564+
}

0 commit comments

Comments
 (0)