diff --git a/doc/API.md b/doc/API.md index a9e089bc..75b18a4a 100644 --- a/doc/API.md +++ b/doc/API.md @@ -12,6 +12,7 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`. | [`getnewaddress`](#getnewaddress) | Get a new receiving address | | [`listspendtxs`](#listspendtxs) | List all stored Spend transactions | | [`delspendtx`](#delspendtx) | Delete a stored Spend transaction | +| [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it | # Reference @@ -182,3 +183,19 @@ This command does not return anything for now. | Field | Type | Description | | -------------- | --------- | ---------------------------------------------------- | + + +### `broadcastspend` + +#### Request + +| Field | Type | Description | +| -------- | ------ | ------------------------------------------------------ | +| `txid` | string | Hex encoded txid of the Spend transaction to broadcast | + +#### Response + +This command does not return anything for now. + +| Field | Type | Description | +| -------------- | --------- | ---------------------------------------------------- | diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 740ed36a..dd0e3a8a 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -72,9 +72,27 @@ fn delete_spend(control: &DaemonControl, params: Params) -> Result Result { + let txid = params + .get(0, "txid") + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))? + .as_str() + .and_then(|s| bitcoin::Txid::from_str(s).ok()) + .ok_or_else(|| Error::invalid_params("Invalid 'txid' parameter."))?; + control.broadcast_spend(&txid)?; + + Ok(serde_json::json!({})) +} + /// Handle an incoming JSONRPC2 request. pub fn handle_request(control: &DaemonControl, req: Request) -> Result { let result = match req.method.as_str() { + "broadcastspend" => { + let params = req + .params + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?; + broadcast_spend(control, params)? + } "createspend" => { let params = req.params.ok_or_else(|| { Error::invalid_params( diff --git a/tests/test_framework/minisafed.py b/tests/test_framework/minisafed.py index 7568c118..014fc990 100644 --- a/tests/test_framework/minisafed.py +++ b/tests/test_framework/minisafed.py @@ -57,15 +57,14 @@ class Minisafed(TailableProc): 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. + This will fill the 'partial_sigs' field of all inputs. :param psbt: PSBT of the transaction to be signed. - :returns: the serialized valid transaction, as hex. + :returns: PSBT with a signature in each input for the owner's key. """ assert isinstance(psbt, PSBT) - # Create a witness for each input of the transaction. + # Sign each input. 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)} @@ -82,13 +81,31 @@ class Minisafed(TailableProc): 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()}") + psbt_in.partial_sigs[pubkey] = sig + + return psbt + + def finalize_psbt(self, psbt): + """Create a valid witness for all inputs in the PSBT. + This will fail if the PSBT input does not contain enough material. + + :param psbt: PSBT of the transaction to be finalized. + :returns: PSBT with finalized inputs. + """ + 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] # 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}, + signatures=psbt_in.partial_sigs, ) stack = desc.satisfy(sat_material) logging.debug(f"Satisfaction for {desc} is {[e.hex() for e in stack]}") @@ -98,9 +115,7 @@ class Minisafed(TailableProc): 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 + return psbt def start(self): TailableProc.start(self) diff --git a/tests/test_framework/utils.py b/tests/test_framework/utils.py index c48b0d58..0afbbd51 100644 --- a/tests/test_framework/utils.py +++ b/tests/test_framework/utils.py @@ -69,7 +69,9 @@ def spend_coins(minisafed, bitcoind, coins): psbt = PSBT() psbt.deserialize(res["psbt"]) - tx = minisafed.sign_psbt(psbt) + signed_psbt = minisafed.sign_psbt(psbt) + finalized_psbt = minisafed.finalize_psbt(signed_psbt) + tx = finalized_psbt.tx.serialize_with_witness().hex() bitcoind.rpc.sendrawtransaction(tx) return tx @@ -184,6 +186,7 @@ class UnixDomainSocketRpc(object): We might still want to define the actual methods in the subclasses for documentation purposes. """ + def wrapper(*args, **kwargs): if len(args) != 0 and len(kwargs) != 0: raise RpcError( diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 44e0c06e..087a7ce4 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -1,6 +1,8 @@ +import pytest + from fixtures import * from test_framework.serializations import PSBT -from test_framework.utils import wait_for, COIN, get_txid, spend_coins +from test_framework.utils import wait_for, COIN, RpcError, get_txid, spend_coins def test_getinfo(minisafed): @@ -108,8 +110,10 @@ def test_create_spend(minisafed, bitcoind): 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) + signed_psbt = minisafed.sign_psbt(spend_psbt) + finalized_psbt = minisafed.finalize_psbt(signed_psbt) + tx = finalized_psbt.tx.serialize_with_witness().hex() + bitcoind.rpc.sendrawtransaction(tx) def test_list_spend(minisafed, bitcoind): @@ -229,3 +233,33 @@ def test_update_spend(minisafed, bitcoind): assert len(psbt_merged.inputs[0].partial_sigs) == 2 assert psbt_merged.inputs[0].partial_sigs[dummy_pk_a] == dummy_sig_a assert psbt_merged.inputs[0].partial_sigs[dummy_pk_b] == dummy_sig_b + + +def test_broadcast_spend(minisafed, bitcoind): + # Create a new coin and a spending tx for it. + addr = minisafed.rpc.getnewaddress()["address"] + bitcoind.rpc.sendtoaddress(addr, 0.2567) + wait_for(lambda: len(minisafed.rpc.listcoins()["coins"]) > 0) + outpoints = [c["outpoint"] for c in minisafed.rpc.listcoins()["coins"]] + destinations = { + bitcoind.rpc.getnewaddress(): 200_000, + } + res = minisafed.rpc.createspend(outpoints, destinations, 6) + psbt = PSBT() + psbt.deserialize(res["psbt"]) + txid = psbt.tx.txid().hex() + + # We can't broadcast an unknown Spend + with pytest.raises(RpcError, match="Unknown spend transaction.*"): + minisafed.rpc.broadcastspend(txid) + minisafed.rpc.updatespend(res["psbt"]) + + # We can't broadcast an unsigned transaction + with pytest.raises(RpcError, match="Failed to finalize the spend transaction.*"): + minisafed.rpc.broadcastspend(txid) + signed_psbt = minisafed.sign_psbt(psbt) + minisafed.rpc.updatespend(signed_psbt.serialize()) + + # Now we've signed and stored it, the daemon will take care of finalizing + # the PSBT before broadcasting the transaction. + minisafed.rpc.broadcastspend(txid)