From 7d015bcf435897fa6e129b931fd2bea742ff7bc5 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sat, 10 Sep 2022 15:02:31 +0200 Subject: [PATCH] jsonrpc: add a 'createspend' RPC command Since the Spend transaction command requires some parameters, this implements the parameter-getting logic on the JSONRPC side as well. This allows us to implement an end-to-end functional test of the transaction flow using an external way to broadcast it. From the input coins creation, to the Spend transaction broadcast. --- doc/API.md | 25 +++++++++++++ src/jsonrpc/api.rs | 52 ++++++++++++++++++++++++++- src/jsonrpc/mod.rs | 40 ++++++++++++++++++++- tests/fixtures.py | 7 +++- tests/requirements.txt | 3 ++ tests/test_framework/minisafed.py | 60 +++++++++++++++++++++++++++++++ tests/test_rpc.py | 53 +++++++++++++++++++++++++++ 7 files changed, 237 insertions(+), 3 deletions(-) diff --git a/doc/API.md b/doc/API.md index 4d4f6997..1764eded 100644 --- a/doc/API.md +++ b/doc/API.md @@ -84,3 +84,28 @@ 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. | diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index b748063a..9f861edc 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -1,11 +1,61 @@ use crate::{ - jsonrpc::{Error, Request, Response}, + jsonrpc::{Error, Params, Request, Response}, DaemonControl, }; +use std::{collections::HashMap, convert::TryInto, str::FromStr}; + +use miniscript::bitcoin; + +fn create_spend(control: &DaemonControl, params: Params) -> Result { + 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::>>() + }) + .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::>>() + }) + .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)) +} + /// Handle an incoming JSONRPC2 request. pub fn handle_request(control: &DaemonControl, req: Request) -> Result { 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()), diff --git a/src/jsonrpc/mod.rs b/src/jsonrpc/mod.rs index a771e666..91683a75 100644 --- a/src/jsonrpc/mod.rs +++ b/src/jsonrpc/mod.rs @@ -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), } +impl Params { + /// Get the parameter supposed to be at a given index / of a given name. + pub fn get(&self, index: usize, name: &Q) -> Option<&serde_json::Value> + where + String: std::borrow::Borrow, + 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 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(message: impl Into) -> Error { + pub fn invalid_params(message: impl Into) -> 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 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)] diff --git a/tests/fixtures.py b/tests/fixtures.py index dad19e38..c94707ea 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -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, diff --git a/tests/requirements.txt b/tests/requirements.txt index 5afa825c..34e9dc94 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -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 diff --git a/tests/test_framework/minisafed.py b/tests/test_framework/minisafed.py index 05e36658..7568c118 100644 --- a/tests/test_framework/minisafed.py +++ b/tests/test_framework/minisafed.py @@ -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( diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 97ade6c1..e56f5cc2 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -1,4 +1,5 @@ from fixtures import * +from test_framework.serializations import PSBT from test_framework.utils import wait_for, COIN @@ -40,3 +41,55 @@ 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)