commands: allow to create send-to-self transactions with 'createspend'

This commit is contained in:
Antoine Poinsot 2023-05-04 16:15:09 +02:00
parent 474e23c730
commit c6c312a011
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
4 changed files with 91 additions and 26 deletions

View File

@ -114,6 +114,11 @@ Will error if the given coins are not sufficient to cover the transaction cost a
the given feerate. If on the contrary the transaction is more than sufficiently funded, it will
create a change output when economically rationale to do so.
You can create a send-to-self transaction by not specifying any destination. This command will
create a single change output. This may be useful to "refresh" coins whose timelocked recovery path
may be close to expiry without having to bear the complexity of computing the correct amount for the
change output.
This command will refuse to create any output worth less than 5k sats.
#### Request

View File

@ -46,7 +46,6 @@ const MAINNET_GENESIS_TIME: u32 = 1231006505;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
NoOutpoint,
NoDestination,
InvalidFeerate(/* sats/vb */ u64),
UnknownOutpoint(bitcoin::OutPoint),
AlreadySpent(bitcoin::OutPoint),
@ -54,7 +53,7 @@ pub enum CommandError {
InvalidOutputValue(bitcoin::Amount),
InsufficientFunds(
/* in value */ bitcoin::Amount,
/* out value */ bitcoin::Amount,
/* out value */ Option<bitcoin::Amount>,
/* target feerate */ u64,
),
InsaneFees(InsaneFeeInfo),
@ -75,7 +74,6 @@ impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::NoOutpoint => write!(f, "No provided outpoint. Need at least one."),
Self::NoDestination => write!(f, "No provided destination. Need at least one."),
Self::InvalidFeerate(sats_vb) => write!(f, "Invalid feerate: {} sats/vb.", sats_vb),
Self::AlreadySpent(op) => write!(f, "Coin at '{}' is already spent.", op),
Self::UnknownOutpoint(op) => write!(f, "Unknown outpoint '{}'.", op),
@ -85,11 +83,19 @@ impl fmt::Display for CommandError {
addr, expected, addr.network
),
Self::InvalidOutputValue(amount) => write!(f, "Invalid output value '{}'.", amount),
Self::InsufficientFunds(in_val, out_val, feerate) => write!(
f,
"Cannot create a {} sat/vb transaction with input value {} and output value {}",
feerate, in_val, out_val
),
Self::InsufficientFunds(in_val, out_val, feerate) => if let Some(out_val) = out_val {
write!(
f,
"Cannot create a {} sat/vb transaction with input value {} and output value {}",
feerate, in_val, out_val
)
} else {
write!(
f,
"Not enough fund to create a {} sat/vb transaction with input value {}",
feerate, in_val
)
},
Self::InsaneFees(info) => write!(
f,
"We assume transactions with a fee larger than {} sats or a feerate larger than {} sats/vb are a mistake. \
@ -161,7 +167,10 @@ fn sanity_check_psbt(
let tx = &psbt.unsigned_tx;
// Must have as many in/out in the PSBT and Bitcoin tx.
if psbt.inputs.len() != tx.input.len() || psbt.outputs.len() != tx.output.len() {
if psbt.inputs.len() != tx.input.len()
|| psbt.outputs.len() != tx.output.len()
|| tx.output.is_empty()
{
return Err(CommandError::SanityCheckFailure(psbt.clone()));
}
@ -320,12 +329,10 @@ impl DaemonControl {
coins_outpoints: &[bitcoin::OutPoint],
feerate_vb: u64,
) -> Result<CreateSpendResult, CommandError> {
let is_self_send = destinations.is_empty();
if coins_outpoints.is_empty() {
return Err(CommandError::NoOutpoint);
}
if destinations.is_empty() {
return Err(CommandError::NoDestination);
}
if feerate_vb < 1 {
return Err(CommandError::InvalidFeerate(feerate_vb));
}
@ -419,6 +426,7 @@ impl DaemonControl {
..PsbtOut::default()
});
}
assert_eq!(txouts.is_empty(), is_self_send);
// Now create the transaction, compute its fees and already sanity check if its feerate
// isn't much less than what was asked (and obviously that fees aren't negative).
@ -433,19 +441,23 @@ impl DaemonControl {
in_value
.checked_sub(out_value)
.ok_or(CommandError::InsufficientFunds(
in_value, out_value, feerate_vb,
in_value,
Some(out_value),
feerate_vb,
))?;
let nochange_feerate_vb = absolute_fee.to_sat().checked_div(nochange_vb).unwrap();
if nochange_feerate_vb.checked_mul(10).unwrap() < feerate_vb.checked_mul(9).unwrap() {
return Err(CommandError::InsufficientFunds(
in_value, out_value, feerate_vb,
in_value,
Some(out_value),
feerate_vb,
));
}
// If necessary, add a change output. The computation here is a bit convoluted: we infer
// the needed change value from the target feerate and the size of the transaction *with
// an added output* (for the change).
if nochange_feerate_vb > feerate_vb {
if is_self_send || nochange_feerate_vb > feerate_vb {
// Get the change address to create a dummy change txo.
let change_index = db_conn.change_index();
let change_desc = self
@ -487,7 +499,11 @@ impl DaemonControl {
bip32_derivation: change_desc.bip32_derivations(),
..PsbtOut::default()
});
} else if is_self_send {
return Err(CommandError::InsufficientFunds(in_value, None, feerate_vb));
}
} else if is_self_send {
return Err(CommandError::InsufficientFunds(in_value, None, feerate_vb));
}
}
@ -772,9 +788,9 @@ impl DaemonControl {
// Compute the value of the single output based on the requested feerate.
let tx_vbytes = (psbt.unsigned_tx.vsize() + sat_vb) as u64;
let absolute_fee = bitcoin::Amount::from_sat(tx_vbytes.checked_mul(feerate_vb).unwrap());
let output_value = in_value.checked_sub(absolute_fee).ok_or({
CommandError::InsufficientFunds(in_value, bitcoin::Amount::from_sat(0), feerate_vb)
})?;
let output_value = in_value
.checked_sub(absolute_fee)
.ok_or(CommandError::InsufficientFunds(in_value, None, feerate_vb))?;
psbt.unsigned_tx.output[0].value = output_value.to_sat();
sanity_check_psbt(&self.config.main_descriptor, &psbt)?;
@ -944,10 +960,6 @@ mod tests {
control.create_spend(&destinations, &[], 1),
Err(CommandError::NoOutpoint)
);
assert_eq!(
control.create_spend(&HashMap::new(), &[dummy_op], 1),
Err(CommandError::NoDestination)
);
assert_eq!(
control.create_spend(&destinations, &[dummy_op], 0),
Err(CommandError::InvalidFeerate(0))
@ -996,7 +1008,7 @@ mod tests {
control.create_spend(&destinations, &[dummy_op], 10_000),
Err(CommandError::InsufficientFunds(
bitcoin::Amount::from_sat(100_000),
bitcoin::Amount::from_sat(10_000),
Some(bitcoin::Amount::from_sat(10_000)),
10_000
))
);
@ -1005,7 +1017,7 @@ mod tests {
control.create_spend(&destinations, &[dummy_op], 1),
Err(CommandError::InsufficientFunds(
bitcoin::Amount::from_sat(100_000),
bitcoin::Amount::from_sat(100_001),
Some(bitcoin::Amount::from_sat(100_001)),
1
))
);

View File

@ -152,7 +152,6 @@ impl From<commands::CommandError> for Error {
fn from(e: commands::CommandError) -> Error {
match e {
commands::CommandError::NoOutpoint
| commands::CommandError::NoDestination
| commands::CommandError::UnknownOutpoint(..)
| commands::CommandError::InvalidFeerate(..)
| commands::CommandError::AlreadySpent(..)

View File

@ -1,6 +1,6 @@
from fixtures import *
from test_framework.serializations import PSBT
from test_framework.utils import wait_for, COIN
from test_framework.utils import wait_for, COIN, RpcError
def test_spend_change(lianad, bitcoind):
@ -170,3 +170,52 @@ def test_coin_marked_spent(lianad, bitcoind):
return True
wait_for(lambda: all(is_spent(c) for c in deposited_coins()))
def test_send_to_self(lianad, bitcoind):
"""Test we can use createspend with no destination to send to a change address."""
# Get 3 coins.
destinations = {
lianad.rpc.getnewaddress()["address"]: 0.03,
lianad.rpc.getnewaddress()["address"]: 0.04,
lianad.rpc.getnewaddress()["address"]: 0.05,
}
deposit_txid = bitcoind.rpc.sendmany("", destinations)
bitcoind.generate_block(1, wait_for_mempool=deposit_txid)
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3)
# Then create a send-to-self transaction (by not providing any destination) that
# sweeps them all.
outpoints = [c["outpoint"] for c in lianad.rpc.listcoins()["coins"]]
specified_feerate = 142
res = lianad.rpc.createspend({}, outpoints, specified_feerate)
spend_psbt = PSBT.from_base64(res["psbt"])
assert len(spend_psbt.o) == len(spend_psbt.tx.vout) == 1
# Note they may ask for an impossible send-to-self. In this case we'll error cleanly.
with pytest.raises(
RpcError,
match="Not enough fund to create a 40500 sat/vb transaction with input value 0.12 BTC",
):
lianad.rpc.createspend({}, outpoints, 40500)
# Sign and broadcast the send-to-self transaction created above.
signed_psbt = lianad.signer.sign_psbt(spend_psbt)
lianad.rpc.updatespend(signed_psbt.to_base64())
spend_txid = signed_psbt.tx.txid().hex()
lianad.rpc.broadcastspend(spend_txid)
# The only output is the change output so the feerate of the transaction must
# not be lower than the one provided, and only possibly slightly higher (since
# we slightly overestimate the satisfaction size).
# FIXME: a 15% increase is huge.
res = bitcoind.rpc.getmempoolentry(spend_txid)
spend_feerate = int(res["fees"]["base"] * COIN / res["vsize"])
assert specified_feerate <= spend_feerate <= int(specified_feerate * 115 / 100)
# We should by now only have one coin.
bitcoind.generate_block(1, wait_for_mempool=spend_txid)
unspent_coins = lambda: (
c for c in lianad.rpc.listcoins()["coins"] if c["spend_info"] is None
)
wait_for(lambda: len(list(unspent_coins())) == 1)