From c09ae3f87bc8bb2391d88d1a718b35d4a6c568fa Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Thu, 8 Dec 2022 17:09:27 +0100 Subject: [PATCH] commands, jsonrpc: add a new 'createrecovery' command This is a new command dedicated to the recovery usecase. For now it's dead simple: sweep all coins that are available through the recovery path to a given address. --- doc/API.md | 25 +++++++++ src/commands/mod.rs | 124 +++++++++++++++++++++++++++++++++++++++++++- src/jsonrpc/api.rs | 26 +++++++++- src/jsonrpc/mod.rs | 3 +- 4 files changed, 174 insertions(+), 4 deletions(-) diff --git a/doc/API.md b/doc/API.md index 71793a82..d976e673 100644 --- a/doc/API.md +++ b/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. | diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 52f45c02..f28f64a2 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -15,7 +15,7 @@ use utils::{ }; use std::{ - collections::{BTreeMap, HashMap}, + collections::{hash_map, BTreeMap, HashMap}, convert::TryInto, fmt, }; @@ -67,6 +67,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 +103,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." + ), } } } @@ -628,6 +633,117 @@ 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 { + 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 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 += desc_sat_vb(&coin_desc); + 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() as u64 + sat_vb; + 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)] @@ -708,6 +824,12 @@ pub struct TransactionInfo { pub time: Option, } +#[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::*; diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 82886372..63ca6493 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -39,8 +39,7 @@ fn create_spend(control: &DaemonControl, params: Params) -> Result Result Result { + 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 { let result = match req.method.as_str() { @@ -148,6 +164,12 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result { + 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( diff --git a/src/jsonrpc/mod.rs b/src/jsonrpc/mod.rs index 24ca83be..dddb35c7 100644 --- a/src/jsonrpc/mod.rs +++ b/src/jsonrpc/mod.rs @@ -161,7 +161,8 @@ impl From 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(..)