Skip to content

Commit 8f021f3

Browse files
committed
Add end-to-end SIP integration test on regtest
Exercise the full swap-in-potentiam lifecycle through the public Node API on regtest: configure SIP via the Builder, generate a deposit address, fund it via bitcoind, register and confirm the UTXO, advance past the CSV expiry, build and broadcast the refund transaction, and verify Bitcoin Core accepts it. This proves the P2WSH scripts and refund spending path are cryptographically correct end-to-end. Co-Authored-By: HAL 9000
1 parent c5772f3 commit 8f021f3

File tree

5 files changed

+305
-32
lines changed

5 files changed

+305
-32
lines changed

src/builder.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -512,15 +512,19 @@ impl NodeBuilder {
512512
/// Configures the [`Node`] instance to use swap-in-potentiam with the given LSP.
513513
///
514514
/// SIP allows the node to receive on-chain funds at a shared address and instantly swap them
515-
/// into a Lightning channel. The LSP at `node_id` must support the SIP protocol.
515+
/// into a Lightning channel. The LSP's node ID is used as the server public key for SIP
516+
/// address construction, following the original protocol design.
517+
///
518+
/// The `csv_delay` is the relative timelock (in blocks) for the refund spending path.
516519
pub fn set_sip_lsp(
517-
&mut self, node_id: PublicKey, address: SocketAddress,
520+
&mut self, node_id: PublicKey, address: SocketAddress, csv_delay: u16,
518521
) -> &mut Self {
519522
self.config.trusted_peers_0conf.push(node_id.clone());
520523

521524
let liquidity_source_config =
522525
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
523-
liquidity_source_config.sip_client = Some(SIPClientConfig { node_id, address });
526+
liquidity_source_config.sip_client =
527+
Some(SIPClientConfig { node_id, address, csv_delay });
524528
self
525529
}
526530

@@ -1868,7 +1872,7 @@ fn build_with_store_internal(
18681872
});
18691873

18701874
lsc.sip_client.as_ref().map(|config| {
1871-
liquidity_source_builder.sip_client(config.node_id, config.address.clone())
1875+
liquidity_source_builder.sip_client(config.node_id, config.address.clone(), config.csv_delay)
18721876
});
18731877

