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.
This commit is contained in:
Antoine Poinsot 2022-12-08 17:09:27 +01:00
parent 3d5d0134b4
commit c09ae3f87b
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
4 changed files with 174 additions and 4 deletions

View File

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

View File

@ -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<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 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<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::*;

View File

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

View File

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