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:
parent
7468a7fcfb
commit
7d015bcf43
25
doc/API.md
25
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. |
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user