Merge #182: Recovery command to sweep "expired" coins
ba994ff8ff01d04fd31ed77cf7f66e0862e68e17 commands: do not underestimate the size of created transactions (Antoine Poinsot) 46a94d6c8eb144484a4b0df2ddbd48b32d4d096f qa: test recovery 'sweep' transaction creation (Antoine Poinsot) 3b5cbd5122fa53d0247e82dd7fc703b5d0a4bae3 qa: introduce a sign_and_broadcast utility (Antoine Poinsot) d362885b85107b616447899d5eaaa5ba1b55c170 qa: record the recovery xpub too (Antoine Poinsot) a69fb625ea3b8dfbfa1bf384e001f167fd04ff86 qa: use a CSV of 10 for recovery (Antoine Poinsot) a78f46fa2fc5337c51d1eaa6ac6bbee04cfdf437 [refactoring] jsonrpc: sort command names alphabetically (Antoine Poinsot) c09ae3f87bc8bb2391d88d1a718b35d4a6c568fa commands, jsonrpc: add a new 'createrecovery' command (Antoine Poinsot) 3d5d0134b4b5bc06910a89d23def36de5b548c30 commands: fix the capacity of input vectors in create_spend (Antoine Poinsot) f2312593da8b27c6c6a69c7b0463959369c5acb6 commands: check for dust outputs in the PSBT sanity checks (Antoine Poinsot) 9f23161a53ad32d03d4ec464c48a819862dbf3e5 commands: correct the max feerate value (Antoine Poinsot) 54410cd9c435e1e6228b985d78e55b357c2368b8 database: allow to query coins by their spending status (Antoine Poinsot) Pull request description: A new command that provides a simple way for a user to sweep all the coins whose timelocked recovery path is available. The first part of #180. Note this also contains a number of drive-by fixes that i noticed while coding this up. ACKs for top commit: darosior: self-ACK ba994ff8ff01d04fd31ed77cf7f66e0862e68e17 -- tested by Edouard on the GUI Tree-SHA512: 002cb8602370fe3bec0692fe7bbe9e7af494b43756d3092da99b37e4ce2837bcdfcfb3afe7043900eb41c70de12e2129e19363b9efe41cee91c7f1f7c2824fb0
This commit is contained in:
commit
ad1f0e20b1
25
doc/API.md
25
doc/API.md
@ -11,12 +11,15 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`.
|
||||
| [`getinfo`](#getinfo) | Get general information about the daemon |
|
||||
| [`getnewaddress`](#getnewaddress) | Get a new receiving address |
|
||||
| [`listcoins`](#listcoins) | List all wallet transaction outputs. |
|
||||
| [`createspend`](#createspend) | Create a new Spend transaction |
|
||||
| [`updatespend`](#updatespend) | Store a created Spend transaction |
|
||||
| [`listspendtxs`](#listspendtxs) | List all stored Spend transactions |
|
||||
| [`delspendtx`](#delspendtx) | Delete a stored Spend transaction |
|
||||
| [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it |
|
||||
| [`startrescan`](#startrescan) | Start rescanning the block chain from a given date |
|
||||
| [`listconfirmed`](#listconfirmed) | List of confirmed transactions of incoming and outgoing funds |
|
||||
| [`listtransactions`](#listtransactions) | List of transactions with the given txids |
|
||||
| [`createrecovery`](#createrecovery) | Create a recovery transaction to sweep expired coins |
|
||||
|
||||
# Reference
|
||||
|
||||
@ -261,3 +264,25 @@ Confirmation time is based on the timestamp of blocks.
|
||||
| Field | Type | Description |
|
||||
| -------------- | ------ | ------------------------------------------------------ |
|
||||
| `transactions` | array | Array of [Transaction resource](#transaction-resource) |
|
||||
|
||||
|
||||
### `createrecovery`
|
||||
|
||||
Create a transaction that sweeps all coins whose timelocked recovery path is available to a provided
|
||||
address at a provided feerate.
|
||||
|
||||
Will error if no such coins are available or the sum of their value is not enough to cover the
|
||||
requested feerate.
|
||||
|
||||
#### Request
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ----------------- | ----------------------------------------------------------------- |
|
||||
| `address` | str | The Bitcoin address to sweep the coins to. |
|
||||
| `feerate` | integer | Target feerate for the transaction, in satoshis per virtual byte. |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------- | ---------------------------------------------------- |
|
||||
| `psbt` | string | PSBT of the recovery transaction, encoded as base64. |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
bitcoin::{BitcoinInterface, BlockChainTip, UTxO},
|
||||
database::{Coin, DatabaseConnection, DatabaseInterface},
|
||||
database::{Coin, CoinType, DatabaseConnection, DatabaseInterface},
|
||||
descriptors,
|
||||
};
|
||||
|
||||
@ -30,7 +30,7 @@ fn update_coins(
|
||||
descs: &[descriptors::InheritanceDescriptor],
|
||||
secp: &secp256k1::Secp256k1<secp256k1::VerifyOnly>,
|
||||
) -> UpdatedCoins {
|
||||
let curr_coins = db_conn.coins();
|
||||
let curr_coins = db_conn.coins(CoinType::All);
|
||||
log::debug!("Current coins: {:?}", curr_coins);
|
||||
|
||||
// Start by fetching newly received coins.
|
||||
|
||||
@ -6,7 +6,7 @@ mod utils;
|
||||
|
||||
use crate::{
|
||||
bitcoin::BitcoinInterface,
|
||||
database::{Coin, DatabaseInterface},
|
||||
database::{Coin, CoinType, DatabaseInterface},
|
||||
descriptors, DaemonControl, VERSION,
|
||||
};
|
||||
|
||||
@ -15,7 +15,7 @@ use utils::{
|
||||
};
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{hash_map, BTreeMap, HashMap},
|
||||
convert::TryInto,
|
||||
fmt,
|
||||
};
|
||||
@ -29,8 +29,6 @@ use miniscript::{
|
||||
};
|
||||
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;
|
||||
@ -39,7 +37,7 @@ const DUST_OUTPUT_SATS: u64 = 5_000;
|
||||
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;
|
||||
const MAX_FEERATE: u64 = 1_000;
|
||||
|
||||
// Timestamp in the header of the genesis block. Used for sanity checks.
|
||||
const MAINNET_GENESIS_TIME: u32 = 1231006505;
|
||||
@ -67,6 +65,7 @@ pub enum CommandError {
|
||||
InsaneRescanTimestamp(u32),
|
||||
/// An error that might occur in the racy rescan triggering logic.
|
||||
RescanTrigger(String),
|
||||
RecoveryNotAvailable,
|
||||
}
|
||||
|
||||
impl fmt::Display for CommandError {
|
||||
@ -102,6 +101,10 @@ impl fmt::Display for CommandError {
|
||||
),
|
||||
Self::InsaneRescanTimestamp(t) => write!(f, "Insane timestamp '{}'.", t),
|
||||
Self::RescanTrigger(s) => write!(f, "Error while starting rescan: '{}'", s),
|
||||
Self::RecoveryNotAvailable => write!(
|
||||
f,
|
||||
"No coin currently available through the timelocked recovery path."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -154,7 +157,7 @@ fn sanity_check_psbt(psbt: &Psbt) -> Result<(), CommandError> {
|
||||
}
|
||||
|
||||
// Check the feerate isn't insane.
|
||||
let tx_vb: u64 = tx_vbytes(tx);
|
||||
let tx_vb = tx.vsize() as u64;
|
||||
let feerate_sats_vb = abs_fee
|
||||
.checked_div(tx_vb)
|
||||
.ok_or_else(|| CommandError::SanityCheckFailure(psbt.clone()))?;
|
||||
@ -162,27 +165,16 @@ fn sanity_check_psbt(psbt: &Psbt) -> Result<(), CommandError> {
|
||||
return Err(CommandError::SanityCheckFailure(psbt.clone()));
|
||||
}
|
||||
|
||||
// Check for dust outputs
|
||||
for txo in psbt.unsigned_tx.output.iter() {
|
||||
if txo.value < txo.script_pubkey.dust_value().to_sat() {
|
||||
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.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()
|
||||
@ -243,7 +235,7 @@ impl DaemonControl {
|
||||
pub fn list_coins(&self) -> ListCoinsResult {
|
||||
let mut db_conn = self.db.connection();
|
||||
let coins: Vec<ListCoinsEntry> = db_conn
|
||||
.coins()
|
||||
.coins(CoinType::All)
|
||||
// Can't use into_values as of Rust 1.48
|
||||
.into_iter()
|
||||
.map(|(_, coin)| {
|
||||
@ -293,9 +285,10 @@ impl DaemonControl {
|
||||
// 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 txin_sat_vb = self.config.main_descriptor.max_sat_vbytes();
|
||||
let mut sat_vb = 0;
|
||||
let mut txins = Vec::with_capacity(destinations.len());
|
||||
let mut psbt_ins = Vec::with_capacity(destinations.len());
|
||||
let mut txins = Vec::with_capacity(coins_outpoints.len());
|
||||
let mut psbt_ins = Vec::with_capacity(coins_outpoints.len());
|
||||
let mut spent_txs = HashMap::with_capacity(coins_outpoints.len());
|
||||
let coins = db_conn.coins_by_outpoints(coins_outpoints);
|
||||
for op in coins_outpoints {
|
||||
@ -323,7 +316,7 @@ impl DaemonControl {
|
||||
|
||||
// Populate the PSBT input with the information needed by signers.
|
||||
let coin_desc = self.derived_desc(coin);
|
||||
sat_vb += desc_sat_vb(&coin_desc);
|
||||
sat_vb += txin_sat_vb;
|
||||
let witness_script = Some(coin_desc.witness_script());
|
||||
let witness_utxo = Some(bitcoin::TxOut {
|
||||
value: coin.amount.to_sat(),
|
||||
@ -381,14 +374,17 @@ impl DaemonControl {
|
||||
input: txins,
|
||||
output: txouts,
|
||||
};
|
||||
let nochange_vb = tx_vbytes(&tx) + sat_vb;
|
||||
let nochange_vb = (tx.vsize() + sat_vb) as u64;
|
||||
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.to_sat().checked_div(nochange_vb).unwrap();
|
||||
let nochange_feerate_vb = absolute_fee
|
||||
.to_sat()
|
||||
.checked_div(nochange_vb as u64)
|
||||
.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,
|
||||
@ -621,6 +617,118 @@ impl DaemonControl {
|
||||
.collect();
|
||||
ListTransactionsResult { transactions }
|
||||
}
|
||||
|
||||
/// Create a transaction that sweeps all coins whose timelocked recovery path is currently
|
||||
/// available to a provided address with the provided feerate.
|
||||
///
|
||||
/// Note that not all coins may be spendable through the recovery path at the same time.
|
||||
pub fn create_recovery(
|
||||
&self,
|
||||
address: bitcoin::Address,
|
||||
feerate_vb: u64,
|
||||
) -> Result<CreateRecoveryResult, CommandError> {
|
||||
if feerate_vb < 1 {
|
||||
return Err(CommandError::InvalidFeerate(feerate_vb));
|
||||
}
|
||||
let mut db_conn = self.db.connection();
|
||||
|
||||
// The transaction template. We'll fill-in the inputs afterward.
|
||||
let mut psbt = Psbt {
|
||||
unsigned_tx: bitcoin::Transaction {
|
||||
version: 2,
|
||||
lock_time: bitcoin::PackedLockTime(0), // TODO: anti-fee sniping
|
||||
input: Vec::new(),
|
||||
output: vec![bitcoin::TxOut {
|
||||
script_pubkey: address.script_pubkey(),
|
||||
value: 0xFF_FF_FF_FF,
|
||||
}],
|
||||
},
|
||||
version: 0,
|
||||
xpub: BTreeMap::new(),
|
||||
proprietary: BTreeMap::new(),
|
||||
unknown: BTreeMap::new(),
|
||||
inputs: Vec::new(),
|
||||
outputs: vec![PsbtOut::default()],
|
||||
};
|
||||
|
||||
// Query the coins that we can spend through the recovery path from the database.
|
||||
let current_height = self.bitcoin.chain_tip().height;
|
||||
let desc_timelock = self.config.main_descriptor.timelock_value();
|
||||
let timelock: i32 = desc_timelock
|
||||
.try_into()
|
||||
.expect("Must fit, it's effectively a u16");
|
||||
let sweepable_coins = db_conn
|
||||
.coins(CoinType::Unspent)
|
||||
.into_iter()
|
||||
.filter(|(_, c)| {
|
||||
// We are interested in coins available at the *next* block
|
||||
c.block_height
|
||||
.map(|h| current_height + 1 >= h + timelock)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
// Fill-in the transaction inputs and PSBT inputs information. Record the value
|
||||
// that is fed to the transaction while doing so, to compute the fees afterward.
|
||||
let csv_value: u16 = desc_timelock
|
||||
.try_into()
|
||||
.expect("Must fit, it's effectively a u16");
|
||||
let mut in_value = bitcoin::Amount::from_sat(0);
|
||||
let txin_sat_vb = self.config.main_descriptor.max_sat_vbytes();
|
||||
let mut sat_vb = 0;
|
||||
let mut spent_txs = HashMap::new();
|
||||
for (_, coin) in sweepable_coins {
|
||||
in_value += coin.amount;
|
||||
psbt.unsigned_tx.input.push(bitcoin::TxIn {
|
||||
previous_output: coin.outpoint,
|
||||
sequence: bitcoin::Sequence::from_height(csv_value),
|
||||
// TODO: once we move to Taproot, anti-fee-sniping using nSequence
|
||||
..bitcoin::TxIn::default()
|
||||
});
|
||||
|
||||
// Fetch the transaction that created this coin if necessary
|
||||
if let hash_map::Entry::Vacant(e) = spent_txs.entry(coin.outpoint) {
|
||||
let tx = self
|
||||
.bitcoin
|
||||
.wallet_transaction(&coin.outpoint.txid)
|
||||
.ok_or(CommandError::FetchingTransaction(coin.outpoint))?;
|
||||
e.insert(tx.0);
|
||||
}
|
||||
|
||||
let coin_desc = self.derived_desc(&coin);
|
||||
sat_vb += txin_sat_vb;
|
||||
let witness_script = Some(coin_desc.witness_script());
|
||||
let witness_utxo = Some(bitcoin::TxOut {
|
||||
value: coin.amount.to_sat(),
|
||||
script_pubkey: coin_desc.script_pubkey(),
|
||||
});
|
||||
let non_witness_utxo = spent_txs.get(&coin.outpoint).cloned();
|
||||
let bip32_derivation = coin_desc.bip32_derivations();
|
||||
psbt.inputs.push(PsbtIn {
|
||||
witness_script,
|
||||
witness_utxo,
|
||||
non_witness_utxo,
|
||||
bip32_derivation,
|
||||
..PsbtIn::default()
|
||||
});
|
||||
}
|
||||
|
||||
// The sweepable_coins iterator may have been empty.
|
||||
if psbt.unsigned_tx.input.is_empty() {
|
||||
return Err(CommandError::RecoveryNotAvailable);
|
||||
}
|
||||
|
||||
// Compute the value of the single output based on the requested feerate.
|
||||
let tx_vbytes = (psbt.unsigned_tx.vsize() + sat_vb) as u64;
|
||||
let absolute_fee = bitcoin::Amount::from_sat(tx_vbytes.checked_mul(feerate_vb).unwrap());
|
||||
let output_value = in_value.checked_sub(absolute_fee).ok_or({
|
||||
CommandError::InsufficientFunds(in_value, bitcoin::Amount::from_sat(0), feerate_vb)
|
||||
})?;
|
||||
psbt.unsigned_tx.output[0].value = output_value.to_sat();
|
||||
|
||||
sanity_check_psbt(&psbt)?;
|
||||
|
||||
Ok(CreateRecoveryResult { psbt })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -701,6 +809,12 @@ pub struct TransactionInfo {
|
||||
pub time: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CreateRecoveryResult {
|
||||
#[serde(serialize_with = "ser_base64", deserialize_with = "deser_base64")]
|
||||
pub psbt: Psbt,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -813,12 +927,12 @@ mod tests {
|
||||
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.
|
||||
// Transaction is 1 in (P2WSH satisfaction), 2 outs. At 1sat/vb, it's 171 sats fees.
|
||||
// At 2sats/vb, it's twice that.
|
||||
assert_eq!(tx.output[1].value, 89_830);
|
||||
assert_eq!(tx.output[1].value, 89_829);
|
||||
let res = control.create_spend(&destinations, &[dummy_op], 2).unwrap();
|
||||
let tx = res.psbt.unsigned_tx;
|
||||
assert_eq!(tx.output[1].value, 89_660);
|
||||
assert_eq!(tx.output[1].value, 89_658);
|
||||
|
||||
// If we ask for a too high feerate, or a too large/too small output, it'll fail.
|
||||
assert_eq!(
|
||||
|
||||
@ -81,7 +81,7 @@ pub trait DatabaseConnection {
|
||||
) -> Option<(bip32::ChildNumber, bool)>;
|
||||
|
||||
/// Get all our coins, past or present, spent or not.
|
||||
fn coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin>;
|
||||
fn coins(&mut self, coin_type: CoinType) -> HashMap<bitcoin::OutPoint, Coin>;
|
||||
|
||||
/// List coins that are being spent and whose spending transaction is still unconfirmed.
|
||||
fn list_spending_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin>;
|
||||
@ -178,8 +178,8 @@ impl DatabaseConnection for SqliteConn {
|
||||
self.complete_wallet_rescan()
|
||||
}
|
||||
|
||||
fn coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
self.coins()
|
||||
fn coins(&mut self, coin_type: CoinType) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
self.coins(coin_type)
|
||||
.into_iter()
|
||||
.map(|db_coin| (db_coin.outpoint, db_coin.into()))
|
||||
.collect()
|
||||
@ -316,3 +316,10 @@ impl Coin {
|
||||
self.spend_txid.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CoinType {
|
||||
All,
|
||||
Unspent,
|
||||
Spent,
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ use crate::{
|
||||
schema::{DbAddress, DbCoin, DbSpendTransaction, DbTip, DbWallet},
|
||||
utils::{create_fresh_db, db_exec, db_query, db_tx_query, LOOK_AHEAD_LIMIT},
|
||||
},
|
||||
Coin,
|
||||
Coin, CoinType,
|
||||
},
|
||||
descriptors::MultipathDescriptor,
|
||||
};
|
||||
@ -313,10 +313,14 @@ impl SqliteConn {
|
||||
}
|
||||
|
||||
/// Get all the coins from DB.
|
||||
pub fn coins(&mut self) -> Vec<DbCoin> {
|
||||
pub fn coins(&mut self, coin_type: CoinType) -> Vec<DbCoin> {
|
||||
db_query(
|
||||
&mut self.conn,
|
||||
"SELECT * FROM coins",
|
||||
match coin_type {
|
||||
CoinType::All => "SELECT * FROM coins",
|
||||
CoinType::Unspent => "SELECT * FROM coins WHERE spend_txid IS NULL",
|
||||
CoinType::Spent => "SELECT * FROM coins WHERE spend_txid IS NOT NULL",
|
||||
},
|
||||
rusqlite::params![],
|
||||
|row| row.try_into(),
|
||||
)
|
||||
@ -682,7 +686,7 @@ mod tests {
|
||||
let mut conn = db.connection().unwrap();
|
||||
|
||||
// Necessarily empty at first.
|
||||
assert!(conn.coins().is_empty());
|
||||
assert!(conn.coins(CoinType::All).is_empty());
|
||||
|
||||
// Add one, we'll get it.
|
||||
let coin_a = Coin {
|
||||
@ -699,13 +703,17 @@ mod tests {
|
||||
spend_block: None,
|
||||
};
|
||||
conn.new_unspent_coins(&[coin_a]);
|
||||
assert_eq!(conn.coins()[0].outpoint, coin_a.outpoint);
|
||||
assert_eq!(conn.coins(CoinType::All)[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);
|
||||
|
||||
// It is unspent.
|
||||
assert_eq!(conn.coins(CoinType::Unspent)[0].outpoint, coin_a.outpoint);
|
||||
assert!(conn.coins(CoinType::Spent).is_empty());
|
||||
|
||||
// Add a second one (this one is change), we'll get both.
|
||||
let coin_b = Coin {
|
||||
outpoint: bitcoin::OutPoint::from_str(
|
||||
@ -721,8 +729,11 @@ mod tests {
|
||||
spend_block: None,
|
||||
};
|
||||
conn.new_unspent_coins(&[coin_b]);
|
||||
let outpoints: HashSet<bitcoin::OutPoint> =
|
||||
conn.coins().into_iter().map(|c| c.outpoint).collect();
|
||||
let outpoints: HashSet<bitcoin::OutPoint> = conn
|
||||
.coins(CoinType::All)
|
||||
.into_iter()
|
||||
.map(|c| c.outpoint)
|
||||
.collect();
|
||||
assert!(outpoints.contains(&coin_a.outpoint));
|
||||
assert!(outpoints.contains(&coin_b.outpoint));
|
||||
|
||||
@ -738,11 +749,15 @@ mod tests {
|
||||
assert!(coins.iter().any(|c| c.outpoint == coin_a.outpoint));
|
||||
assert!(coins.iter().any(|c| c.outpoint == coin_b.outpoint));
|
||||
|
||||
// They are both unspent
|
||||
assert_eq!(conn.coins(CoinType::Unspent).len(), 2);
|
||||
assert!(conn.coins(CoinType::Spent).is_empty());
|
||||
|
||||
// Now if we confirm one, it'll be marked as such.
|
||||
let height = 174500;
|
||||
let time = 174500;
|
||||
conn.confirm_coins(&[(coin_a.outpoint, height, time)]);
|
||||
let coins = conn.coins();
|
||||
let coins = conn.coins(CoinType::All);
|
||||
assert_eq!(coins[0].block_height, Some(height));
|
||||
assert_eq!(coins[0].block_time, Some(time));
|
||||
assert!(coins[1].block_height.is_none());
|
||||
@ -753,14 +768,18 @@ mod tests {
|
||||
coin_a.outpoint,
|
||||
bitcoin::Txid::from_slice(&[0; 32][..]).unwrap(),
|
||||
)]);
|
||||
let coins_map: HashMap<bitcoin::OutPoint, DbCoin> =
|
||||
conn.coins().into_iter().map(|c| (c.outpoint, c)).collect();
|
||||
let coins_map: HashMap<bitcoin::OutPoint, DbCoin> = conn
|
||||
.coins(CoinType::All)
|
||||
.into_iter()
|
||||
.map(|c| (c.outpoint, c))
|
||||
.collect();
|
||||
assert!(coins_map
|
||||
.get(&coin_a.outpoint)
|
||||
.unwrap()
|
||||
.spend_txid
|
||||
.is_some());
|
||||
|
||||
// We will see it as 'spending'
|
||||
let outpoints: HashSet<bitcoin::OutPoint> = conn
|
||||
.list_spending_coins()
|
||||
.into_iter()
|
||||
@ -768,6 +787,10 @@ mod tests {
|
||||
.collect();
|
||||
assert!(outpoints.contains(&coin_a.outpoint));
|
||||
|
||||
// The first one is spent, not the second one.
|
||||
assert_eq!(conn.coins(CoinType::Spent)[0].outpoint, coin_a.outpoint);
|
||||
assert_eq!(conn.coins(CoinType::Unspent)[0].outpoint, coin_b.outpoint);
|
||||
|
||||
// Now if we confirm the spend.
|
||||
let height = 128_097;
|
||||
let time = 3_000_000;
|
||||
|
||||
@ -487,6 +487,17 @@ impl MultipathDescriptor {
|
||||
.expect("Cannot fail for P2WSH")
|
||||
}
|
||||
|
||||
/// Get the maximum size in vbytes (rounded up) of a satisfaction for this descriptor.
|
||||
pub fn max_sat_vbytes(&self) -> usize {
|
||||
self.multi_desc
|
||||
.max_satisfaction_weight()
|
||||
.expect("Cannot fail for P2WSH")
|
||||
.checked_add(WITNESS_FACTOR - 1)
|
||||
.unwrap()
|
||||
.checked_div(WITNESS_FACTOR)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Get the maximum size in virtual bytes of the whole input in a transaction spending
|
||||
/// a coin with this Script.
|
||||
pub fn spender_input_size(&self) -> usize {
|
||||
@ -588,13 +599,6 @@ impl DerivedInheritanceDescriptor {
|
||||
.map(|k| (k.key.inner, (k.origin.0, 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)]
|
||||
@ -667,7 +671,6 @@ mod tests {
|
||||
der_desc.script_pubkey();
|
||||
der_desc.witness_script();
|
||||
assert!(!der_desc.bip32_derivations().is_empty());
|
||||
assert!(!der_desc.max_sat_weight() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -684,17 +687,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn inheritance_descriptor_sat_size() {
|
||||
let secp = secp256k1::Secp256k1::verification_only();
|
||||
let desc = MultipathDescriptor::from_str("wsh(or_d(pk([92162c45]tpubD6NzVbkrYhZ4WzTf9SsD6h7AH7oQEippXK2KP8qvhMMqFoNeN5YFVi7vRyeRSDGtgd2bPyMxUNmHui8t5yCgszxPPxMafu1VVzDpg9aruYW/<0;1>/*),and_v(v:pkh(tpubD6NzVbkrYhZ4Wdgu2yfdmrce5g4fiH1ZLmKhewsnNKupbi4sxjH1ZVAorkBLWSkhsjhg8kiq8C4BrBjMy3SjAKDyDdbuvUa1ToAHbiR98js/<0;1>/*),older(2))))#uact7s3g").unwrap();
|
||||
let receive_desc = desc.receive_descriptor();
|
||||
let change_desc = desc.change_descriptor();
|
||||
|
||||
// Derived or not the expected maximum satisfaction size should be the same for
|
||||
// the change and receive descriptor.
|
||||
assert_eq!(
|
||||
receive_desc.derive(999.into(), &secp).max_sat_weight(),
|
||||
change_desc.derive(999.into(), &secp).max_sat_weight()
|
||||
);
|
||||
assert_eq!(desc.max_sat_vbytes(), (1 + 69 + 1 + 34 + 73 + 3) / 4); // See the stack details below.
|
||||
|
||||
// Maximum input size is (txid + vout + scriptsig + nSequence + max_sat).
|
||||
// Where max_sat is:
|
||||
|
||||
@ -39,8 +39,7 @@ fn create_spend(control: &DaemonControl, params: Params) -> Result<serde_json::V
|
||||
let feerate: u64 = params
|
||||
.get(2, "feerate")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'feerate' parameter."))?
|
||||
.as_i64()
|
||||
.and_then(|i| i.try_into().ok())
|
||||
.as_u64()
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'feerate' parameter."))?;
|
||||
|
||||
let res = control.create_spend(&destinations, &outpoints, feerate)?;
|
||||
@ -139,6 +138,23 @@ fn start_rescan(control: &DaemonControl, params: Params) -> Result<serde_json::V
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
|
||||
fn create_recovery(control: &DaemonControl, params: Params) -> Result<serde_json::Value, Error> {
|
||||
let address = params
|
||||
.get(0, "address")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'address' parameter."))?
|
||||
.as_str()
|
||||
.and_then(|s| bitcoin::Address::from_str(s).ok())
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'address' parameter."))?;
|
||||
let feerate: u64 = params
|
||||
.get(1, "feerate")
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'feerate' parameter."))?
|
||||
.as_u64()
|
||||
.ok_or_else(|| Error::invalid_params("Invalid 'feerate' parameter."))?;
|
||||
|
||||
let res = control.create_recovery(address, feerate)?;
|
||||
Ok(serde_json::json!(&res))
|
||||
}
|
||||
|
||||
/// Handle an incoming JSONRPC2 request.
|
||||
pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response, Error> {
|
||||
let result = match req.method.as_str() {
|
||||
@ -148,6 +164,12 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response,
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?;
|
||||
broadcast_spend(control, params)?
|
||||
}
|
||||
"createrecovery" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params("Missing 'address' and 'feerate' parameters.")
|
||||
})?;
|
||||
create_recovery(control, params)?
|
||||
}
|
||||
"createspend" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params(
|
||||
@ -165,7 +187,23 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response,
|
||||
"getinfo" => serde_json::json!(&control.get_info()),
|
||||
"getnewaddress" => serde_json::json!(&control.get_new_address()),
|
||||
"listcoins" => serde_json::json!(&control.list_coins()),
|
||||
"listconfirmed" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params(
|
||||
"The 'listconfirmed' command requires 3 parameters: 'start', 'end' and 'limit'",
|
||||
)
|
||||
})?;
|
||||
list_confirmed(control, params)?
|
||||
}
|
||||
"listspendtxs" => serde_json::json!(&control.list_spend()),
|
||||
"listtransactions" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params(
|
||||
"The 'listtransactions' command requires 1 parameter: 'txids'",
|
||||
)
|
||||
})?;
|
||||
list_transactions(control, params)?
|
||||
}
|
||||
"startrescan" => {
|
||||
let params = req
|
||||
.params
|
||||
@ -179,22 +217,6 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response,
|
||||
.ok_or_else(|| Error::invalid_params("Missing 'psbt' parameter."))?;
|
||||
update_spend(control, params)?
|
||||
}
|
||||
"listconfirmed" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params(
|
||||
"The 'listconfirmed' command requires 3 parameters: 'start', 'end' and 'limit'",
|
||||
)
|
||||
})?;
|
||||
list_confirmed(control, params)?
|
||||
}
|
||||
"listtransactions" => {
|
||||
let params = req.params.ok_or_else(|| {
|
||||
Error::invalid_params(
|
||||
"The 'listtransactions' command requires 1 parameter: 'txids'",
|
||||
)
|
||||
})?;
|
||||
list_transactions(control, params)?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::method_not_found());
|
||||
}
|
||||
|
||||
@ -161,7 +161,8 @@ impl From<commands::CommandError> for Error {
|
||||
| commands::CommandError::UnknownSpend(..)
|
||||
| commands::CommandError::SpendFinalization(..)
|
||||
| commands::CommandError::InsaneRescanTimestamp(..)
|
||||
| commands::CommandError::AlreadyRescanning => {
|
||||
| commands::CommandError::AlreadyRescanning
|
||||
| commands::CommandError::RecoveryNotAvailable => {
|
||||
Error::new(ErrorCode::InvalidParams, e.to_string())
|
||||
}
|
||||
commands::CommandError::FetchingTransaction(..)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
bitcoin::{BitcoinInterface, Block, BlockChainTip, UTxO},
|
||||
config::{BitcoinConfig, Config},
|
||||
database::{Coin, DatabaseConnection, DatabaseInterface, SpendBlock},
|
||||
database::{Coin, CoinType, DatabaseConnection, DatabaseInterface, SpendBlock},
|
||||
descriptors, DaemonHandle,
|
||||
};
|
||||
|
||||
@ -189,8 +189,19 @@ impl DatabaseConnection for DummyDatabase {
|
||||
self.db.write().unwrap().change_index = index;
|
||||
}
|
||||
|
||||
fn coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
self.db.read().unwrap().coins.clone()
|
||||
fn coins(&mut self, coin_type: CoinType) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
let coins = self.db.read().unwrap().coins.clone();
|
||||
match coin_type {
|
||||
CoinType::All => coins,
|
||||
CoinType::Unspent => coins
|
||||
.into_iter()
|
||||
.filter(|(_, c)| c.spend_txid.is_none())
|
||||
.collect(),
|
||||
CoinType::Spent => coins
|
||||
.into_iter()
|
||||
.filter(|(_, c)| c.spend_txid.is_some())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_spending_coins(&mut self) -> HashMap<bitcoin::OutPoint, Coin> {
|
||||
|
||||
@ -119,12 +119,17 @@ def lianad(bitcoind, directory):
|
||||
bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
|
||||
|
||||
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}/<0;1>/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/<0;1>/*),older(65000))))")
|
||||
recovery_hd = BIP32.from_seed(os.urandom(32), network="test")
|
||||
owner_xpub, recovery_xpub = owner_hd.get_xpub(), recovery_hd.get_xpub()
|
||||
csv_value = 10
|
||||
main_desc = Descriptor.from_str(
|
||||
f"wsh(or_d(pk({owner_xpub}/<0;1>/*),and_v(v:pkh({recovery_xpub}/<0;1>/*),older({csv_value}))))"
|
||||
)
|
||||
|
||||
lianad = Lianad(
|
||||
datadir,
|
||||
owner_hd,
|
||||
recovery_hd,
|
||||
main_desc,
|
||||
bitcoind.rpcport,
|
||||
bitcoind_cookie,
|
||||
|
||||
@ -30,6 +30,7 @@ class Lianad(TailableProc):
|
||||
self,
|
||||
datadir,
|
||||
owner_hd,
|
||||
recovery_hd,
|
||||
multi_desc,
|
||||
bitcoind_rpc_port,
|
||||
bitcoind_cookie_path,
|
||||
@ -40,6 +41,7 @@ class Lianad(TailableProc):
|
||||
self.prefix = os.path.split(datadir)[-1]
|
||||
|
||||
self.owner_hd = owner_hd
|
||||
self.recovery_hd = recovery_hd
|
||||
self.multi_desc = multi_desc
|
||||
self.receive_desc, self.change_desc = multi_desc.singlepath_descriptors()
|
||||
|
||||
@ -63,15 +65,20 @@ class Lianad(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 will fill the 'partial_sigs' field of all inputs.
|
||||
def sign_psbt(self, psbt, recovery=False):
|
||||
"""Sign a transaction.
|
||||
|
||||
This will fill the 'partial_sigs' field of all inputs. Uses either the 'primary'
|
||||
'recovery' key as specified.
|
||||
|
||||
:param psbt: PSBT of the transaction to be signed.
|
||||
:returns: PSBT with a signature in each input for the owner's key.
|
||||
"""
|
||||
assert isinstance(psbt, PSBT)
|
||||
|
||||
# Which key to sign the transaction with.
|
||||
hd = self.recovery_hd if recovery else self.owner_hd
|
||||
|
||||
# Sign each input.
|
||||
for i, psbt_in in enumerate(psbt.i):
|
||||
# First, gather the needed information from the PSBT input.
|
||||
@ -84,12 +91,9 @@ class Lianad(TailableProc):
|
||||
]
|
||||
script_code = psbt_in.map[PSBT_IN_WITNESS_SCRIPT]
|
||||
|
||||
# Now sign the transaction with the key of the "owner" (the participant that
|
||||
# can sign immediately without a timelock)
|
||||
# Now sign the transaction.
|
||||
sighash = sighash_all_witness(script_code, psbt, i)
|
||||
privkey = coincurve.PrivateKey(
|
||||
self.owner_hd.get_privkey_from_path(der_path)
|
||||
)
|
||||
privkey = coincurve.PrivateKey(hd.get_privkey_from_path(der_path))
|
||||
pubkey = privkey.public_key.format()
|
||||
assert pubkey in psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), (
|
||||
der_path,
|
||||
|
||||
@ -75,6 +75,14 @@ def spend_coins(lianad, bitcoind, coins):
|
||||
return tx
|
||||
|
||||
|
||||
def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False):
|
||||
"""Sign a PSBT, finalize it, extract the transaction and broadcast it."""
|
||||
signed_psbt = lianad.sign_psbt(psbt, recovery)
|
||||
finalized_psbt = lianad.finalize_psbt(signed_psbt)
|
||||
tx = finalized_psbt.tx.serialize_with_witness().hex()
|
||||
return bitcoind.rpc.sendrawtransaction(tx)
|
||||
|
||||
|
||||
class RpcError(ValueError):
|
||||
def __init__(self, method: str, params: dict, error: str):
|
||||
super(ValueError, self).__init__(
|
||||
|
||||
@ -8,7 +8,14 @@ from test_framework.serializations import (
|
||||
PSBT_IN_PARTIAL_SIG,
|
||||
PSBT_IN_NON_WITNESS_UTXO,
|
||||
)
|
||||
from test_framework.utils import wait_for, COIN, RpcError, get_txid, spend_coins
|
||||
from test_framework.utils import (
|
||||
wait_for,
|
||||
COIN,
|
||||
RpcError,
|
||||
get_txid,
|
||||
spend_coins,
|
||||
sign_and_broadcast,
|
||||
)
|
||||
|
||||
|
||||
def test_getinfo(lianad):
|
||||
@ -123,10 +130,7 @@ def test_create_spend(lianad, bitcoind):
|
||||
)
|
||||
|
||||
# We can sign it and broadcast it.
|
||||
signed_psbt = lianad.sign_psbt(PSBT.from_base64(res["psbt"]))
|
||||
finalized_psbt = lianad.finalize_psbt(signed_psbt)
|
||||
tx = finalized_psbt.tx.serialize_with_witness().hex()
|
||||
bitcoind.rpc.sendrawtransaction(tx)
|
||||
sign_and_broadcast(lianad, bitcoind, PSBT.from_base64(res["psbt"]))
|
||||
|
||||
|
||||
def test_list_spend(lianad, bitcoind):
|
||||
@ -533,3 +537,51 @@ def test_listtransactions(lianad, bitcoind):
|
||||
assert len(txs) == 3
|
||||
bit_txids = set(bitcoind.rpc.decoderawtransaction(tx["tx"])["txid"] for tx in txs)
|
||||
assert bit_txids == txids
|
||||
|
||||
|
||||
def test_create_recovery(lianad, bitcoind):
|
||||
"""Test the sweep of coins that are available through the timelocked path."""
|
||||
# Start by getting a few coins
|
||||
destinations = {
|
||||
lianad.rpc.getnewaddress()["address"]: 0.1,
|
||||
lianad.rpc.getnewaddress()["address"]: 0.2,
|
||||
lianad.rpc.getnewaddress()["address"]: 0.3,
|
||||
}
|
||||
txid = bitcoind.rpc.sendmany("", destinations)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(
|
||||
lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount()
|
||||
)
|
||||
|
||||
# There's nothing to sweep
|
||||
with pytest.raises(
|
||||
RpcError,
|
||||
match="No coin currently available through the timelocked recovery path",
|
||||
):
|
||||
lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2)
|
||||
|
||||
# Receive another coin, it will be one block after the others
|
||||
txid = bitcoind.rpc.sendtoaddress(lianad.rpc.getnewaddress()["address"], 0.4)
|
||||
|
||||
# Make the timelock of the 3 first coins mature (we use a csv of 10 in the fixture)
|
||||
bitcoind.generate_block(9, wait_for_mempool=txid)
|
||||
|
||||
# Now we can create a recovery tx that sweeps the first 3 coins.
|
||||
res = lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 18)
|
||||
reco_psbt = PSBT.from_base64(res["psbt"])
|
||||
assert len(reco_psbt.tx.vin) == 3, "The last coin's timelock hasn't matured yet"
|
||||
assert len(reco_psbt.tx.vout) == 1
|
||||
assert int(0.5999 * COIN) < int(reco_psbt.tx.vout[0].nValue) < int(0.6 * COIN)
|
||||
txid = sign_and_broadcast(lianad, bitcoind, reco_psbt, recovery=True)
|
||||
|
||||
# And by mining one more block we'll be able to sweep the last coin.
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(
|
||||
lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount()
|
||||
)
|
||||
res = lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 1)
|
||||
reco_psbt = PSBT.from_base64(res["psbt"])
|
||||
assert len(reco_psbt.tx.vin) == 1
|
||||
assert len(reco_psbt.tx.vout) == 1
|
||||
assert int(0.39999 * COIN) < int(reco_psbt.tx.vout[0].nValue) < int(0.4 * COIN)
|
||||
sign_and_broadcast(lianad, bitcoind, reco_psbt, recovery=True)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user