Merge #282: Multisig support
3ea85fa9506bc96440096a9d11cceffdbf5eab44 descriptor: introduce a helper to analyze a PSBT spending Liana coins (Antoine Poinsot) aac330ca65686ddb0d5b64778f49025e602cfa02 descriptors: rename DescCreationError in a general desc-related error (Antoine Poinsot) 92905968234053feaaad8a83a5080860779d2898 qa: test using lianad with a multisig descriptor (Antoine Poinsot) 3c82173f468a099d298514ddfe2bd376ad9f08c8 qa: abstract the signer from the Lianad class (Antoine Poinsot) e22e30dc8d3f0c99a0f176857f35ac5ed60a34f4 descriptors: support Multisig in both the primary and recovery paths (Antoine Poinsot) Pull request description: This permits the creation of a Liana descriptor containing a multisig in either of the two spending paths. The multisig is currently restricted at creation time to be a CHECKUMULTISIG-like multisig (k-of-n with n<20). See the linked issue for rationale. Closes https://github.com/wizardsardine/liana/issues/53. ACKs for top commit: edouardparis: ACK 3ea85fa9506bc96440096a9d11cceffdbf5eab44 Tree-SHA512: b95402b4483e9b282680288b6515049c3ab7ac0debe7659b39768e4b85de8e4b3d6000a2de8b633c47f7f7db49593a23853028f481a09fc13f3d9d574f7cca51
This commit is contained in:
commit
46de2e07a8
File diff suppressed because one or more lines are too long
@ -390,10 +390,9 @@ impl DummyLiana {
|
||||
poll_interval_secs: time::Duration::from_secs(2),
|
||||
};
|
||||
|
||||
let owner_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*").unwrap();
|
||||
let heir_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap();
|
||||
let desc =
|
||||
crate::descriptors::MultipathDescriptor::new(owner_key, heir_key, 10_000).unwrap();
|
||||
let owner_key = descriptors::LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*").unwrap());
|
||||
let heir_key = descriptors::LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap());
|
||||
let desc = descriptors::MultipathDescriptor::new(owner_key, heir_key, 10_000).unwrap();
|
||||
let config = Config {
|
||||
bitcoin_config,
|
||||
bitcoind_config: None,
|
||||
|
||||
@ -3,6 +3,7 @@ from bip380.descriptors import Descriptor
|
||||
from concurrent import futures
|
||||
from test_framework.bitcoind import Bitcoind
|
||||
from test_framework.lianad import Lianad
|
||||
from test_framework.signer import SingleSigner, MultiSigner
|
||||
from test_framework.utils import (
|
||||
EXECUTOR_WORKERS,
|
||||
)
|
||||
@ -118,18 +119,63 @@ def lianad(bitcoind, directory):
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
|
||||
|
||||
owner_hd = BIP32.from_seed(os.urandom(32), network="test")
|
||||
recovery_hd = BIP32.from_seed(os.urandom(32), network="test")
|
||||
owner_xpub, recovery_xpub = owner_hd.get_xpub(), recovery_hd.get_xpub()
|
||||
signer = SingleSigner()
|
||||
primary_xpub, recovery_xpub = (
|
||||
signer.primary_hd.get_xpub(),
|
||||
signer.recovery_hd.get_xpub(),
|
||||
)
|
||||
csv_value = 10
|
||||
main_desc = Descriptor.from_str(
|
||||
f"wsh(or_d(pk({owner_xpub}/<0;1>/*),and_v(v:pkh({recovery_xpub}/<0;1>/*),older({csv_value}))))"
|
||||
f"wsh(or_d(pk({primary_xpub}/<0;1>/*),and_v(v:pkh({recovery_xpub}/<0;1>/*),older({csv_value}))))"
|
||||
)
|
||||
|
||||
lianad = Lianad(
|
||||
datadir,
|
||||
owner_hd,
|
||||
recovery_hd,
|
||||
signer,
|
||||
main_desc,
|
||||
bitcoind.rpcport,
|
||||
bitcoind_cookie,
|
||||
)
|
||||
|
||||
try:
|
||||
lianad.start()
|
||||
yield lianad
|
||||
except Exception:
|
||||
lianad.cleanup()
|
||||
raise
|
||||
|
||||
lianad.cleanup()
|
||||
|
||||
|
||||
def multi_expression(thresh, keys):
|
||||
exp = f"multi({thresh},"
|
||||
for i, key in enumerate(keys):
|
||||
exp += f"{key.get_xpub()}/<0;1>/*"
|
||||
if i != len(keys) - 1:
|
||||
exp += ","
|
||||
return exp + ")"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lianad_multisig(bitcoind, directory):
|
||||
datadir = os.path.join(directory, "lianad")
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
|
||||
|
||||
# A 3-of-4 that degrades into a 2-of-5 after 10 blocks
|
||||
signer = MultiSigner(3, 4, 2, 5)
|
||||
csv_value = 10
|
||||
prim_multi, recov_multi = (
|
||||
multi_expression(signer.prim_thresh, signer.prim_hds),
|
||||
multi_expression(signer.recov_thresh, signer.recov_hds),
|
||||
)
|
||||
main_desc = Descriptor.from_str(
|
||||
f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))"
|
||||
)
|
||||
|
||||
lianad = Lianad(
|
||||
datadir,
|
||||
signer,
|
||||
main_desc,
|
||||
bitcoind.rpcport,
|
||||
bitcoind_cookie,
|
||||
|
||||
@ -2,7 +2,6 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from bip32.utils import coincurve
|
||||
from bip380.descriptors import Descriptor
|
||||
from bip380.miniscript import SatisfactionMaterial
|
||||
from test_framework.utils import (
|
||||
@ -15,11 +14,9 @@ from test_framework.utils import (
|
||||
)
|
||||
from test_framework.serializations import (
|
||||
PSBT,
|
||||
sighash_all_witness,
|
||||
CTxInWitness,
|
||||
CScriptWitness,
|
||||
PSBT_IN_BIP32_DERIVATION,
|
||||
PSBT_IN_WITNESS_SCRIPT,
|
||||
PSBT_IN_PARTIAL_SIG,
|
||||
PSBT_IN_FINAL_SCRIPTWITNESS,
|
||||
)
|
||||
@ -29,8 +26,7 @@ class Lianad(TailableProc):
|
||||
def __init__(
|
||||
self,
|
||||
datadir,
|
||||
owner_hd,
|
||||
recovery_hd,
|
||||
signer,
|
||||
multi_desc,
|
||||
bitcoind_rpc_port,
|
||||
bitcoind_cookie_path,
|
||||
@ -40,8 +36,7 @@ class Lianad(TailableProc):
|
||||
self.datadir = datadir
|
||||
self.prefix = os.path.split(datadir)[-1]
|
||||
|
||||
self.owner_hd = owner_hd
|
||||
self.recovery_hd = recovery_hd
|
||||
self.signer = signer
|
||||
self.multi_desc = multi_desc
|
||||
self.receive_desc, self.change_desc = multi_desc.singlepath_descriptors()
|
||||
|
||||
@ -65,51 +60,6 @@ class Lianad(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, recovery=False):
|
||||
"""Sign a transaction.
|
||||
|
||||
This will fill the 'partial_sigs' field of all inputs. Uses either the 'primary'
|
||||
'recovery' key as specified.
|
||||
|
||||
:param psbt: PSBT of the transaction to be signed.
|
||||
:returns: PSBT with a signature in each input for the owner's key.
|
||||
"""
|
||||
assert isinstance(psbt, PSBT)
|
||||
|
||||
# Which key to sign the transaction with.
|
||||
hd = self.recovery_hd if recovery else self.owner_hd
|
||||
|
||||
# Sign each input.
|
||||
for i, psbt_in in enumerate(psbt.i):
|
||||
# First, gather the needed information from the PSBT input.
|
||||
# 'hd_keypaths' is of the form {pubkey: (fingerprint (4 bytes), derivation path (n * 4 bytes))}
|
||||
fing_der = next(iter(psbt_in.map[PSBT_IN_BIP32_DERIVATION].values()))
|
||||
raw_der_path = fing_der[4:]
|
||||
der_path = [
|
||||
int.from_bytes(raw_der_path[i : i + 4], byteorder="little", signed=True)
|
||||
for i in range(0, len(raw_der_path), 4)
|
||||
]
|
||||
script_code = psbt_in.map[PSBT_IN_WITNESS_SCRIPT]
|
||||
|
||||
# Now sign the transaction.
|
||||
sighash = sighash_all_witness(script_code, psbt, i)
|
||||
privkey = coincurve.PrivateKey(hd.get_privkey_from_path(der_path))
|
||||
pubkey = privkey.public_key.format()
|
||||
assert pubkey in psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), (
|
||||
der_path,
|
||||
fing_der,
|
||||
pubkey,
|
||||
psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(),
|
||||
)
|
||||
sig = privkey.sign(sighash, hasher=None) + b"\x01"
|
||||
logging.debug(
|
||||
f"Adding signature {sig.hex()} for pubkey {pubkey.hex()} (path {der_path})"
|
||||
)
|
||||
assert PSBT_IN_PARTIAL_SIG not in psbt_in.map
|
||||
psbt_in.map[PSBT_IN_PARTIAL_SIG] = {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.
|
||||
|
||||
97
tests/test_framework/signer.py
Normal file
97
tests/test_framework/signer.py
Normal file
@ -0,0 +1,97 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from bip32 import BIP32
|
||||
from bip32.utils import coincurve
|
||||
from test_framework.serializations import (
|
||||
PSBT,
|
||||
sighash_all_witness,
|
||||
PSBT_IN_BIP32_DERIVATION,
|
||||
PSBT_IN_WITNESS_SCRIPT,
|
||||
PSBT_IN_PARTIAL_SIG,
|
||||
)
|
||||
|
||||
|
||||
def sign_psbt(psbt, hds):
|
||||
"""Sign a transaction.
|
||||
|
||||
This will fill the 'partial_sigs' field of all inputs.
|
||||
|
||||
:param psbt: PSBT of the transaction to be signed.
|
||||
:param hds: the BIP32 objects to sign the transaction with.
|
||||
:returns: PSBT with a signature in each input for the given keys.
|
||||
"""
|
||||
assert isinstance(psbt, PSBT)
|
||||
|
||||
# Sign each input.
|
||||
for i, psbt_in in enumerate(psbt.i):
|
||||
# First, gather the needed information from the PSBT input.
|
||||
# 'hd_keypaths' is of the form {pubkey: (fingerprint (4 bytes), derivation path (n * 4 bytes))}
|
||||
fing_der = next(iter(psbt_in.map[PSBT_IN_BIP32_DERIVATION].values()))
|
||||
raw_der_path = fing_der[4:]
|
||||
der_path = [
|
||||
int.from_bytes(raw_der_path[i : i + 4], byteorder="little", signed=True)
|
||||
for i in range(0, len(raw_der_path), 4)
|
||||
]
|
||||
script_code = psbt_in.map[PSBT_IN_WITNESS_SCRIPT]
|
||||
|
||||
# Now sign the transaction for all the given keys.
|
||||
for hd in hds:
|
||||
sighash = sighash_all_witness(script_code, psbt, i)
|
||||
privkey = coincurve.PrivateKey(hd.get_privkey_from_path(der_path))
|
||||
pubkey = privkey.public_key.format()
|
||||
assert pubkey in psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), (
|
||||
der_path,
|
||||
fing_der,
|
||||
pubkey,
|
||||
psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(),
|
||||
)
|
||||
sig = privkey.sign(sighash, hasher=None) + b"\x01"
|
||||
logging.debug(
|
||||
f"Adding signature {sig.hex()} for pubkey {pubkey.hex()} (path {der_path})"
|
||||
)
|
||||
if PSBT_IN_PARTIAL_SIG not in psbt_in.map:
|
||||
psbt_in.map[PSBT_IN_PARTIAL_SIG] = {pubkey: sig}
|
||||
else:
|
||||
psbt_in.map[PSBT_IN_PARTIAL_SIG][pubkey] = sig
|
||||
|
||||
return psbt
|
||||
|
||||
|
||||
class SingleSigner:
|
||||
def __init__(self):
|
||||
self.primary_hd = BIP32.from_seed(os.urandom(32), network="test")
|
||||
self.recovery_hd = BIP32.from_seed(os.urandom(32), network="test")
|
||||
|
||||
def sign_psbt(self, psbt, recovery=False):
|
||||
"""Sign a transaction.
|
||||
|
||||
This will fill the 'partial_sigs' field of all inputs. Uses either the 'primary'
|
||||
'recovery' key as specified.
|
||||
|
||||
:param psbt: PSBT of the transaction to be signed.
|
||||
:returns: PSBT with a signature in each input for the specified key.
|
||||
"""
|
||||
return sign_psbt(psbt, [self.recovery_hd if recovery else self.primary_hd])
|
||||
|
||||
|
||||
class MultiSigner:
|
||||
def __init__(
|
||||
self, primary_thresh, primary_hds_count, recovery_thresh, recovery_hds_count
|
||||
):
|
||||
self.prim_thresh = primary_thresh
|
||||
self.prim_hds = [
|
||||
BIP32.from_seed(os.urandom(32), network="test")
|
||||
for _ in range(primary_hds_count)
|
||||
]
|
||||
self.recov_thresh = recovery_thresh
|
||||
self.recov_hds = [
|
||||
BIP32.from_seed(os.urandom(32), network="test")
|
||||
for _ in range(recovery_hds_count)
|
||||
]
|
||||
|
||||
def sign_psbt(self, psbt, key_indices, recovery=False):
|
||||
"""Sign a transaction with the keys at the specified indices."""
|
||||
hds = self.recov_hds if recovery else self.prim_hds
|
||||
hds = [hds[i] for i in key_indices]
|
||||
return sign_psbt(psbt, hds)
|
||||
@ -67,7 +67,7 @@ def spend_coins(lianad, bitcoind, coins):
|
||||
}
|
||||
res = lianad.rpc.createspend(destinations, [c["outpoint"] for c in coins], 1)
|
||||
|
||||
signed_psbt = lianad.sign_psbt(PSBT.from_base64(res["psbt"]))
|
||||
signed_psbt = lianad.signer.sign_psbt(PSBT.from_base64(res["psbt"]))
|
||||
finalized_psbt = lianad.finalize_psbt(signed_psbt)
|
||||
tx = finalized_psbt.tx.serialize_with_witness().hex()
|
||||
bitcoind.rpc.sendrawtransaction(tx)
|
||||
@ -77,7 +77,7 @@ def spend_coins(lianad, bitcoind, coins):
|
||||
|
||||
def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False):
|
||||
"""Sign a PSBT, finalize it, extract the transaction and broadcast it."""
|
||||
signed_psbt = lianad.sign_psbt(psbt, recovery)
|
||||
signed_psbt = lianad.signer.sign_psbt(psbt, recovery)
|
||||
finalized_psbt = lianad.finalize_psbt(signed_psbt)
|
||||
tx = finalized_psbt.tx.serialize_with_witness().hex()
|
||||
return bitcoind.rpc.sendrawtransaction(tx)
|
||||
|
||||
@ -1,5 +1,74 @@
|
||||
import pytest
|
||||
|
||||
from fixtures import *
|
||||
from test_framework.serializations import PSBT
|
||||
from test_framework.utils import wait_for, RpcError
|
||||
|
||||
|
||||
def test_startup(lianad):
|
||||
pass
|
||||
def test_multisig(lianad_multisig, bitcoind):
|
||||
"""Test using lianad with a descriptor that contains multiple keys for both
|
||||
the primary and recovery paths."""
|
||||
lianad = lianad_multisig
|
||||
|
||||
# Receive 3 coins in different blocks on different addresses.
|
||||
for _ in range(3):
|
||||
addr = lianad.rpc.getnewaddress()["address"]
|
||||
txid = bitcoind.rpc.sendtoaddress(addr, 0.01)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3)
|
||||
|
||||
print(lianad.rpc.listcoins())
|
||||
# Create a spend that will create a change output, sign and broadcast it.
|
||||
outpoints = [lianad.rpc.listcoins()["coins"][0]["outpoint"]]
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 200_000,
|
||||
}
|
||||
res = lianad.rpc.createspend(destinations, outpoints, 42)
|
||||
psbt = PSBT.from_base64(res["psbt"])
|
||||
txid = psbt.tx.txid().hex()
|
||||
signed_psbt = lianad.signer.sign_psbt(psbt, range(3))
|
||||
lianad.rpc.updatespend(signed_psbt.to_base64())
|
||||
lianad.rpc.broadcastspend(txid)
|
||||
bitcoind.generate_block(1, wait_for_mempool=txid)
|
||||
wait_for(
|
||||
lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount()
|
||||
)
|
||||
|
||||
# Spend all coins to check we can spend from change too. Re-create some deposits.
|
||||
outpoints = [
|
||||
c["outpoint"]
|
||||
for c in lianad.rpc.listcoins()["coins"]
|
||||
if c["spend_info"] is None
|
||||
]
|
||||
destinations = {
|
||||
bitcoind.rpc.getnewaddress(): 400_000,
|
||||
lianad.rpc.getnewaddress()["address"]: 300_000,
|
||||
lianad.rpc.getnewaddress()["address"]: 800_000,
|
||||
}
|
||||
res = lianad.rpc.createspend(destinations, outpoints, 42)
|
||||
psbt = PSBT.from_base64(res["psbt"])
|
||||
txid = psbt.tx.txid().hex()
|
||||
# If we sign only with two keys it won't be able to finalize
|
||||
with pytest.raises(RpcError, match="Miniscript Error: could not satisfy at index 0"):
|
||||
signed_psbt = lianad.signer.sign_psbt(psbt, range(2))
|
||||
lianad.rpc.updatespend(signed_psbt.to_base64())
|
||||
lianad.rpc.broadcastspend(txid)
|
||||
# We can sign with different keys as long as there are 3 sigs
|
||||
signed_psbt = lianad.signer.sign_psbt(psbt, range(1, 4))
|
||||
lianad.rpc.updatespend(signed_psbt.to_base64())
|
||||
lianad.rpc.broadcastspend(txid)
|
||||
|
||||
# Generate 10 blocks to test the recovery path
|
||||
bitcoind.generate_block(10, wait_for_mempool=txid)
|
||||
wait_for(
|
||||
lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount()
|
||||
)
|
||||
|
||||
# Sweep all coins through the recovery path. It needs 2 signatures out of
|
||||
# 5 keys. Sign with the second and the fifth ones.
|
||||
res = lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2)
|
||||
reco_psbt = PSBT.from_base64(res["psbt"])
|
||||
txid = reco_psbt.tx.txid().hex()
|
||||
signed_psbt = lianad.signer.sign_psbt(reco_psbt, [1, 4], recovery=True)
|
||||
lianad.rpc.updatespend(signed_psbt.to_base64())
|
||||
lianad.rpc.broadcastspend(txid)
|
||||
|
||||
@ -262,7 +262,7 @@ def test_broadcast_spend(lianad, bitcoind):
|
||||
# We can't broadcast an unsigned transaction
|
||||
with pytest.raises(RpcError, match="Failed to finalize the spend transaction.*"):
|
||||
lianad.rpc.broadcastspend(txid)
|
||||
signed_psbt = lianad.sign_psbt(PSBT.from_base64(res["psbt"]))
|
||||
signed_psbt = lianad.signer.sign_psbt(PSBT.from_base64(res["psbt"]))
|
||||
lianad.rpc.updatespend(signed_psbt.to_base64())
|
||||
|
||||
# Now we've signed and stored it, the daemon will take care of finalizing
|
||||
@ -372,7 +372,7 @@ def test_listtransactions(lianad, bitcoind):
|
||||
|
||||
def sign_and_broadcast(psbt):
|
||||
txid = psbt.tx.txid().hex()
|
||||
psbt = lianad.sign_psbt(psbt)
|
||||
psbt = lianad.signer.sign_psbt(psbt)
|
||||
lianad.rpc.updatespend(psbt.to_base64())
|
||||
lianad.rpc.broadcastspend(txid)
|
||||
return txid
|
||||
|
||||
@ -27,7 +27,7 @@ def test_spend_change(lianad, bitcoind):
|
||||
assert len(spend_psbt.tx.vout) == 3
|
||||
|
||||
# Sign and broadcast this first Spend transaction.
|
||||
signed_psbt = lianad.sign_psbt(spend_psbt)
|
||||
signed_psbt = lianad.signer.sign_psbt(spend_psbt)
|
||||
lianad.rpc.updatespend(signed_psbt.to_base64())
|
||||
spend_txid = signed_psbt.tx.txid().hex()
|
||||
lianad.rpc.broadcastspend(spend_txid)
|
||||
@ -48,7 +48,7 @@ def test_spend_change(lianad, bitcoind):
|
||||
spend_psbt = PSBT.from_base64(res["psbt"])
|
||||
|
||||
# We can sign and broadcast it.
|
||||
signed_psbt = lianad.sign_psbt(spend_psbt)
|
||||
signed_psbt = lianad.signer.sign_psbt(spend_psbt)
|
||||
lianad.rpc.updatespend(signed_psbt.to_base64())
|
||||
spend_txid = signed_psbt.tx.txid().hex()
|
||||
lianad.rpc.broadcastspend(spend_txid)
|
||||
@ -85,7 +85,7 @@ def test_coin_marked_spent(lianad, bitcoind):
|
||||
|
||||
def sign_and_broadcast(psbt):
|
||||
txid = psbt.tx.txid().hex()
|
||||
psbt = lianad.sign_psbt(psbt)
|
||||
psbt = lianad.signer.sign_psbt(psbt)
|
||||
lianad.rpc.updatespend(psbt.to_base64())
|
||||
lianad.rpc.broadcastspend(txid)
|
||||
return txid
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user