commands: add a new create_spend command

This commit is contained in:
Antoine Poinsot 2022-09-08 18:33:51 +02:00
parent 12188f0c52
commit 9afd44061e
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
5 changed files with 457 additions and 4 deletions

7
Cargo.lock generated
View File

@ -43,6 +43,12 @@ dependencies = [
"rustc-demangle",
]
[[package]]
name = "base64"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
[[package]]
name = "base64-compat"
version = "1.0.0"
@ -234,6 +240,7 @@ name = "minisafe"
version = "0.0.1"
dependencies = [
"backtrace",
"base64",
"dirs",
"fern",
"jsonrpc",

View File

@ -53,3 +53,6 @@ jsonrpc = "0.12"
# Used for daemonization
libc = "0.2"
# Used for PSBTs
base64 = "0.13"

View File

@ -9,11 +9,161 @@ use crate::{
database::{Coin, DatabaseInterface},
descriptors, DaemonControl, VERSION,
};
use utils::{deser_amount_from_sats, ser_amount};
use utils::{deser_amount_from_sats, deser_psbt_base64, ser_amount, ser_base64};
use miniscript::bitcoin;
use std::{
collections::{BTreeMap, HashMap},
convert::TryInto,
fmt,
};
use miniscript::bitcoin::{
self,
util::bip32,
util::psbt::{self, Input as PsbtIn, Output as PsbtOut, PartiallySignedTransaction as Psbt},
};
use serde::{Deserialize, Serialize};
const WITNESS_FACTOR: usize = 4;
// We would never create a transaction with an output worth less than this.
// That's 1$ at 20_000$ per BTC.
const DUST_OUTPUT_SATS: u64 = 5_000;
// Assume that paying more than 1BTC in fee is a bug.
const MAX_FEE: u64 = bitcoin::blockdata::constants::COIN_VALUE;
// Assume that paying more than 1000sat/vb in feerate is a bug.
const MAX_FEERATE: u64 = bitcoin::blockdata::constants::COIN_VALUE;
#[derive(Debug, Clone, PartialEq)]
pub enum CommandError {
NoOutpoint,
NoDestination,
InvalidFeerate(/* sats/vb */ u64),
UnknownOutpoint(bitcoin::OutPoint),
AlreadySpent(bitcoin::OutPoint),
InvalidOutputValue(bitcoin::Amount),
InsufficientFunds(
/* in value */ bitcoin::Amount,
/* out value */ bitcoin::Amount,
/* target feerate */ u64,
),
SanityCheckFailure(Psbt),
}
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),
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::SanityCheckFailure(psbt) => write!(
f,
"BUG! Please report this. Failed sanity checks for PSBT '{:?}'.",
psbt
),
}
}
}
impl std::error::Error for CommandError {}
// Sanity check the value of a transaction output.
fn check_output_value(value: bitcoin::Amount) -> Result<(), CommandError> {
// NOTE: the network parameter isn't used upstream
if value.as_sat() > bitcoin::blockdata::constants::max_money(bitcoin::Network::Bitcoin)
|| value.as_sat() < DUST_OUTPUT_SATS
{
Err(CommandError::InvalidOutputValue(value))
} else {
Ok(())
}
}
// Apply some sanity checks on a created transaction's PSBT.
// TODO: add more sanity checks from revault_tx
fn sanity_check_psbt(psbt: &Psbt) -> Result<(), CommandError> {
let tx = &psbt.global.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() {
return Err(CommandError::SanityCheckFailure(psbt.clone()));
}
// Compute the transaction input value, checking all PSBT inputs have the derivation
// index set for signing devices to recognize them as ours.
let mut value_in = 0;
for psbtin in psbt.inputs.iter() {
if psbtin.bip32_derivation.is_empty() {
return Err(CommandError::SanityCheckFailure(psbt.clone()));
}
value_in += psbtin
.witness_utxo
.as_ref()
.ok_or(CommandError::SanityCheckFailure(psbt.clone()))?
.value;
}
// Compute the output value and check the absolute fee isn't insane.
let value_out: u64 = tx.output.iter().map(|o| o.value).sum();
let abs_fee = value_in
.checked_sub(value_out)
.ok_or(CommandError::SanityCheckFailure(psbt.clone()))?;
if abs_fee > MAX_FEE {
return Err(CommandError::SanityCheckFailure(psbt.clone()));
}
// Check the feerate isn't insane.
let tx_vb: u64 = tx_vbytes(&tx);
let feerate_sats_vb = abs_fee
.checked_div(tx_vb)
.ok_or(CommandError::SanityCheckFailure(psbt.clone()))?;
if feerate_sats_vb > MAX_FEERATE || feerate_sats_vb < 1 {
return Err(CommandError::SanityCheckFailure(psbt.clone()));
}
Ok(())
}
// Get the maximum satisfaction size in vbytes for this descriptor
fn desc_sat_vb(desc: &descriptors::DerivedInheritanceDescriptor) -> u64 {
desc.max_sat_weight()
.checked_div(WITNESS_FACTOR)
.unwrap()
.try_into()
.unwrap()
}
// Get the virtual size of this transaction
fn tx_vbytes(tx: &bitcoin::Transaction) -> u64 {
tx.get_weight()
.checked_div(WITNESS_FACTOR)
.unwrap()
.try_into()
.unwrap()
}
// Get the size of a type that can be serialized (txos, transactions, ..)
fn serializable_size<T: bitcoin::consensus::Encodable + ?Sized>(t: &T) -> u64 {
bitcoin::consensus::serialize(t).len().try_into().unwrap()
}
impl DaemonControl {
// Get the descriptor at this derivation index
fn derived_desc(&self, index: bip32::ChildNumber) -> descriptors::DerivedInheritanceDescriptor {
self.config.main_descriptor.derive(index, &self.secp)
}
}
impl DaemonControl {
/// Get information about the current state of the daemon
pub fn get_info(&self) -> GetInfoResult {
@ -66,6 +216,156 @@ impl DaemonControl {
.collect();
ListCoinsResult { coins }
}
pub fn create_spend(
&self,
coins_outpoints: &[bitcoin::OutPoint],
destinations: &HashMap<bitcoin::Address, u64>,
feerate_vb: u64,
) -> Result<CreateSpendResult, CommandError> {
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));
}
let mut db_conn = self.db.connection();
// Iterate through given outpoints to fetch the coins (hence checking there existence
// at the same time). We checked there is at least one, therefore after this loop the
// list of coins is not empty.
// While doing so, we record the total input value of the transaction to later compute
// fees, and add necessary information to the PSBT inputs.
let mut in_value = bitcoin::Amount::from_sat(0);
let mut sat_vb = 0;
let mut txins = Vec::with_capacity(destinations.len());
let mut psbt_ins = Vec::with_capacity(destinations.len());
let coins = db_conn.coins_by_outpoints(coins_outpoints);
for op in coins_outpoints {
let coin = coins.get(op).ok_or(CommandError::UnknownOutpoint(*op))?;
if coin.is_spent() {
return Err(CommandError::AlreadySpent(*op));
}
in_value += coin.amount;
txins.push(bitcoin::TxIn {
previous_output: *op,
// TODO: once we move to Taproot, anti-fee-sniping using nSequence
..bitcoin::TxIn::default()
});
let coin_desc = self.derived_desc(coin.derivation_index);
sat_vb += desc_sat_vb(&coin_desc);
let witness_script = Some(coin_desc.witness_script());
let witness_utxo = Some(bitcoin::TxOut {
value: coin.amount.as_sat(),
script_pubkey: coin_desc.script_pubkey(),
});
let bip32_derivation = coin_desc.bip32_derivations();
psbt_ins.push(PsbtIn {
witness_script,
witness_utxo,
bip32_derivation,
..PsbtIn::default()
});
}
// Add the destinations outputs to the transaction and PSBT. At the same time record the
// total output value to later compute fees, and sanity check each output's value.
let mut out_value = bitcoin::Amount::from_sat(0);
let mut txouts = Vec::with_capacity(destinations.len());
let mut psbt_outs = Vec::with_capacity(destinations.len());
for (address, value_sat) in destinations {
let amount = bitcoin::Amount::from_sat(*value_sat);
check_output_value(amount)?;
out_value = out_value.checked_add(amount).unwrap();
txouts.push(bitcoin::TxOut {
value: amount.as_sat(),
script_pubkey: address.script_pubkey(),
});
// TODO: if it's an address of ours, signal it as change to signing devices by adding
// the BIP32 derivation path to the PSBT input.
psbt_outs.push(PsbtOut::default());
}
// 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).
let mut tx = bitcoin::Transaction {
version: 2,
lock_time: 0, // TODO: randomized anti fee sniping
input: txins,
output: txouts,
};
let nochange_vb = tx_vbytes(&tx) + sat_vb;
let absolute_fee =
in_value
.checked_sub(out_value)
.ok_or(CommandError::InsufficientFunds(
in_value, out_value, feerate_vb,
))?;
let nochange_feerate_vb = absolute_fee.as_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,
));
}
// 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 {
// Get the change address to create a dummy change txo.
// TODO: decent change management
let first_coin = coins
.get(&coins_outpoints.get(0).expect("We checked it wasn't empty"))
.expect("We checked they were all present");
let coin_desc = self.derived_desc(first_coin.derivation_index);
let mut change_txo = bitcoin::TxOut {
value: std::u64::MAX,
script_pubkey: coin_desc.script_pubkey(),
};
// Serialized size is equal to the virtual size for an output.
let change_vb: u64 = serializable_size(&change_txo);
// We assume the added output does not increase the size of the varint for
// the output count.
let with_change_vb = nochange_vb.checked_add(change_vb).unwrap();
let with_change_feerate_vb = absolute_fee.as_sat().checked_div(with_change_vb).unwrap();
if with_change_feerate_vb > feerate_vb {
let target_fee = with_change_vb.checked_mul(feerate_vb).unwrap();
let change_amount = absolute_fee
.checked_sub(bitcoin::Amount::from_sat(target_fee))
.unwrap();
if change_amount.as_sat() >= DUST_OUTPUT_SATS {
check_output_value(change_amount)?;
// TODO: shuffle once we have Taproot
change_txo.value = change_amount.as_sat();
tx.output.push(change_txo);
psbt_outs.push(PsbtOut::default());
}
}
}
let psbt = Psbt {
global: psbt::Global {
unsigned_tx: tx,
version: 0,
xpub: BTreeMap::new(),
proprietary: BTreeMap::new(),
unknown: BTreeMap::new(),
},
inputs: psbt_ins,
outputs: psbt_outs,
};
sanity_check_psbt(&psbt)?;
// TODO: maybe check for common standardness rules (max size, ..)?
Ok(CreateSpendResult { psbt })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -104,6 +404,12 @@ pub struct ListCoinsResult {
pub coins: Vec<ListCoinsEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CreateSpendResult {
#[serde(serialize_with = "ser_base64", deserialize_with = "deser_psbt_base64")]
pub psbt: Psbt,
}
#[cfg(test)]
mod tests {
use super::*;
@ -138,4 +444,117 @@ mod tests {
ms.shutdown();
}
#[test]
fn create_spend() {
let ms = DummyMinisafe::new();
let control = &ms.handle.control;
// Arguments sanity checking
let dummy_op = bitcoin::OutPoint::from_str(
"3753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:0",
)
.unwrap();
let dummy_addr =
bitcoin::Address::from_str("bc1qnsexk3gnuyayu92fc3tczvc7k62u22a22ua2kv").unwrap();
let dummy_value = 10_000;
let mut destinations: HashMap<bitcoin::Address, u64> = [(dummy_addr.clone(), dummy_value)]
.iter()
.cloned()
.collect();
assert_eq!(
control.create_spend(&[], &destinations, 1),
Err(CommandError::NoOutpoint)
);
assert_eq!(
control.create_spend(&[dummy_op], &HashMap::new(), 1),
Err(CommandError::NoDestination)
);
assert_eq!(
control.create_spend(&[dummy_op], &destinations, 0),
Err(CommandError::InvalidFeerate(0))
);
// The coin doesn't exist. If we create a new unspent one at this outpoint with a much
// higher value, we'll get a Spend transaction with a change output.
assert_eq!(
control.create_spend(&[dummy_op], &destinations, 1),
Err(CommandError::UnknownOutpoint(dummy_op))
);
let mut db_conn = control.db().lock().unwrap().connection();
db_conn.new_unspent_coins(&[Coin {
outpoint: dummy_op,
block_height: None,
amount: bitcoin::Amount::from_sat(100_000),
derivation_index: bip32::ChildNumber::from(13),
spend_txid: None,
}]);
let res = control.create_spend(&[dummy_op], &destinations, 1).unwrap();
let tx = res.psbt.global.unsigned_tx;
assert_eq!(tx.input.len(), 1);
assert_eq!(tx.input[0].previous_output, dummy_op);
assert_eq!(tx.output.len(), 2);
assert_eq!(tx.output[0].script_pubkey, dummy_addr.script_pubkey());
assert_eq!(tx.output[0].value, dummy_value);
// Transaction is 1 in (P2WSH satisfaction), 2 outs. At 1sat/vb, it's 170 sats fees.
// At 2sats/vb, it's twice that.
assert_eq!(tx.output[1].value, 89_830);
let res = control.create_spend(&[dummy_op], &destinations, 2).unwrap();
let tx = res.psbt.global.unsigned_tx;
assert_eq!(tx.output[1].value, 89_660);
// If we ask for a too high feerate, or a too large/too small output, it'll fail.
assert_eq!(
control.create_spend(&[dummy_op], &destinations, 10_000),
Err(CommandError::InsufficientFunds(
bitcoin::Amount::from_sat(100_000),
bitcoin::Amount::from_sat(10_000),
10_000
))
);
*destinations.get_mut(&dummy_addr).unwrap() = 100_001;
assert_eq!(
control.create_spend(&[dummy_op], &destinations, 1),
Err(CommandError::InsufficientFunds(
bitcoin::Amount::from_sat(100_000),
bitcoin::Amount::from_sat(100_001),
1
))
);
*destinations.get_mut(&dummy_addr).unwrap() = 4_500;
assert_eq!(
control.create_spend(&[dummy_op], &destinations, 1),
Err(CommandError::InvalidOutputValue(bitcoin::Amount::from_sat(
4_500
)))
);
// If we ask for a large, but valid, output we won't get a change output. 95_000 because we
// won't create an output lower than 5k sats.
*destinations.get_mut(&dummy_addr).unwrap() = 95_000;
let res = control.create_spend(&[dummy_op], &destinations, 1).unwrap();
let tx = res.psbt.global.unsigned_tx;
assert_eq!(tx.input.len(), 1);
assert_eq!(tx.input[0].previous_output, dummy_op);
assert_eq!(tx.output.len(), 1);
assert_eq!(tx.output[0].script_pubkey, dummy_addr.script_pubkey());
assert_eq!(tx.output[0].value, 95_000);
// Now if we mark the coin as spent, we won't create another Spend transaction containing
// it.
db_conn.spend_coins(&[(
dummy_op,
bitcoin::Txid::from_str(
"ef78f79ba747813887747cf8582897a48f1a09f1ca04d2cd3d6fcfdcbb5e0797",
)
.unwrap(),
)]);
assert_eq!(
control.create_spend(&[dummy_op], &destinations, 1),
Err(CommandError::AlreadySpent(dummy_op))
);
ms.shutdown();
}
}

View File

@ -1,5 +1,5 @@
use miniscript::bitcoin;
use serde::{Deserialize, Deserializer, Serializer};
use miniscript::bitcoin::{self, consensus, util::psbt::PartiallySignedTransaction as Psbt};
use serde::{de, Deserialize, Deserializer, Serializer};
/// Serialize an amount as sats
pub fn ser_amount<S: Serializer>(amount: &bitcoin::Amount, s: S) -> Result<S::Ok, S::Error> {
@ -14,3 +14,21 @@ where
let a = u64::deserialize(deserializer)?;
Ok(bitcoin::Amount::from_sat(a))
}
pub fn ser_base64<S, T>(t: T, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
T: consensus::Encodable,
{
s.serialize_str(&base64::encode(consensus::serialize(&t)))
}
pub fn deser_psbt_base64<'de, D>(d: D) -> Result<Psbt, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(d)?;
let s = base64::decode(&s).map_err(de::Error::custom)?;
let psbt = consensus::deserialize(&s).map_err(de::Error::custom)?;
Ok(psbt)
}

View File

@ -233,6 +233,12 @@ impl DaemonControl {
secp,
}
}
// Useful for unit test to directly mess up with the DB
#[cfg(test)]
pub fn db(&self) -> sync::Arc<sync::Mutex<dyn DatabaseInterface>> {
self.db.clone()
}
}
pub struct DaemonHandle {