18741878
lsc.sip_service.as_ref().map(|config| {
@@ -2016,6 +2020,19 @@ fn build_with_store_internal(
20162020
_leak_checker.0.push(Arc::downgrade(&wallet) as Weak<dyn Any + Send + Sync>);
20172021
}
20182022

2023+
let sip_manager = liquidity_source_config
2024+
.as_ref()
2025+
.and_then(|lsc| lsc.sip_client.as_ref())
2026+
.map(|sip_config| {
2027+
Arc::new(crate::sip::SipManager::new(
2028+
xprv,
2029+
sip_config.node_id,
2030+
sip_config.csv_delay,
2031+
config.network,
2032+
Arc::clone(&logger),
2033+
))
2034+
});
2035+
20192036
Ok(Node {
20202037
runtime,
20212038
stop_sender,
@@ -2037,7 +2054,7 @@ fn build_with_store_internal(
20372054
gossip_source,
20382055
pathfinding_scores_sync_url,
20392056
liquidity_source,
2040-
sip_manager: None,
2057+
sip_manager,
20412058
kv_store,
20422059
logger,
20432060
_router: router,

src/lib.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ use bitcoin::secp256k1::PublicKey;
122122
pub use bitcoin::FeeRate;
123123
#[cfg(not(feature = "uniffi"))]
124124
use bitcoin::FeeRate;
125-
use bitcoin::{Address, Amount};
125+
use bitcoin::{Address, Amount, OutPoint, ScriptBuf, Transaction, Txid};
126126
#[cfg(feature = "uniffi")]
127127
pub use builder::ArcedNodeBuilder as Builder;
128128
pub use builder::BuildError;
@@ -1959,6 +1959,57 @@ impl Node {
19591959
Ok(sip.list_utxos())
19601960
}
19611961

1962+
/// Registers a newly discovered SIP UTXO.
1963+
///
1964+
/// Call this when a deposit to a SIP address is detected on-chain. In production this
1965+
/// would be driven by chain sync; for testing it can be called manually.
1966+
pub fn register_sip_utxo(
1967+
&self, outpoint: OutPoint, value: Amount, address_index: u32,
1968+
prevtx: Transaction,
1969+
) -> Result<(), Error> {
1970+
let sip = self.sip_manager.as_ref().ok_or(Error::LiquiditySourceUnavailable)?;
1971+
sip.wallet().register_utxo(outpoint, value, address_index, prevtx);
1972+
Ok(())
1973+
}
1974+
1975+
/// Marks a SIP UTXO as confirmed at the given block height.
1976+
pub fn confirm_sip_utxo(
1977+
&self, outpoint: &OutPoint, confirmed_at_height: u32,
1978+
) -> Result<(), Error> {
1979+
let sip = self.sip_manager.as_ref().ok_or(Error::LiquiditySourceUnavailable)?;
1980+
sip.wallet().confirm_utxo(outpoint, confirmed_at_height);
1981+
Ok(())
1982+
}
1983+
1984+
/// Updates SIP UTXO states based on the current chain tip, transitioning confirmed UTXOs
1985+
/// to expired once their CSV timelock has elapsed.
1986+
pub fn update_sip_on_new_block(&self, current_height: u32) -> Result<(), Error> {
1987+
let sip = self.sip_manager.as_ref().ok_or(Error::LiquiditySourceUnavailable)?;
1988+
sip.update_on_new_block(current_height);
1989+
Ok(())
1990+
}
1991+
1992+
/// Builds a signed refund transaction sweeping all CSV-expired SIP UTXOs to the given
1993+
/// destination.
1994+
///
1995+
/// Returns the signed transaction and the swept outpoints, or `None` if no UTXOs are
1996+
/// eligible for refund. The caller is responsible for broadcasting the transaction.
1997+
pub fn build_sip_refund_transaction(
1998+
&self, destination: ScriptBuf, fee_rate: FeeRate,
1999+
) -> Result<Option<(Transaction, Vec<OutPoint>)>, Error> {
2000+
let sip = self.sip_manager.as_ref().ok_or(Error::LiquiditySourceUnavailable)?;
2001+
Ok(sip.wallet().build_refund_transaction(destination, fee_rate))
2002+
}
2003+
2004+
/// Marks a SIP UTXO as refunded after the refund transaction has been broadcast.
2005+
pub fn mark_sip_refunded(
2006+
&self, outpoint: &OutPoint, spending_txid: Txid,
2007+
) -> Result<(), Error> {
2008+
let sip = self.sip_manager.as_ref().ok_or(Error::LiquiditySourceUnavailable)?;
2009+
sip.wallet().mark_refunded(outpoint, spending_txid);
2010+
Ok(())
2011+
}
2012+
19622013
/// Retrieves all payments that match the given predicate.
19632014
///
19642015
/// For example, you could retrieve all stored outbound payments as follows:

src/liquidity.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,13 @@ pub struct LSPS2ServiceConfig {
152152
}
153153

154154
/// Client-side configuration for connecting to an LSP's SIP service.
155+
///
156+
/// The LSP's `node_id` doubles as the server public key for SIP address construction.
155157
#[derive(Debug, Clone)]
156158
pub(crate) struct SIPClientConfig {
157159
pub node_id: PublicKey,
158160
pub address: SocketAddress,
161+
pub csv_delay: u16,
159162
}
160163

161164
/// Service-side configuration for offering a SIP service.
@@ -261,9 +264,10 @@ where
261264
}
262265

263266
pub(crate) fn sip_client(
264-
&mut self, lsp_node_id: PublicKey, lsp_address: SocketAddress,
267+
&mut self, lsp_node_id: PublicKey, lsp_address: SocketAddress, csv_delay: u16,
265268
) -> &mut Self {
266-
self.sip_client = Some(SIPClientConfig { node_id: lsp_node_id, address: lsp_address });
269+
self.sip_client =
270+
Some(SIPClientConfig { node_id: lsp_node_id, address: lsp_address, csv_delay });
267271
self
268272
}
269273

src/sip/wallet.rs

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const SIP_HARDENED_CHILD_INDEX: u32 = 787;
3131

3232
/// Information about a generated SIP address.
3333
#[derive(Debug, Clone)]
34-
pub(crate) struct SipAddressInfo {
34+
pub struct SipAddressInfo {
3535
/// The BIP32 derivation index.
3636
pub index: u32,
3737
/// The derived user public key.
@@ -45,7 +45,7 @@ pub(crate) struct SipAddressInfo {
4545
/// Key derivation uses BIP32: user keys are derived at `m/787'/<index>` from the node's
4646
/// master key. The server (LSP) public key and CSV delay are obtained during the initial
4747
/// SIP protocol exchange and remain fixed for the lifetime of the wallet.
48-
pub(crate) struct SipWallet {
48+
pub struct SipWallet {
4949
/// BIP32 extended private key for deriving user keys.
5050
user_xpriv: Xpriv,
5151
/// The server (LSP) public key used in all SIP addresses.
@@ -68,7 +68,7 @@ impl SipWallet {
6868
///
6969
/// The `master_xpriv` should be the node's master extended private key. SIP user keys are
7070
/// derived from it at `m/787'/<index>`.
71-
pub(crate) fn new(
71+
pub fn new(
7272
master_xpriv: Xpriv, server_pubkey: PublicKey, csv_delay: u16, network: Network,
7373
logger: Arc<Logger>,
7474
) -> Self {
@@ -103,25 +103,25 @@ impl SipWallet {
103103
}
104104

105105
/// Derives the user public key for the given address index.
106-
pub(crate) fn derive_user_pubkey(&self, index: u32) -> PublicKey {
106+
pub fn derive_user_pubkey(&self, index: u32) -> PublicKey {
107107
let secp = Secp256k1::new();
108108
PublicKey::from_secret_key(&secp, &self.derive_user_secret_key(index))
109109
}
110110

111111
/// Returns the server (LSP) public key.
112-
pub(crate) fn server_pubkey(&self) -> PublicKey {
112+
pub fn server_pubkey(&self) -> PublicKey {
113113
self.server_pubkey
114114
}
115115

116116
/// Returns the CSV delay in blocks.
117-
pub(crate) fn csv_delay(&self) -> u16 {
117+
pub fn csv_delay(&self) -> u16 {
118118
self.csv_delay
119119
}
120120

121121
/// Generates a new SIP deposit address.
122122
///
123123
/// Each call increments the internal derivation index, producing a unique address.
124-
pub(crate) fn new_address(&self) -> SipAddressInfo {
124+
pub fn new_address(&self) -> SipAddressInfo {
125125
let index = self.next_index.fetch_add(1, Ordering::Relaxed);
126126
let user_pk = self.derive_user_pubkey(index);
127127
let witness_script =
@@ -138,7 +138,7 @@ impl SipWallet {
138138

139139
/// Returns the satisfaction weight for cooperative spends from SIP addresses managed by this
140140
/// wallet. This is constant for all addresses since they share the same script structure.
141-
pub(crate) fn cooperative_satisfaction_weight(&self) -> bitcoin::Weight {
141+
pub fn cooperative_satisfaction_weight(&self) -> bitcoin::Weight {
142142
// Use index 0 as representative -- all SIP addresses have the same script structure
143143
// and thus the same satisfaction weight.
144144
let user_pk = self.derive_user_pubkey(0);
@@ -151,7 +151,7 @@ impl SipWallet {
151151
///
152152
/// The chain source should watch these for incoming transactions. This returns the
153153
/// P2WSH scriptPubKey for each generated SIP address.
154-
pub(crate) fn script_pubkeys_to_watch(&self) -> Vec<bitcoin::ScriptBuf> {
154+
pub fn script_pubkeys_to_watch(&self) -> Vec<bitcoin::ScriptBuf> {
155155
let addresses = self.addresses.lock().unwrap();
156156
addresses
157157
.values()
@@ -169,7 +169,7 @@ impl SipWallet {
169169
/// Registers a newly discovered UTXO at a SIP address.
170170
///
171171
/// Called when the chain source discovers a deposit to one of our SIP addresses.
172-
pub(crate) fn register_utxo(
172+
pub fn register_utxo(
173173
&self, outpoint: OutPoint, value: Amount, address_index: u32, prevtx: Transaction,
174174
) {
175175
let user_pk = self.derive_user_pubkey(address_index);
@@ -197,7 +197,7 @@ impl SipWallet {
197197
}
198198

199199
/// Updates a UTXO's state to confirmed.
200-
pub(crate) fn confirm_utxo(&self, outpoint: &OutPoint, confirmed_at_height: u32) {
200+
pub fn confirm_utxo(&self, outpoint: &OutPoint, confirmed_at_height: u32) {
201201
let mut utxos = self.utxos.lock().unwrap();
202202
if let Some(utxo) = utxos.get_mut(outpoint) {
203203
if matches!(utxo.state, SipUtxoState::Unconfirmed) {
@@ -215,7 +215,7 @@ impl SipWallet {
215215
/// Updates UTXO states based on the current chain tip height.
216216
///
217217
/// Transitions confirmed UTXOs to `CsvExpired` when the relative timelock has elapsed.
218-
pub(crate) fn update_csv_expiry(&self, current_height: u32) {
218+
pub fn update_csv_expiry(&self, current_height: u32) {
219219
let mut utxos = self.utxos.lock().unwrap();
220220
for utxo in utxos.values_mut() {
221221
if utxo.csv_expired(current_height)
@@ -233,32 +233,32 @@ impl SipWallet {
233233
}
234234

235235
/// Returns all tracked SIP UTXOs that are confirmed and eligible for swapping.
236-
pub(crate) fn swappable_utxos(&self) -> Vec<SipUtxo> {
236+
pub fn swappable_utxos(&self) -> Vec<SipUtxo> {
237237
self.utxos.lock().unwrap().values().filter(|u| u.is_swappable()).cloned().collect()
238238
}
239239

240240
/// Returns all tracked SIP UTXOs whose CSV has expired and are eligible for refund.
241-
pub(crate) fn refundable_utxos(&self) -> Vec<SipUtxo> {
241+
pub fn refundable_utxos(&self) -> Vec<SipUtxo> {
242242
self.utxos.lock().unwrap().values().filter(|u| u.is_refundable()).cloned().collect()
243243
}
244244

245245
/// Returns information about all tracked SIP UTXOs for the public API.
246-
pub(crate) fn list_utxos(&self) -> Vec<SipUtxoInfo> {
246+
pub fn list_utxos(&self) -> Vec<SipUtxoInfo> {
247247
self.utxos.lock().unwrap().values().map(SipUtxoInfo::from).collect()
248248
}
249249

250250
/// Returns the total balance across all non-terminal SIP UTXOs.
251-
pub(crate) fn total_balance(&self) -> Amount {
251+
pub fn total_balance(&self) -> Amount {
252252
self.utxos.lock().unwrap().values().filter(|u| !u.is_terminal()).map(|u| u.value).sum()
253253
}
254254

255255
/// Returns the balance of confirmed, swappable SIP UTXOs.
256-
pub(crate) fn spendable_balance(&self) -> Amount {
256+
pub fn spendable_balance(&self) -> Amount {
257257
self.utxos.lock().unwrap().values().filter(|u| u.is_swappable()).map(|u| u.value).sum()
258258
}
259259

260260
/// Returns the balance of unconfirmed SIP UTXOs.
261-
pub(crate) fn pending_balance(&self) -> Amount {
261+
pub fn pending_balance(&self) -> Amount {
262262
self.utxos
263263
.lock()
264264
.unwrap()
@@ -271,18 +271,18 @@ impl SipWallet {
271271
/// Returns the user secret key for signing a cooperative or refund spend.
272272
///
273273
/// This is used by the SIP protocol handlers when constructing spending transactions.
274-
pub(crate) fn signing_key(&self, address_index: u32) -> SecretKey {
274+
pub fn signing_key(&self, address_index: u32) -> SecretKey {
275275
self.derive_user_secret_key(address_index)
276276
}
277277

278278
/// Looks up the address index for a given SIP address, if it was generated by this wallet.
279-
pub(crate) fn address_index_for(&self, address: &Address) -> Option<u32> {
279+
pub fn address_index_for(&self, address: &Address) -> Option<u32> {
280280
let addresses = self.addresses.lock().unwrap();
281281
addresses.iter().find(|(_, info)| &info.address == address).map(|(index, _)| *index)
282282
}
283283

284284
/// Marks a UTXO as swap-initiated.
285-
pub(crate) fn mark_swap_initiated(
285+
pub fn mark_swap_initiated(
286286
&self, outpoint: &OutPoint, channel_id: lightning::ln::types::ChannelId,
287287
) {
288288
let mut utxos = self.utxos.lock().unwrap();
@@ -301,7 +301,7 @@ impl SipWallet {
301301
}
302302

303303
/// Marks a UTXO as swapped (terminal state).
304-
pub(crate) fn mark_swapped(
304+
pub fn mark_swapped(
305305
&self, outpoint: &OutPoint, channel_id: lightning::ln::types::ChannelId,
306306
) {
307307
let mut utxos = self.utxos.lock().unwrap();
@@ -312,7 +312,7 @@ impl SipWallet {
312312
}
313313

314314
/// Marks a UTXO as refunded (terminal state).
315-
pub(crate) fn mark_refunded(&self, outpoint: &OutPoint, spending_txid: Txid) {
315+
pub fn mark_refunded(&self, outpoint: &OutPoint, spending_txid: Txid) {
316316
let mut utxos = self.utxos.lock().unwrap();
317317
if let Some(utxo) = utxos.get_mut(outpoint) {
318318
log_info!(self.logger, "SIP UTXO {} refunded via {}", outpoint, spending_txid);
@@ -324,7 +324,7 @@ impl SipWallet {
324324
///
325325
/// Returns the signed transaction and the outpoints being swept, or `None` if no UTXOs are
326326
/// eligible for refund.
327-
pub(crate) fn build_refund_transaction(
327+
pub fn build_refund_transaction(
328328
&self, destination: bitcoin::ScriptBuf, fee_rate: FeeRate,
329329
) -> Option<(Transaction, Vec<OutPoint>)> {
330330
let secp = Secp256k1::new();

0 commit comments

Comments
 (0)