Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions contrib/msggen/msggen/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions contrib/pyln-client/pyln/client/lightning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions doc/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Core Lightning Documentation
splice_init <splice_init.7.md>
splice_signed <splice_signed.7.md>
splice_update <splice_update.7.md>
spliceout <spliceout.7.md>
sql <sql.7.md>
staticbackup <staticbackup.7.md>
stop <stop.7.md>
Expand Down
85 changes: 85 additions & 0 deletions doc/schemas/spliceout.json
Original file line number Diff line number Diff line change
@@ -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)"
]
}
53 changes: 53 additions & 0 deletions plugins/spender/splice.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
104 changes: 104 additions & 0 deletions tests/test_splice.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,107 @@ 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.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
Loading