Merge #27: Spend transactions creation and storage
3dfc7261db33a4db85772dfa9db5f61f8d8952e2 jsonrpc: a new 'updatespend' RPC (Antoine Poinsot) cf45ba0fa5ead02997f1ffe7fea4faf83894ec8d commands: add a new 'update_spend' command (Antoine Poinsot) bafcadf39870c02b89a9a4627a6918fce6c54f30 db: interface to upsert a Spend PSBT and query it by txid (Antoine Poinsot) 7d015bcf435897fa6e129b931fd2bea742ff7bc5 jsonrpc: add a 'createspend' RPC command (Antoine Poinsot) 7468a7fcfb37736c4585d16c091fb5db68bf50cc qa: add PSBT and transaction serialization to the test framework (Antoine Poinsot) 09f59c417ba04a153887a7cdf1e284703c9647aa qa: fix the UDS class' forwarding method calls as JSONRPC calls (Antoine Poinsot) 9afd44061e12c4b50720571d7e6aa0c260e725eb commands: add a new create_spend command (Antoine Poinsot) 12188f0c52d5ab141c1dcea194851ba01c04a57d descriptors: add more utilities to the derived descriptor type (Antoine Poinsot) 46320d5fc09371d1657044fd1a8507e919bcaff4 db: an interface to query coins by their outpoints (Antoine Poinsot) Pull request description: This implements two new commands to create and store a Spend transaction PSBT. We'll need a `listspendtxs` and `delspendtx` eventually as in `revaultd`. This has poor change management (address reuse for simplicity). I was thinking of using odd/even indexes to differentiate receive/change addresses before we have [multi-path descriptors](https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2022-July/020791.html). Now i wonder if it makes sense at all to implement this odd/even thing and just stick with address reuse before multi-paths descriptors. Nobody is supposed to use that anyways. ACKs for top commit: darosior: ACK 3dfc7261db33a4db85772dfa9db5f61f8d8952e2 Tree-SHA512: 7f148c4d0d9ff8054c6405ca3031c2629c79917cdbf0134949ce7e066fa5aa157eb5bbe823d56a165a14c946d72c615d7dafa920899ba21d8471cc935c14790b
This commit is contained in:
commit
9c787d061d
7
Cargo.lock
generated
7
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -53,3 +53,6 @@ jsonrpc = "0.12"
|
||||
|
||||
# Used for daemonization
|
||||
libc = "0.2"
|
||||
|
||||
# Used for PSBTs
|
||||
base64 = "0.13"
|
||||
|
||||
46
doc/API.md
46
doc/API.md
@ -84,3 +84,49 @@ This command does not take any parameter for now.
|
||||
| `amount` | int | Value of the UTxO in satoshis |
|
||||
| `outpoint` | string | Transaction id and output index of this coin |
|
||||
| `block_height` | int or null | Blockheight the transaction was confirmed at, or `null` |
|
||||
|
||||
|
||||
### `createspend`
|
||||
|
||||
Create a transaction spending one or more of our coins. All coins must exist and not be spent.
|
||||
|
||||
Will error if the given coins are not sufficient to cover the transaction cost at 90% (or more) of
|
||||
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.
|
||||
|
||||
This command will refuse to create any output worth less than 5k sats.
|
||||
|
||||
#### Request
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | ----------------- | ----------------------------------------------------------------- |
|
||||
| `outpoints` | list of string | List of the coins to be spent, as `txid:vout`. |
|
||||
| `destinations` | object | Map from Bitcoin address to value |
|
||||
| `feerate` | integer | Target feerate for the transaction, in satoshis per virtual byte. |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------- | ---------------------------------------------------- |
|
||||
| `psbt` | string | PSBT of the spending transaction, encoded as base64. |
|
||||
|
||||
|
||||
### `updatespend`
|
||||
|
||||
Store the PSBT of a Spend transaction in database, updating it if it already exists.
|
||||
|
||||
Will merge the partial signatures for all inputs if a PSBT for a transaction with the same txid
|
||||
exists in DB.
|
||||
|
||||
#### Request
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------- | ------ | ------------------------------------------- |
|
||||
| `psbt` | string | Base64-encoded PSBT of a Spend transaction. |
|
||||
|
||||
#### Response
|
||||
|
||||
This command does not return anything for now.
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------- | ---------------------------------------------------- |
|
||||
|
||||
@ -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,208 @@ 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 })
|
||||
}
|
||||
|
||||
pub fn update_spend(&self, mut psbt: Psbt) -> Result<(), CommandError> {
|
||||
let mut db_conn = self.db.connection();
|
||||
let tx = &psbt.global.unsigned_tx;
|
||||
|
||||
// If the transaction already exists in DB, merge the signatures for each input on a best
|
||||
// effort basis.
|
||||
// We work on the newly provided PSBT, in case its content was updated.
|
||||
let txid = tx.txid();
|
||||
if let Some(db_psbt) = db_conn.spend_tx(&txid) {
|
||||
let db_tx = db_psbt.global.unsigned_tx;
|
||||
for i in 0..db_tx.input.len() {
|
||||
if tx
|
||||
.input
|
||||
.get(i)
|
||||
.map(|tx_in| tx_in.previous_output == db_tx.input[i].previous_output)
|
||||
!= Some(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let psbtin = match psbt.inputs.get_mut(i) {
|
||||
Some(psbtin) => psbtin,
|
||||
None => continue,
|
||||
};
|
||||
let db_psbtin = match db_psbt.inputs.get(i) {
|
||||
Some(db_psbtin) => db_psbtin,
|
||||
None => continue,
|
||||
};
|
||||
psbtin
|
||||
.partial_sigs
|
||||
.extend(db_psbtin.partial_sigs.clone().into_iter());
|
||||
}
|
||||
} else {
|
||||
// If the transaction doesn't exist in DB already, sanity check its inputs.
|
||||
// FIXME: should we allow for external inputs?
|
||||
let outpoints: Vec<bitcoin::OutPoint> =
|
||||
tx.input.iter().map(|txin| txin.previous_output).collect();
|
||||
let coins = db_conn.coins_by_outpoints(&outpoints);
|
||||
if coins.len() != outpoints.len() {
|
||||
for op in outpoints {
|
||||
if coins.get(&op).is_none() {
|
||||
return Err(CommandError::UnknownOutpoint(op));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, insert (or update) the PSBT in database.
|
||||
db_conn.store_spend(&psbt);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -104,10 +456,17 @@ 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::*;
|
||||
use crate::testutils::*;
|
||||
use bitcoin::hashes::hex::FromHex;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
@ -138,4 +497,220 @@ 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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_spend() {
|
||||
let ms = DummyMinisafe::new();
|
||||
let control = &ms.handle.control;
|
||||
let mut db_conn = control.db().lock().unwrap().connection();
|
||||
|
||||
// Add two (unconfirmed) coins in DB
|
||||
let dummy_op_a = bitcoin::OutPoint::from_str(
|
||||
"3753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:0",
|
||||
)
|
||||
.unwrap();
|
||||
let dummy_op_b = bitcoin::OutPoint::from_str(
|
||||
"4753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:1",
|
||||
)
|
||||
.unwrap();
|
||||
db_conn.new_unspent_coins(&[
|
||||
Coin {
|
||||
outpoint: dummy_op_a,
|
||||
block_height: None,
|
||||
amount: bitcoin::Amount::from_sat(100_000),
|
||||
derivation_index: bip32::ChildNumber::from(13),
|
||||
spend_txid: None,
|
||||
},
|
||||
Coin {
|
||||
outpoint: dummy_op_b,
|
||||
block_height: None,
|
||||
amount: bitcoin::Amount::from_sat(115_680),
|
||||
derivation_index: bip32::ChildNumber::from(34),
|
||||
spend_txid: None,
|
||||
},
|
||||
]);
|
||||
|
||||
// Now create three transactions spending those coins differently
|
||||
let dummy_addr_a =
|
||||
bitcoin::Address::from_str("bc1qnsexk3gnuyayu92fc3tczvc7k62u22a22ua2kv").unwrap();
|
||||
let dummy_addr_b =
|
||||
bitcoin::Address::from_str("bc1q39srgatmkp6k2ne3l52yhkjprdvunvspqydmkx").unwrap();
|
||||
let dummy_value_a = 50_000;
|
||||
let dummy_value_b = 60_000;
|
||||
let destinations_a: HashMap<bitcoin::Address, u64> =
|
||||
[(dummy_addr_a.clone(), dummy_value_a)]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let destinations_b: HashMap<bitcoin::Address, u64> =
|
||||
[(dummy_addr_b.clone(), dummy_value_b)]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let destinations_c: HashMap<bitcoin::Address, u64> = [
|
||||
(dummy_addr_a.clone(), dummy_value_a),
|
||||
(dummy_addr_b.clone(), dummy_value_b),
|
||||
]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut psbt_a = control
|
||||
.create_spend(&[dummy_op_a], &destinations_a, 1)
|
||||
.unwrap()
|
||||
.psbt;
|
||||
let txid_a = psbt_a.global.unsigned_tx.txid();
|
||||
let psbt_b = control
|
||||
.create_spend(&[dummy_op_b], &destinations_b, 10)
|
||||
.unwrap()
|
||||
.psbt;
|
||||
let txid_b = psbt_b.global.unsigned_tx.txid();
|
||||
let psbt_c = control
|
||||
.create_spend(&[dummy_op_a, dummy_op_b], &destinations_c, 100)
|
||||
.unwrap()
|
||||
.psbt;
|
||||
let txid_c = psbt_c.global.unsigned_tx.txid();
|
||||
|
||||
// We can store and query them all
|
||||
control.update_spend(psbt_a.clone()).unwrap();
|
||||
assert_eq!(db_conn.spend_tx(&txid_a).unwrap(), psbt_a);
|
||||
control.update_spend(psbt_b.clone()).unwrap();
|
||||
assert_eq!(db_conn.spend_tx(&txid_b).unwrap(), psbt_b);
|
||||
control.update_spend(psbt_c.clone()).unwrap();
|
||||
assert_eq!(db_conn.spend_tx(&txid_c).unwrap(), psbt_c);
|
||||
|
||||
// As well as update them, with or without new signatures
|
||||
psbt_a.inputs[0].partial_sigs.insert(bitcoin::PublicKey::from_str("023a664c5617412f0b292665b1fd9d766456a7a3b1614c7e7c5f411200ff1958ef").unwrap(), Vec::<u8>::from_hex("304402204004fcdbb9c0d0cbf585f58cee34dccb012efbd8fc2b0d5e97760045ae35803802201a0bd7ec2383e0b93748abc9946c8e17a8312e314dab85982aeba650e738cbf401").unwrap());
|
||||
control.update_spend(psbt_a.clone()).unwrap();
|
||||
assert_eq!(db_conn.spend_tx(&txid_a).unwrap(), psbt_a);
|
||||
control.update_spend(psbt_b.clone()).unwrap();
|
||||
assert_eq!(db_conn.spend_tx(&txid_b).unwrap(), psbt_b);
|
||||
control.update_spend(psbt_c.clone()).unwrap();
|
||||
assert_eq!(db_conn.spend_tx(&txid_c).unwrap(), psbt_c);
|
||||
|
||||
// We can't store a PSBT spending an external coin
|
||||
let external_op = bitcoin::OutPoint::from_str(
|
||||
"8753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:2",
|
||||
)
|
||||
.unwrap();
|
||||
psbt_a.global.unsigned_tx.input[0].previous_output = external_op;
|
||||
assert_eq!(
|
||||
control.update_spend(psbt_a),
|
||||
Err(CommandError::UnknownOutpoint(external_op))
|
||||
);
|
||||
|
||||
ms.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -13,7 +13,10 @@ use crate::{
|
||||
|
||||
use std::{collections::HashMap, sync};
|
||||
|
||||
use miniscript::bitcoin::{self, secp256k1, util::bip32};
|
||||
use miniscript::bitcoin::{
|
||||
self, secp256k1,
|
||||
util::{bip32, psbt::PartiallySignedTransaction as Psbt},
|
||||
};
|
||||
|
||||
pub trait DatabaseInterface: Send {
|
||||
fn connection(&self) -> Box<dyn DatabaseConnection>;
|
||||
@ -62,6 +65,44 @@ pub trait DatabaseConnection {
|
||||
|
||||
/// Mark a set of coins as being spent by a specified txid.
|
||||
fn spend_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]);
|
||||
|
||||
/// Get specific coins from the database.
|
||||
fn coins_by_outpoints(
|
||||
&mut self,
|
||||
outpoints: &[bitcoin::OutPoint],
|
||||
) -> HashMap<bitcoin::OutPoint, Coin>;
|
||||
|
||||
fn spend_tx(&mut self, txid: &bitcoin::Txid) -> Option<Psbt>;
|
||||
|
||||
/// Insert a new Spend transaction or replace an existing one.
|
||||
fn store_spend(&mut self, psbt: &Psbt);
|
||||
}
|
||||
|
||||
// FIXME: if possible, avoid reallocating.
|
||||
fn db_coins_into_coins(db_coins: Vec<DbCoin>) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
db_coins
|
||||
.into_iter()
|
||||
.map(|db_coin| {
|
||||
let DbCoin {
|
||||
outpoint,
|
||||
block_height,
|
||||
amount,
|
||||
derivation_index,
|
||||
spend_txid,
|
||||
..
|
||||
} = db_coin;
|
||||
(
|
||||
outpoint,
|
||||
Coin {
|
||||
outpoint,
|
||||
block_height,
|
||||
amount,
|
||||
derivation_index,
|
||||
spend_txid,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl DatabaseConnection for SqliteConn {
|
||||
@ -93,30 +134,7 @@ impl DatabaseConnection for SqliteConn {
|
||||
}
|
||||
|
||||
fn unspent_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
// FIXME: if possible, avoid reallocating.
|
||||
self.unspent_coins()
|
||||
.into_iter()
|
||||
.map(|db_coin| {
|
||||
let DbCoin {
|
||||
outpoint,
|
||||
block_height,
|
||||
amount,
|
||||
derivation_index,
|
||||
spend_txid,
|
||||
..
|
||||
} = db_coin;
|
||||
(
|
||||
outpoint,
|
||||
Coin {
|
||||
outpoint,
|
||||
block_height,
|
||||
amount,
|
||||
derivation_index,
|
||||
spend_txid,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
db_coins_into_coins(self.unspent_coins())
|
||||
}
|
||||
|
||||
fn new_unspent_coins<'a>(&mut self, coins: &[Coin]) {
|
||||
@ -138,6 +156,21 @@ impl DatabaseConnection for SqliteConn {
|
||||
self.db_address(address)
|
||||
.map(|db_addr| db_addr.derivation_index)
|
||||
}
|
||||
|
||||
fn coins_by_outpoints(
|
||||
&mut self,
|
||||
outpoints: &[bitcoin::OutPoint],
|
||||
) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
db_coins_into_coins(self.db_coins(outpoints))
|
||||
}
|
||||
|
||||
fn spend_tx(&mut self, txid: &bitcoin::Txid) -> Option<Psbt> {
|
||||
self.db_spend(txid).map(|db_spend| db_spend.psbt)
|
||||
}
|
||||
|
||||
fn store_spend(&mut self, psbt: &Psbt) {
|
||||
self.store_spend(psbt)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@ -13,7 +13,7 @@ use crate::{
|
||||
bitcoin::BlockChainTip,
|
||||
database::{
|
||||
sqlite::{
|
||||
schema::{DbAddress, DbCoin, DbTip, DbWallet},
|
||||
schema::{DbAddress, DbCoin, DbSpendTransaction, DbTip, DbWallet},
|
||||
utils::{create_fresh_db, db_exec, db_query, db_tx_query, LOOK_AHEAD_LIMIT},
|
||||
},
|
||||
Coin,
|
||||
@ -23,7 +23,10 @@ use crate::{
|
||||
|
||||
use std::{convert::TryInto, fmt, io, path};
|
||||
|
||||
use miniscript::bitcoin::{self, secp256k1};
|
||||
use miniscript::bitcoin::{
|
||||
self, consensus::encode, hashes::hex::ToHex, secp256k1,
|
||||
util::psbt::PartiallySignedTransaction as Psbt,
|
||||
};
|
||||
|
||||
const DB_VERSION: i64 = 0;
|
||||
|
||||
@ -330,6 +333,55 @@ impl SqliteConn {
|
||||
.expect("Db must not fail")
|
||||
.pop()
|
||||
}
|
||||
|
||||
pub fn db_coins(&mut self, outpoints: &[bitcoin::OutPoint]) -> Vec<DbCoin> {
|
||||
// SELECT * FROM coins WHERE (txid, vout) IN ((txidA, voutA), (txidB, voutB));
|
||||
let mut query = "SELECT * FROM coins WHERE (txid, vout) IN (VALUES ".to_string();
|
||||
for (i, outpoint) in outpoints.iter().enumerate() {
|
||||
// NOTE: the txid is not stored as little-endian. Convert it to vec first.
|
||||
query += &format!(
|
||||
"(x'{}', {})",
|
||||
&outpoint.txid.to_vec().to_hex(),
|
||||
outpoint.vout
|
||||
);
|
||||
if i != outpoints.len() - 1 {
|
||||
query += ", ";
|
||||
}
|
||||
}
|
||||
query += ")";
|
||||
|
||||
db_query(&mut self.conn, &query, rusqlite::params![], |row| {
|
||||
row.try_into()
|
||||
})
|
||||
.expect("Db must not fail")
|
||||
}
|
||||
|
||||
pub fn db_spend(&mut self, txid: &bitcoin::Txid) -> Option<DbSpendTransaction> {
|
||||
db_query(
|
||||
&mut self.conn,
|
||||
"SELECT * FROM spend_transactions WHERE txid = ?1",
|
||||
rusqlite::params![txid.to_vec()],
|
||||
|row| row.try_into(),
|
||||
)
|
||||
.expect("Db must not fail")
|
||||
.pop()
|
||||
}
|
||||
|
||||
/// Insert a new Spend transaction or replace an existing one.
|
||||
pub fn store_spend(&mut self, psbt: &Psbt) {
|
||||
let txid = psbt.global.unsigned_tx.txid().to_vec();
|
||||
let psbt = encode::serialize(psbt);
|
||||
|
||||
db_exec(&mut self.conn, |db_tx| {
|
||||
db_tx.execute(
|
||||
"INSERT into spend_transactions (psbt, txid) VALUES (?1, ?2) \
|
||||
ON CONFLICT DO UPDATE SET psbt=excluded.psbt",
|
||||
rusqlite::params![psbt, txid],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.expect("Db must not fail");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -462,6 +514,11 @@ mod tests {
|
||||
conn.new_unspent_coins(&[coin_a.clone()]); // On 1.48, arrays aren't IntoIterator
|
||||
assert_eq!(conn.unspent_coins()[0].outpoint, coin_a.outpoint);
|
||||
|
||||
// We can query it by its outpoint
|
||||
let coins = conn.db_coins(&[coin_a.outpoint]);
|
||||
assert_eq!(coins.len(), 1);
|
||||
assert_eq!(coins[0].outpoint, coin_a.outpoint);
|
||||
|
||||
// Add a second one, we'll get both.
|
||||
let coin_b = Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
@ -482,6 +539,24 @@ mod tests {
|
||||
assert!(outpoints.contains(&coin_a.outpoint));
|
||||
assert!(outpoints.contains(&coin_b.outpoint));
|
||||
|
||||
// We can query both by their outpoints
|
||||
let coins = conn.db_coins(&[coin_a.outpoint]);
|
||||
assert_eq!(coins.len(), 1);
|
||||
assert_eq!(coins[0].outpoint, coin_a.outpoint);
|
||||
let coins = conn.db_coins(&[coin_b.outpoint]);
|
||||
assert_eq!(coins.len(), 1);
|
||||
assert_eq!(coins[0].outpoint, coin_b.outpoint);
|
||||
let coins = conn.db_coins(&[coin_a.outpoint, coin_b.outpoint]);
|
||||
assert_eq!(coins.len(), 2);
|
||||
assert!(coins
|
||||
.iter()
|
||||
.find(|c| c.outpoint == coin_a.outpoint)
|
||||
.is_some());
|
||||
assert!(coins
|
||||
.iter()
|
||||
.find(|c| c.outpoint == coin_b.outpoint)
|
||||
.is_some());
|
||||
|
||||
// Now if we confirm one, it'll be marked as such.
|
||||
let height = 174500;
|
||||
conn.confirm_coins(&[(coin_a.outpoint, height)]);
|
||||
@ -501,6 +576,10 @@ mod tests {
|
||||
.collect();
|
||||
assert!(!outpoints.contains(&coin_a.outpoint));
|
||||
assert!(outpoints.contains(&coin_b.outpoint));
|
||||
|
||||
// Both are still in DB
|
||||
let coins = conn.db_coins(&[coin_a.outpoint, coin_b.outpoint]);
|
||||
assert_eq!(coins.len(), 2);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&tmp_dir).unwrap();
|
||||
|
||||
@ -2,7 +2,11 @@ use crate::descriptors::InheritanceDescriptor;
|
||||
|
||||
use std::{convert::TryFrom, str::FromStr};
|
||||
|
||||
use miniscript::bitcoin::{self, consensus::encode, util::bip32};
|
||||
use miniscript::bitcoin::{
|
||||
self,
|
||||
consensus::encode,
|
||||
util::{bip32, psbt::PartiallySignedTransaction as Psbt},
|
||||
};
|
||||
|
||||
pub const SCHEMA: &str = "\
|
||||
CREATE TABLE version (
|
||||
@ -49,6 +53,13 @@ CREATE TABLE addresses (
|
||||
address TEXT NOT NULL UNIQUE,
|
||||
derivation_index INTEGER NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
/* Transactions we created that spend some of our coins. */
|
||||
CREATE TABLE spend_transactions (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
psbt BLOB UNIQUE NOT NULL,
|
||||
txid BLOB UNIQUE NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// A row in the "tip" table.
|
||||
@ -186,3 +197,28 @@ impl TryFrom<&rusqlite::Row<'_>> for DbAddress {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A row in the "spend_transactions" table
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DbSpendTransaction {
|
||||
pub id: i64,
|
||||
pub psbt: Psbt,
|
||||
pub txid: bitcoin::Txid,
|
||||
}
|
||||
|
||||
impl TryFrom<&rusqlite::Row<'_>> for DbSpendTransaction {
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
fn try_from(row: &rusqlite::Row) -> Result<Self, Self::Error> {
|
||||
let id: i64 = row.get(0)?;
|
||||
|
||||
let psbt: Vec<u8> = row.get(1)?;
|
||||
let psbt: Psbt = encode::deserialize(&psbt).expect("We only store valid PSBTs");
|
||||
|
||||
let txid: Vec<u8> = row.get(2)?;
|
||||
let txid: bitcoin::Txid = encode::deserialize(&txid).expect("We only store valid txids");
|
||||
assert_eq!(txid, psbt.global.unsigned_tx.txid());
|
||||
|
||||
Ok(DbSpendTransaction { id, psbt, txid })
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ use miniscript::{
|
||||
MiniscriptKey, ScriptContext, ToPublicKey, TranslatePk2,
|
||||
};
|
||||
|
||||
use std::{error, fmt, io::Write, str, sync};
|
||||
use std::{collections::BTreeMap, error, fmt, io::Write, str, sync};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -374,12 +374,56 @@ impl InheritanceDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map of a raw public key to the xpub used to derive it and its derivation path
|
||||
pub type Bip32Deriv = BTreeMap<bitcoin::PublicKey, (bip32::Fingerprint, bip32::DerivationPath)>;
|
||||
|
||||
impl DerivedInheritanceDescriptor {
|
||||
pub fn address(&self, network: bitcoin::Network) -> bitcoin::Address {
|
||||
self.0
|
||||
.address(network)
|
||||
.expect("A P2WSH always has an address")
|
||||
}
|
||||
|
||||
pub fn script_pubkey(&self) -> bitcoin::Script {
|
||||
self.0.script_pubkey()
|
||||
}
|
||||
|
||||
pub fn witness_script(&self) -> bitcoin::Script {
|
||||
self.0.explicit_script()
|
||||
}
|
||||
|
||||
pub fn bip32_derivations(&self) -> Bip32Deriv {
|
||||
let ms = match self.0 {
|
||||
descriptor::Descriptor::Wsh(ref wsh) => match wsh.as_inner() {
|
||||
descriptor::WshInner::Ms(ms) => ms,
|
||||
descriptor::WshInner::SortedMulti(_) => {
|
||||
unreachable!("None of our descriptors is a sorted multi")
|
||||
}
|
||||
},
|
||||
_ => unreachable!("All our descriptors are always P2WSH"),
|
||||
};
|
||||
|
||||
// For DerivedPublicKey, Pk::Hash == Self.
|
||||
ms.iter_pk_pkh()
|
||||
.map(|pkpkh| match pkpkh {
|
||||
PkPkh::PlainPubkey(pk) => pk,
|
||||
PkPkh::HashedPubkey(pkh) => pkh,
|
||||
})
|
||||
.map(|k| {
|
||||
(
|
||||
k.key,
|
||||
(k.origin.0, bip32::DerivationPath::from(&[k.origin.1][..])),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the maximum size in WU of a satisfaction for this descriptor.
|
||||
pub fn max_sat_weight(&self) -> usize {
|
||||
self.0
|
||||
.max_satisfaction_weight()
|
||||
.expect("Cannot fail for P2WSH")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -426,6 +470,12 @@ mod tests {
|
||||
"bc1qvjzcg25nsxmfccct0txjvljxjwn68htkrw57jqmjhfzvhyd2z4msc74w65",
|
||||
der_desc.address(bitcoin::Network::Bitcoin).to_string()
|
||||
);
|
||||
|
||||
// Sanity check we can call the methods on the derived desc
|
||||
der_desc.script_pubkey();
|
||||
der_desc.witness_script();
|
||||
assert!(!der_desc.bip32_derivations().is_empty());
|
||||
assert!(!der_desc.max_sat_weight() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -1,15 +1,84 @@
|
||||
use crate::{
|
||||
jsonrpc::{Error, Request, Response},
|
||||
jsonrpc::{Error, Params, Request, Response},
|
||||
DaemonControl,
|
||||
};
|
||||
|
||||
use std::{collections::HashMap, convert::TryInto, str::FromStr};
|
||||
|
||||
use miniscript::bitcoin::{self, consensus, util::psbt::PartiallySignedTransaction as Psbt};
|
||||
|
||||
fn create_spend(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let outpoints = params
|
||||
.get(0, "outpoints")
|
||||
.ok_or(Error::invalid_params("Missing 'outpoints' parameter."))?
|
||||
.as_array()
|
||||
.and_then(|arr| {
|
||||
arr.into_iter()
|
||||
.map(|entry| {
|
||||
entry
|
||||
.as_str()
|
||||
.and_then(|e| bitcoin::OutPoint::from_str(&e).ok())
|
||||
})
|
||||
.collect::<Option<Vec<bitcoin::OutPoint>>>()
|
||||
})
|
||||
.ok_or(Error::invalid_params("Invalid 'outpoints' parameter."))?;
|
||||
let destinations = params
|
||||
.get(1, "destinations")
|
||||
.ok_or(Error::invalid_params("Missing 'destinations' parameter."))?
|
||||
.as_object()
|
||||
.and_then(|obj| {
|
||||
obj.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let addr = bitcoin::Address::from_str(&k).ok()?;
|
||||
let amount: u64 = v.as_i64()?.try_into().ok()?;
|
||||
Some((addr, amount))
|
||||
})
|
||||
.collect::<Option<HashMap<bitcoin::Address, u64>>>()
|
||||
})
|
||||
.ok_or(Error::invalid_params("Invalid 'destinations' parameter."))?;
|
||||
let feerate: u64 = params
|
||||
.get(2, "feerate")
|
||||
.ok_or(Error::invalid_params("Missing 'feerate' parameter."))?
|
||||
.as_i64()
|
||||
.and_then(|i| i.try_into().ok())
|
||||
.ok_or(Error::invalid_params("Invalid 'feerate' parameter."))?;
|
||||
|
||||
let res = control.create_spend(&outpoints, &destinations, feerate)?;
|
||||
Ok(serde_json::json!(&res))
|
||||
}
|
||||
|
||||
fn update_spend(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let psbt: Psbt = params
|
||||
.get(0, "psbt")
|
||||
.ok_or(Error::invalid_params("Missing 'psbt' parameter."))?
|
||||
.as_str()
|
||||
.and_then(|s| base64::decode(&s).ok())
|
||||
.and_then(|bytes| consensus::deserialize(&bytes).ok())
|
||||
.ok_or(Error::invalid_params("Invalid 'feerate' parameter."))?;
|
||||
control.update_spend(psbt)?;
|
||||
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// Handle an incoming JSONRPC2 request.
|
||||
pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response, Error> {
|
||||
let result = match req.method.as_str() {
|
||||
"createspend" => {
|
||||
let params = req.params.ok_or(Error::invalid_params(
|
||||
"Missing 'outpoints', 'destinations' and 'feerate' parameters.",
|
||||
))?;
|
||||
create_spend(control, params)?
|
||||
}
|
||||
"getinfo" => serde_json::json!(&control.get_info()),
|
||||
"getnewaddress" => serde_json::json!(&control.get_new_address()),
|
||||
"listcoins" => serde_json::json!(&control.list_coins()),
|
||||
"stop" => serde_json::json!({}),
|
||||
"updatespend" => {
|
||||
let params = req
|
||||
.params
|
||||
.ok_or(Error::invalid_params("Missing 'psbt' parameter."))?;
|
||||
update_spend(control, params)?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::method_not_found());
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
mod api;
|
||||
pub mod server;
|
||||
|
||||
use crate::commands;
|
||||
|
||||
use std::{error, fmt};
|
||||
|
||||
use serde::{self, Deserialize, Deserializer, Serialize, Serializer};
|
||||
@ -13,6 +15,20 @@ pub enum Params {
|
||||
Map(serde_json::Map<String, serde_json::Value>),
|
||||
}
|
||||
|
||||
impl Params {
|
||||
/// Get the parameter supposed to be at a given index / of a given name.
|
||||
pub fn get<Q>(&self, index: usize, name: &Q) -> Option<&serde_json::Value>
|
||||
where
|
||||
String: std::borrow::Borrow<Q>,
|
||||
Q: ?Sized + Ord + Eq + std::hash::Hash,
|
||||
{
|
||||
match self {
|
||||
Params::Array(vec) => vec.get(index),
|
||||
Params::Map(map) => map.get(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(untagged)]
|
||||
@ -42,6 +58,8 @@ pub enum ErrorCode {
|
||||
MethodNotFound,
|
||||
/// Invalid method parameter(s).
|
||||
InvalidParams,
|
||||
/// Internal error while handling the command.
|
||||
InternalError,
|
||||
/// Reserved for implementation-defined server-errors.
|
||||
ServerError(i64),
|
||||
}
|
||||
@ -51,6 +69,7 @@ impl Into<i64> for &ErrorCode {
|
||||
match self {
|
||||
ErrorCode::MethodNotFound => -32601,
|
||||
ErrorCode::InvalidParams => -32602,
|
||||
ErrorCode::InternalError => -32603,
|
||||
ErrorCode::ServerError(code) => *code,
|
||||
}
|
||||
}
|
||||
@ -108,7 +127,7 @@ impl Error {
|
||||
Error::new(ErrorCode::MethodNotFound, "Method not found")
|
||||
}
|
||||
|
||||
pub fn invalid_params<M>(message: impl Into<String>) -> Error {
|
||||
pub fn invalid_params(message: impl Into<String>) -> Error {
|
||||
Error::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("Invalid params: {}", message.into()),
|
||||
@ -125,6 +144,25 @@ impl fmt::Display for Error {
|
||||
|
||||
impl error::Error for Error {}
|
||||
|
||||
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(..)
|
||||
| commands::CommandError::InvalidOutputValue(..)
|
||||
| commands::CommandError::InsufficientFunds(..) => {
|
||||
Error::new(ErrorCode::InvalidParams, e.to_string())
|
||||
}
|
||||
commands::CommandError::SanityCheckFailure(_) => {
|
||||
Error::new(ErrorCode::InternalError, e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSONRPC2 response. See https://www.jsonrpc.org/specification#response_object.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -8,7 +8,10 @@ use crate::{
|
||||
use std::{collections::HashMap, env, fs, io, path, process, str::FromStr, sync, thread, time};
|
||||
|
||||
use miniscript::{
|
||||
bitcoin::{self, secp256k1, util::bip32},
|
||||
bitcoin::{
|
||||
self, secp256k1,
|
||||
util::{bip32, psbt::PartiallySignedTransaction as Psbt},
|
||||
},
|
||||
descriptor,
|
||||
};
|
||||
|
||||
@ -58,6 +61,7 @@ pub struct DummyDb {
|
||||
curr_index: bip32::ChildNumber,
|
||||
curr_tip: Option<BlockChainTip>,
|
||||
coins: HashMap<bitcoin::OutPoint, Coin>,
|
||||
spend_txs: HashMap<bitcoin::Txid, Psbt>,
|
||||
}
|
||||
|
||||
impl DummyDb {
|
||||
@ -66,6 +70,7 @@ impl DummyDb {
|
||||
curr_index: 0.into(),
|
||||
curr_tip: None,
|
||||
coins: HashMap::new(),
|
||||
spend_txs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -137,6 +142,34 @@ impl DatabaseConnection for DummyDbConn {
|
||||
fn derivation_index_by_address(&mut self, _: &bitcoin::Address) -> Option<bip32::ChildNumber> {
|
||||
None
|
||||
}
|
||||
|
||||
fn coins_by_outpoints(
|
||||
&mut self,
|
||||
outpoints: &[bitcoin::OutPoint],
|
||||
) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
// Very inefficient but hey
|
||||
self.db
|
||||
.read()
|
||||
.unwrap()
|
||||
.coins
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|(op, _)| outpoints.contains(&op))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn store_spend(&mut self, psbt: &Psbt) {
|
||||
let txid = psbt.global.unsigned_tx.txid();
|
||||
self.db
|
||||
.write()
|
||||
.unwrap()
|
||||
.spend_txs
|
||||
.insert(txid, psbt.clone());
|
||||
}
|
||||
|
||||
fn spend_tx(&mut self, txid: &bitcoin::Txid) -> Option<Psbt> {
|
||||
self.db.read().unwrap().spend_txs.get(txid).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyMinisafe {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from bip32 import BIP32
|
||||
from bip380.descriptors import Descriptor
|
||||
from concurrent import futures
|
||||
from ephemeral_port_reserve import reserve
|
||||
from test_framework.bitcoind import Bitcoind
|
||||
@ -117,10 +119,13 @@ def minisafed(bitcoind, directory):
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
|
||||
|
||||
main_desc = "wsh(or_d(pk(tpubD9vQiBdDxYzU1V5D5UUmMTXF9FZC13PuQDs4aiv6rF7UCKQFvtVKZguYakX12C2bt8736ksioxu9Y9Nmp18gj4jDeNJEEqrBPEZXAxe5YcQ/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/*),older(65000))))"
|
||||
owner_hd = BIP32.from_seed(os.urandom(32), network="test")
|
||||
owner_xpub = owner_hd.get_xpub()
|
||||
main_desc = Descriptor.from_str(f"wsh(or_d(pk({owner_xpub}/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/*),older(65000))))")
|
||||
|
||||
minisafed = Minisafed(
|
||||
datadir,
|
||||
owner_hd,
|
||||
main_desc,
|
||||
bitcoind.rpcport,
|
||||
bitcoind_cookie,
|
||||
|
||||
@ -2,3 +2,6 @@ pytest==6.2
|
||||
pytest-xdist==1.31.0
|
||||
pytest-timeout==1.3.4
|
||||
ephemeral_port_reserve==1.1.1
|
||||
|
||||
bip32~=3.0
|
||||
bip380==0.0.3
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from bip32.utils import coincurve
|
||||
from bip380.descriptors import Descriptor
|
||||
from bip380.miniscript import SatisfactionMaterial
|
||||
from test_framework.utils import (
|
||||
UnixDomainSocketRpc,
|
||||
TailableProc,
|
||||
@ -8,12 +11,19 @@ from test_framework.utils import (
|
||||
LOG_LEVEL,
|
||||
MINISAFED_PATH,
|
||||
)
|
||||
from test_framework.serializations import (
|
||||
PSBT,
|
||||
sighash_all_witness,
|
||||
CTxInWitness,
|
||||
CScriptWitness,
|
||||
)
|
||||
|
||||
|
||||
class Minisafed(TailableProc):
|
||||
def __init__(
|
||||
self,
|
||||
datadir,
|
||||
owner_hd,
|
||||
main_desc,
|
||||
bitcoind_rpc_port,
|
||||
bitcoind_cookie_path,
|
||||
@ -22,6 +32,9 @@ class Minisafed(TailableProc):
|
||||
|
||||
self.prefix = os.path.split(datadir)[-1]
|
||||
|
||||
self.owner_hd = owner_hd
|
||||
self.main_desc = main_desc
|
||||
|
||||
self.conf_file = os.path.join(datadir, "config.toml")
|
||||
self.cmd_line = [MINISAFED_PATH, "--conf", f"{self.conf_file}"]
|
||||
socket_path = os.path.join(os.path.join(datadir, "regtest"), "minisafed_rpc")
|
||||
@ -42,6 +55,53 @@ class Minisafed(TailableProc):
|
||||
f.write(f"cookie_path = '{bitcoind_cookie_path}'\n")
|
||||
f.write(f"addr = '127.0.0.1:{bitcoind_rpc_port}'\n")
|
||||
|
||||
def sign_psbt(self, psbt):
|
||||
"""Sign a transaction using the owner's key.
|
||||
This creates a valid witness for all inputs in the transaction using the
|
||||
information contained in the PSBT.
|
||||
|
||||
:param psbt: PSBT of the transaction to be signed.
|
||||
:returns: the serialized valid transaction, as hex.
|
||||
"""
|
||||
assert isinstance(psbt, PSBT)
|
||||
|
||||
# Create a witness for each input of the transaction.
|
||||
for i, psbt_in in enumerate(psbt.inputs):
|
||||
# First, gather the needed information from the PSBT input.
|
||||
# 'hd_keypaths' is of the form {pubkey: (fingerprint, derivation index)}
|
||||
der_index = next(iter(psbt_in.hd_keypaths.values()))[1]
|
||||
script_code = psbt_in.witness_script
|
||||
|
||||
# Now sign the transaction with the key of the "owner" (the participant that
|
||||
# can sign immediately without a timelock)
|
||||
sighash = sighash_all_witness(script_code, psbt, i)
|
||||
privkey = coincurve.PrivateKey(
|
||||
self.owner_hd.get_privkey_from_path([der_index])
|
||||
)
|
||||
pubkey = privkey.public_key.format()
|
||||
assert pubkey in psbt_in.hd_keypaths.keys()
|
||||
sig = privkey.sign(sighash, hasher=None) + b"\x01"
|
||||
logging.debug(f"Adding signature {sig.hex()} for pubkey {pubkey.hex()}")
|
||||
|
||||
# Create a copy of the descriptor to derive it at the index used in this input.
|
||||
# Then create a satisfaction for it using the signature we just created.
|
||||
desc = Descriptor.from_str(str(self.main_desc))
|
||||
desc.derive(der_index)
|
||||
sat_material = SatisfactionMaterial(
|
||||
signatures={pubkey: sig},
|
||||
)
|
||||
stack = desc.satisfy(sat_material)
|
||||
logging.debug(f"Satisfaction for {desc} is {[e.hex() for e in stack]}")
|
||||
|
||||
# Update the transaction inside the PSBT directly.
|
||||
assert stack is not None
|
||||
psbt_in.final_script_witness = CTxInWitness(CScriptWitness(stack))
|
||||
psbt.tx.wit.vtxinwit.append(psbt_in.final_script_witness)
|
||||
|
||||
tx = psbt.tx.serialize_with_witness().hex()
|
||||
logging.debug(f"Final transaction: {tx}")
|
||||
return tx
|
||||
|
||||
def start(self):
|
||||
TailableProc.start(self)
|
||||
self.wait_for_logs(
|
||||
|
||||
1045
tests/test_framework/serializations.py
Normal file
1045
tests/test_framework/serializations.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -152,14 +152,12 @@ class UnixDomainSocketRpc(object):
|
||||
We might still want to define the actual methods in the subclasses for
|
||||
documentation purposes.
|
||||
"""
|
||||
name = name.replace("_", "-")
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
if len(args) != 0 and len(kwargs) != 0:
|
||||
raise RpcError(
|
||||
name, {}, "Cannot mix positional and non-positional arguments"
|
||||
)
|
||||
return self.call(name, params=kwargs)
|
||||
return self.call(name, params=args or kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from fixtures import *
|
||||
from test_framework.serializations import PSBT
|
||||
from test_framework.utils import wait_for, COIN
|
||||
|
||||
|
||||
@ -40,3 +41,74 @@ def test_listcoins(minisafed, bitcoind):
|
||||
wait_for(
|
||||
lambda: minisafed.rpc.listcoins()["coins"][0]["block_height"] == block_height
|
||||
)
|
||||
|
||||
|
||||
def test_jsonrpc_server(minisafed, bitcoind):
|
||||
"""Test passing parameters as a list or a mapping."""
|
||||
addr = minisafed.rpc.getnewaddress()["address"]
|
||||
bitcoind.rpc.sendtoaddress(addr, 1)
|
||||
wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) == 1)
|
||||
outpoints = [minisafed.rpc.listcoins()["coins"][0]["outpoint"]]
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 20_000,
|
||||
}
|
||||
res = minisafed.rpc.createspend(outpoints, destinations, 18)
|
||||
assert "psbt" in res
|
||||
res = minisafed.rpc.createspend(
|
||||
outpoints=outpoints, destinations=destinations, feerate=18
|
||||
)
|
||||
assert "psbt" in res
|
||||
|
||||
|
||||
def test_create_spend(minisafed, bitcoind):
|
||||
# Receive a number of coins in different blocks on different addresses, and
|
||||
# one more on the same address.
|
||||
for _ in range(15):
|
||||
addr = minisafed.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 0.01)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 0.3556)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
|
||||
# Stop the daemon, should be a no-op
|
||||
minisafed.stop()
|
||||
minisafed.start()
|
||||
|
||||
# Now create a transaction spending all those coins to a few addresses
|
||||
outpoints = [c["outpoint"] for c in minisafed.rpc.listcoins()["coins"]]
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 200_000,
|
||||
bitcoind.rpc.getnewaddress(): 400_000,
|
||||
bitcoind.rpc.getnewaddress(): 1_000_000,
|
||||
}
|
||||
res = minisafed.rpc.createspend(outpoints, destinations, 18)
|
||||
assert "psbt" in res
|
||||
|
||||
# The transaction must contain a change output.
|
||||
spend_psbt = PSBT()
|
||||
spend_psbt.deserialize(res["psbt"])
|
||||
assert len(spend_psbt.outputs) == 4
|
||||
assert len(spend_psbt.tx.vout) == 4
|
||||
|
||||
# We can sign it and broadcast it.
|
||||
signed_tx_hex = minisafed.sign_psbt(spend_psbt)
|
||||
bitcoind.rpc.sendrawtransaction(signed_tx_hex)
|
||||
|
||||
|
||||
def test_update_spend(minisafed, bitcoind):
|
||||
# Start by creating a Spend PSBT
|
||||
addr = minisafed.rpc.getnewaddress()["address"]
|
||||
bitcoind.rpc.sendtoaddress(addr, 0.2567)
|
||||
wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) > 0)
|
||||
outpoints = [c["outpoint"] for c in minisafed.rpc.listcoins()["coins"]]
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 200_000,
|
||||
}
|
||||
res = minisafed.rpc.createspend(outpoints, destinations, 6)
|
||||
assert "psbt" in res
|
||||
|
||||
# Now update it
|
||||
minisafed.rpc.updatespend(res["psbt"])
|
||||
|
||||
# TODO: check it's stored once we implement 'listspendtxs'
|
||||
# TODO: check with added signatures once we implement 'listspendtxs'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user