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.
This commit is contained in:
Antoine Poinsot 2022-09-10 15:02:31 +02:00
parent 7468a7fcfb
commit 7d015bcf43
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
7 changed files with 237 additions and 3 deletions

View File

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

View File

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

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

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

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