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:
Antoine Poinsot 2022-10-04 01:53:07 +02:00
commit 9c787d061d
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
19 changed files with 2216 additions and 40 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

@ -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 |
| -------------- | --------- | ---------------------------------------------------- |

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,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();
}
}

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

@ -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)]

View File

@ -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();

View File

@ -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 })
}
}

View File

@ -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]

View File

@ -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());
}

View File

@ -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)]

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 {

View File

@ -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 {

View File

@ -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,

View File

@ -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

View File

@ -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(

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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'