From dd24980ca72f37c776e15f976ef03a92cb75441a Mon Sep 17 00:00:00 2001 From: Dusty Daemon Date: Tue, 20 Jan 2026 11:56:31 -0500 Subject: [PATCH 1/3] splice: Add test for easy spliceout command --- contrib/pyln-client/pyln/client/lightning.py | 10 ++ tests/test_splice.py | 105 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/contrib/pyln-client/pyln/client/lightning.py b/contrib/pyln-client/pyln/client/lightning.py index f656b60020b2..210d921b1abb 100644 --- a/contrib/pyln-client/pyln/client/lightning.py +++ b/contrib/pyln-client/pyln/client/lightning.py @@ -1226,6 +1226,16 @@ def splice(self, script_or_json, dryrun=None, force_feerate=None, debug_log=None } return self.call("dev-splice", payload) + def spliceout(self, channel, amount, destination=None, force_feerate=None): + """ Execute a splice out """ + payload = { + "channel": channel, + "amount": amount, + "destination": destination, + "force_feerate": force_feerate, + } + return self.call("spliceout", payload) + def stfu_channels(self, channel_ids): """ STFU multiple channels """ payload = { diff --git a/tests/test_splice.py b/tests/test_splice.py index a26c983166ad..649c8b63b958 100644 --- a/tests/test_splice.py +++ b/tests/test_splice.py @@ -195,3 +195,108 @@ def test_script_splice_in(node_factory, bitcoind, chainparams): l1.wait_for_channel_onchain(l2.info['id']) account_info = only_one([acct for acct in l1.rpc.bkpr_listbalances()['accounts'] if acct['account'] == account_id]) assert not account_info['account_closed'] + + +@pytest.mark.xfail(strict=True) +@pytest.mark.openchannel('v1') +@pytest.mark.openchannel('v2') +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +def test_easy_splice_out(node_factory, bitcoind, chainparams): + fundamt = 1000000 + + coin_mvt_plugin = Path(__file__).parent / "plugins" / "coin_movements.py" + l1, l2 = node_factory.line_graph(2, fundamount=fundamt, wait_for_announce=True, + opts={'experimental-splicing': None, + 'plugin': coin_mvt_plugin}) + + initial_wallet_balance = Millisatoshi(bkpr_account_balance(l1, 'wallet')) + + # Splice out 100k from first channel, putting result less fees into onchain wallet + spliceamt = 100000 + l1.rpc.spliceout("*:?", f"{spliceamt}", force_feerate=True) + + bitcoind.generate_block(6, wait_for_mempool=1) + l2.daemon.wait_for_log(r'lightningd, splice_locked clearing inflights') + + p1 = only_one(l1.rpc.listpeerchannels(peer_id=l2.info['id'])['channels']) + p2 = only_one(l2.rpc.listpeerchannels(l1.info['id'])['channels']) + assert 'inflight' not in p1 + assert 'inflight' not in p2 + + wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == 2) + wait_for(lambda: len(l1.rpc.listfunds()['channels']) == 1) + + end_wallet_balance = Millisatoshi(bkpr_account_balance(l1, 'wallet')) + assert initial_wallet_balance + Millisatoshi(spliceamt * 1000) == end_wallet_balance + + +@pytest.mark.xfail(strict=True) +@pytest.mark.openchannel('v1') +@pytest.mark.openchannel('v2') +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +def test_easy_splice_out_address(node_factory, bitcoind, chainparams): + fundamt = 1000000 + + coin_mvt_plugin = Path(__file__).parent / "plugins" / "coin_movements.py" + l1, l2 = node_factory.line_graph(2, fundamount=fundamt, wait_for_announce=True, + opts={'experimental-splicing': None, + 'plugin': coin_mvt_plugin}) + + initial_wallet_balance = Millisatoshi(bkpr_account_balance(l1, 'wallet')) + + addr = l1.rpc.newaddr()['p2tr'] + + # Splice out 100k from first channel, putting result less fees into onchain wallet via addres + spliceamt = 100000 + l1.rpc.spliceout("*:?", f"{spliceamt}", destination=addr, force_feerate=True) + + bitcoind.generate_block(6, wait_for_mempool=1) + l2.daemon.wait_for_log(r'lightningd, splice_locked clearing inflights') + + p1 = only_one(l1.rpc.listpeerchannels(peer_id=l2.info['id'])['channels']) + p2 = only_one(l2.rpc.listpeerchannels(l1.info['id'])['channels']) + assert 'inflight' not in p1 + assert 'inflight' not in p2 + + wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == 2) + wait_for(lambda: len(l1.rpc.listfunds()['channels']) == 1) + + end_wallet_balance = Millisatoshi(bkpr_account_balance(l1, 'wallet')) + assert initial_wallet_balance + Millisatoshi(spliceamt * 1000) == end_wallet_balance + + +@pytest.mark.xfail(strict=True) +@pytest.mark.openchannel('v1') +@pytest.mark.openchannel('v2') +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +def test_easy_splice_out_into_channel(node_factory, bitcoind, chainparams): + fundamt = 1000000 + + coin_mvt_plugin = Path(__file__).parent / "plugins" / "coin_movements.py" + l1, l2, l3 = node_factory.line_graph(3, fundamount=fundamt, wait_for_announce=True, + opts={'experimental-splicing': None, + 'plugin': coin_mvt_plugin}) + + chan1 = first_channel_id(l1, l2) + chan2 = first_channel_id(l2, l3) + + initial_chan1_balance = Millisatoshi(bkpr_account_balance(l2, chan1)) + assert initial_chan1_balance == 0 + + # Splice out 100k from first channel, putting result into channel + spliceamt = 100000 + l2.rpc.spliceout(f"{chan2}", f"{spliceamt}", destination=chan1, force_feerate=True) + + bitcoind.generate_block(6, wait_for_mempool=1) + l2.daemon.wait_for_log(r'lightningd, splice_locked clearing inflights') + + p1 = only_one(l1.rpc.listpeerchannels()) + p2 = only_one(l3.rpc.listpeerchannels()) + assert 'inflight' not in p1 + assert 'inflight' not in p2 + + wait_for(lambda: len(l2.rpc.listfunds()['outputs']) == 1) + wait_for(lambda: len(l2.rpc.listfunds()['channels']) == 2) + + end_chan1_balance = Millisatoshi(bkpr_account_balance(l2, chan1)) + assert initial_chan1_balance + Millisatoshi(spliceamt * 1000) == end_chan1_balance From 3c897af550bcf525e869bb3b641f5decde90db8d Mon Sep 17 00:00:00 2001 From: Dusty Daemon Date: Tue, 20 Jan 2026 11:58:50 -0500 Subject: [PATCH 2/3] splice: Add docs for easy spliceout command --- contrib/msggen/msggen/schema.json | 85 +++++++++++++++++++++++++++++++ doc/Makefile | 1 + doc/index.rst | 1 + doc/schemas/spliceout.json | 85 +++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 doc/schemas/spliceout.json diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json index ca068e2fb5e7..084aba8caafe 100644 --- a/contrib/msggen/msggen/schema.json +++ b/contrib/msggen/msggen/schema.json @@ -33917,6 +33917,91 @@ } ] }, + "spliceout.json": { + "$schema": "../rpc-schema-draft.json", + "type": "object", + "additionalProperties": false, + "added": "v26.04", + "rpc": "spliceout", + "title": "Command to splice funds out of a channel", + "warning": "experimental-splicing only", + "description": [ + "`spliceout` is the command to move funds into a channel." + ], + "request": { + "required": [ + "channel", + "amount" + ], + "properties": { + "channel": { + "type": "string", + "description": [ + "channel identifier or channel query. Format is the same as is used in `dev-splice`." + ] + }, + "amount": { + "type": "string", + "description": [ + "Amount in satoshis taken from the channel. Format is the same as is used in `dev-splice`." + ] + }, + "destination": { + "type": "string", + "description": [ + "Where to send the funds to. Defaults to `wallet` which sends the funds to your onchain wallet. Specify a bitcoin address to send funds to that address or specify a channel identifier to send funds to another channel. Format is the same as is used in `dev-splice`." + ] + }, + "force_feerate": { + "type": "boolean", + "description": [ + "By default splices will fail if the fee provided looks too high. This is to protect against accidentally setting your fee higher than intended. Set `force_feerate` to true to skip this saftey check" + ] + } + } + }, + "response": { + "required": [], + "properties": { + "psbt": { + "type": "string", + "description": [ + "The final psbt" + ] + }, + "tx": { + "type": "string", + "description": [ + "The final transaction in hex" + ] + }, + "txid": { + "type": "string", + "description": [ + "The txid of the final transaction" + ] + } + } + }, + "usage": [ + "`spliceout` is the command take funds from a channel. It takes `amount` funds from the specified `channel` and puts them somewhere.", + "", + "The default destination is your onchain wallet.", + "By specifying the `destination` as a bitcoin address, the funds will be sent to the specified address", + "By specifying the `destination` as a channel identifier, the funds will be sent to the specified channel. This accomplishes a simple \"cross-splice\".", + "", + "The fee for the transaction will be taken from channel funds." + ], + "author": [ + "Dusty [@dusty_daemon](mailto:@dustydaemon) is mainly responsible." + ], + "see_also": [ + "lightning-dev-splice(7)" + ], + "resources": [ + "Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)" + ] + }, "sql-template.json": { "$schema": "../rpc-schema-draft.json", "type": "object", diff --git a/doc/Makefile b/doc/Makefile index e99c3ded0808..c989c286c88e 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -138,6 +138,7 @@ MARKDOWNPAGES := doc/addgossip.7 \ doc/splice_init.7 \ doc/splice_signed.7 \ doc/splice_update.7 \ + doc/spliceout.7 \ doc/staticbackup.7 \ doc/stop.7 \ doc/txdiscard.7 \ diff --git a/doc/index.rst b/doc/index.rst index 3a495cfe57ae..07ff5cb792a5 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -154,6 +154,7 @@ Core Lightning Documentation splice_init splice_signed splice_update + spliceout sql staticbackup stop diff --git a/doc/schemas/spliceout.json b/doc/schemas/spliceout.json new file mode 100644 index 000000000000..8ffc1498220a --- /dev/null +++ b/doc/schemas/spliceout.json @@ -0,0 +1,85 @@ +{ + "$schema": "../rpc-schema-draft.json", + "type": "object", + "additionalProperties": false, + "added": "v26.04", + "rpc": "spliceout", + "title": "Command to splice funds out of a channel", + "warning": "experimental-splicing only", + "description": [ + "`spliceout` is the command to move funds into a channel." + ], + "request": { + "required": [ + "channel", + "amount" + ], + "properties": { + "channel": { + "type": "string", + "description": [ + "channel identifier or channel query. Format is the same as is used in `dev-splice`." + ] + }, + "amount": { + "type": "string", + "description": [ + "Amount in satoshis taken from the channel. Format is the same as is used in `dev-splice`." + ] + }, + "destination": { + "type": "string", + "description": [ + "Where to send the funds to. Defaults to `wallet` which sends the funds to your onchain wallet. Specify a bitcoin address to send funds to that address or specify a channel identifier to send funds to another channel. Format is the same as is used in `dev-splice`." + ] + }, + "force_feerate": { + "type": "boolean", + "description": [ + "By default splices will fail if the fee provided looks too high. This is to protect against accidentally setting your fee higher than intended. Set `force_feerate` to true to skip this saftey check" + ] + } + } + }, + "response": { + "required": [], + "properties": { + "psbt": { + "type": "string", + "description": [ + "The final psbt" + ] + }, + "tx": { + "type": "string", + "description": [ + "The final transaction in hex" + ] + }, + "txid": { + "type": "string", + "description": [ + "The txid of the final transaction" + ] + } + } + }, + "usage": [ + "`spliceout` is the command take funds from a channel. It takes `amount` funds from the specified `channel` and puts them somewhere.", + "", + "The default destination is your onchain wallet.", + "By specifying the `destination` as a bitcoin address, the funds will be sent to the specified address", + "By specifying the `destination` as a channel identifier, the funds will be sent to the specified channel. This accomplishes a simple \"cross-splice\".", + "", + "The fee for the transaction will be taken from channel funds." + ], + "author": [ + "Dusty [@dusty_daemon](mailto:@dustydaemon) is mainly responsible." + ], + "see_also": [ + "lightning-dev-splice(7)" + ], + "resources": [ + "Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)" + ] +} From 698fcc3831a5b75d5a878ea32e199c06a5b07e4e Mon Sep 17 00:00:00 2001 From: Dusty Daemon Date: Tue, 20 Jan 2026 11:59:50 -0500 Subject: [PATCH 3/3] splice: Add easy spliceout command Adds a RPC command for easily splicing out. Built on top of the splice script framework. Changelog-Added: New command `spliceout` for easily splicing out of channels --- plugins/spender/splice.c | 53 ++++++++++++++++++++++++++++++++++++++++ tests/test_splice.py | 1 - 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/plugins/spender/splice.c b/plugins/spender/splice.c index 35098c0c7f31..1cc00d2582ff 100644 --- a/plugins/spender/splice.c +++ b/plugins/spender/splice.c @@ -1462,10 +1462,63 @@ json_splice(struct command *cmd, const char *buf, const jsmntok_t *params) return send_outreq(req); } +static struct command_result * +json_spliceout(struct command *cmd, const char *buf, const jsmntok_t *params) +{ + struct out_req *req; + const char *channel, *amount, *destination; + struct splice_cmd *splice_cmd; + bool *force_feerate; + char *script; + + if (!param(cmd, buf, params, + p_req("channel", param_string, &channel), + p_req("amount", param_string, &amount), + p_opt("destination", param_string, &destination), + p_opt_def("force_feerate", param_bool, &force_feerate, + false), + NULL)) + return command_param_failed(); + + if (!destination) + destination = "wallet"; + + script = tal_fmt(NULL, + "%s -> %s + fee; 100%% -> %s", + channel, amount, destination); + + splice_cmd = tal(cmd, struct splice_cmd); + + splice_cmd->cmd = cmd; + splice_cmd->script = tal_steal(splice_cmd, script); + splice_cmd->psbt = create_psbt(splice_cmd, 0, 0, 0); + splice_cmd->dryrun = false; + splice_cmd->wetrun = false; + splice_cmd->feerate_per_kw = 0; + splice_cmd->force_feerate = *force_feerate; + splice_cmd->wallet_inputs_to_signed = 0; + splice_cmd->fee_calculated = false; + splice_cmd->initial_funds = AMOUNT_SAT(0); + splice_cmd->emergency_sat = AMOUNT_SAT(0); + splice_cmd->debug_log = NULL; + splice_cmd->debug_counter = 0; + memset(&splice_cmd->final_txid, 0, sizeof(splice_cmd->final_txid)); + + req = jsonrpc_request_start(cmd, "listpeerchannels", + listpeerchannels_get_result, + splice_error, splice_cmd); + + return send_outreq(req); +} + const struct plugin_command splice_commands[] = { { "dev-splice", json_splice }, + { + "spliceout", + json_spliceout + }, }; const size_t num_splice_commands = ARRAY_SIZE(splice_commands); diff --git a/tests/test_splice.py b/tests/test_splice.py index 649c8b63b958..58a81e4f90c9 100644 --- a/tests/test_splice.py +++ b/tests/test_splice.py @@ -197,7 +197,6 @@ def test_script_splice_in(node_factory, bitcoind, chainparams): assert not account_info['account_closed'] -@pytest.mark.xfail(strict=True) @pytest.mark.openchannel('v1') @pytest.mark.openchannel('v2') @unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')