@@ -1213,6 +1213,174 @@ async fn test_decodeoffer() {
12131213 assert ! ( decoded[ "amountMsat" ] . is_null( ) ) ;
12141214}
12151215
1216+ /// End-to-end auto-splice flow: pay → JIT channel #1 → close → on-chain funds
1217+ /// → pay again → JIT channel #2 → splice manager ticks → on-chain drained
1218+ /// into channel #2.
1219+ #[ tokio:: test( flavor = "multi_thread" ) ]
1220+ async fn test_auto_splice_after_channel_close_and_reopen ( ) {
1221+ let bitcoind = TestBitcoind :: new ( ) ;
1222+ let lsp = LspNode :: new ( & bitcoind) ;
1223+ fund_lsp ( & bitcoind, & lsp) . await ;
1224+
1225+ let server = MdkdHandle :: start ( & bitcoind, None , Some ( & lsp) , & random_mnemonic ( ) ) . await ;
1226+ let payer = PayerNode :: new ( & bitcoind) ;
1227+ setup_payer_lsp_channel ( & bitcoind, & payer, & lsp, 500_000 ) . await ;
1228+
1229+ // First payment opens JIT channel #1.
1230+ let invoice: serde_json:: Value = server
1231+ . post_form (
1232+ "/createinvoice" ,
1233+ & [
1234+ ( "amountSat" , "100000" ) ,
1235+ ( "description" , "splice setup" ) ,
1236+ ( "expirySeconds" , "3600" ) ,
1237+ ] ,
1238+ )
1239+ . await
1240+ . json ( )
1241+ . await
1242+ . unwrap ( ) ;
1243+ let invoice_str = invoice[ "serialized" ] . as_str ( ) . unwrap ( ) ;
1244+ let payment_hash = invoice[ "paymentHash" ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
1245+ payer. pay_invoice ( invoice_str) ;
1246+
1247+ let start = std:: time:: Instant :: now ( ) ;
1248+ loop {
1249+ let resp: serde_json:: Value = server
1250+ . get ( & format ! ( "/payments/incoming/{payment_hash}" ) )
1251+ . await
1252+ . json ( )
1253+ . await
1254+ . unwrap ( ) ;
1255+ if resp[ "isPaid" ] . as_bool ( ) . unwrap ( ) {
1256+ break ;
1257+ }
1258+ if start. elapsed ( ) > Duration :: from_secs ( 60 ) {
1259+ panic ! ( "Timed out waiting for first payment to settle" ) ;
1260+ }
1261+ bitcoind. mine_blocks ( 1 ) ;
1262+ tokio:: time:: sleep ( Duration :: from_secs ( 2 ) ) . await ;
1263+ }
1264+
1265+ // Close channel #1 cooperatively.
1266+ let channels: Vec < serde_json:: Value > = server. get ( "/listchannels" ) . await . json ( ) . await . unwrap ( ) ;
1267+ assert_eq ! ( channels. len( ) , 1 ) ;
1268+ let channel_id = channels[ 0 ] [ "channelId" ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
1269+ let resp = server
1270+ . post_form ( "/closechannel" , & [ ( "channelId" , & channel_id) ] )
1271+ . await ;
1272+ assert_eq ! ( resp. status( ) , 200 ) ;
1273+
1274+ let start = std:: time:: Instant :: now ( ) ;
1275+ loop {
1276+ bitcoind. mine_blocks ( 1 ) ;
1277+ tokio:: time:: sleep ( Duration :: from_secs ( 2 ) ) . await ;
1278+ let channels: Vec < serde_json:: Value > =
1279+ server. get ( "/listchannels" ) . await . json ( ) . await . unwrap ( ) ;
1280+ if channels. is_empty ( ) {
1281+ break ;
1282+ }
1283+ if start. elapsed ( ) > Duration :: from_secs ( 60 ) {
1284+ panic ! ( "Timed out waiting for channel #1 to close" ) ;
1285+ }
1286+ }
1287+
1288+ // Wait for the close output to become spendable on the server side.
1289+ bitcoind. mine_blocks ( 6 ) ;
1290+ let start = std:: time:: Instant :: now ( ) ;
1291+ let onchain_after_close = loop {
1292+ let balance: serde_json:: Value = server. get ( "/getbalance" ) . await . json ( ) . await . unwrap ( ) ;
1293+ let onchain = balance[ "onchainBalanceSat" ] . as_u64 ( ) . unwrap ( ) ;
1294+ if onchain > 0 {
1295+ break onchain;
1296+ }
1297+ if start. elapsed ( ) > Duration :: from_secs ( 30 ) {
1298+ panic ! ( "Timed out waiting for spendable on-chain balance: {balance}" ) ;
1299+ }
1300+ bitcoind. mine_blocks ( 1 ) ;
1301+ tokio:: time:: sleep ( Duration :: from_secs ( 1 ) ) . await ;
1302+ } ;
1303+ assert ! (
1304+ onchain_after_close > 50_000 ,
1305+ "Expected meaningful on-chain balance after close, got {onchain_after_close}"
1306+ ) ;
1307+
1308+ // Second payment opens JIT channel #2 — the splice target.
1309+ let invoice: serde_json:: Value = server
1310+ . post_form (
1311+ "/createinvoice" ,
1312+ & [
1313+ ( "amountSat" , "100000" ) ,
1314+ ( "description" , "splice trigger" ) ,
1315+ ( "expirySeconds" , "3600" ) ,
1316+ ] ,
1317+ )
1318+ . await
1319+ . json ( )
1320+ . await
1321+ . unwrap ( ) ;
1322+ let invoice_str = invoice[ "serialized" ] . as_str ( ) . unwrap ( ) ;
1323+ let payment_hash = invoice[ "paymentHash" ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
1324+ payer. pay_invoice ( invoice_str) ;
1325+
1326+ let start = std:: time:: Instant :: now ( ) ;
1327+ loop {
1328+ let resp: serde_json:: Value = server
1329+ . get ( & format ! ( "/payments/incoming/{payment_hash}" ) )
1330+ . await
1331+ . json ( )
1332+ . await
1333+ . unwrap ( ) ;
1334+ if resp[ "isPaid" ] . as_bool ( ) . unwrap ( ) {
1335+ break ;
1336+ }
1337+ if start. elapsed ( ) > Duration :: from_secs ( 60 ) {
1338+ panic ! ( "Timed out waiting for second JIT payment to settle" ) ;
1339+ }
1340+ bitcoind. mine_blocks ( 1 ) ;
1341+ tokio:: time:: sleep ( Duration :: from_secs ( 2 ) ) . await ;
1342+ }
1343+
1344+ // Splice manager polls every 1s (test config). Rather than racing a
1345+ // pre-splice capacity snapshot, assert conservation across two
1346+ // observations: on-chain drops AND channel capacity grew by at least
1347+ // that delta. Together they pin the funds to the channel without
1348+ // depending on snapshot timing. Mine blocks while waiting so the
1349+ // splice tx confirms and the LSP returns splice_locked (client-
1350+ // initiated splices are not 0-conf).
1351+ let splice_threshold = onchain_after_close / 10 ;
1352+ let start = std:: time:: Instant :: now ( ) ;
1353+ let onchain_after_splice = loop {
1354+ bitcoind. mine_blocks ( 1 ) ;
1355+ tokio:: time:: sleep ( Duration :: from_secs ( 1 ) ) . await ;
1356+ let balance: serde_json:: Value = server. get ( "/getbalance" ) . await . json ( ) . await . unwrap ( ) ;
1357+ let onchain = balance[ "onchainBalanceSat" ] . as_u64 ( ) . unwrap ( ) ;
1358+ if onchain < splice_threshold {
1359+ break onchain;
1360+ }
1361+ if start. elapsed ( ) > Duration :: from_secs ( 120 ) {
1362+ let channels: serde_json:: Value =
1363+ server. get ( "/listchannels" ) . await . json ( ) . await . unwrap ( ) ;
1364+ panic ! (
1365+ "Auto-splice did not consume on-chain balance. \
1366+ onchain_after_close={onchain_after_close}, balance={balance}, \
1367+ channels={channels}"
1368+ ) ;
1369+ }
1370+ } ;
1371+
1372+ // Sanity: the channel absorbed at least the consumed on-chain funds.
1373+ let channels: Vec < serde_json:: Value > = server. get ( "/listchannels" ) . await . json ( ) . await . unwrap ( ) ;
1374+ assert_eq ! ( channels. len( ) , 1 ) ;
1375+ let post_splice_capacity = channels[ 0 ] [ "capacitySat" ] . as_u64 ( ) . unwrap ( ) ;
1376+ let consumed_onchain = onchain_after_close - onchain_after_splice;
1377+ assert ! (
1378+ post_splice_capacity > consumed_onchain,
1379+ "Expected channel capacity to absorb the spliced funds. \
1380+ capacity={post_splice_capacity}, consumed_onchain={consumed_onchain}"
1381+ ) ;
1382+ }
1383+
12161384#[ tokio:: test( flavor = "multi_thread" ) ]
12171385async fn test_decodeoffer_invalid ( ) {
12181386 let bitcoind = TestBitcoind :: new ( ) ;
0 commit comments