Skip to content

Commit

Permalink
descriptors: fix satisfaction size for Taproot
Browse files Browse the repository at this point in the history
This includes the sizes of the script and control block elements.
  • Loading branch information
jp1ac4 committed Nov 8, 2024
1 parent e9c6995 commit cf88aca
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 11 deletions.
57 changes: 46 additions & 11 deletions src/descriptors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use miniscript::{
secp256k1,
},
descriptor,
miniscript::satisfy::Placeholder,
plan::{Assets, CanSign},
psbt::{PsbtInputExt, PsbtOutputExt},
translate_hash_clone, ForEachKey, TranslatePk, Translator,
Expand Down Expand Up @@ -259,26 +260,35 @@ impl LianaDescriptor {
};

// Unfortunately rust-miniscript satisfaction size estimation is inconsistent. For
// Taproot it considers the whole witness (including the control block size + the
// script size) but under P2WSH it does not consider the witscript! Therefore we
// manually add the size of the witscript, but only under P2WSH by the mean of the
// `explicit_script()` helper.
// Taproot it considers the whole witness (except the control block size + the
// script size), while under P2WSH it does not consider the witscript! Therefore we
// manually add the size of the witscript under P2WSH by means of the
// `explicit_script()` helper, which gives an error for Taproot, and for Taproot
// we add the sizes of the control block and script.
let der_desc = self
.receive_desc
.0
.at_derivation_index(0)
.expect("unhardened index");
let witscript_size = der_desc
.explicit_script()
.map(|s| varint_len(s.len()) + s.len())
.unwrap_or(0);
.map(|s| varint_len(s.len()) + s.len());

// Finally, compute the satisfaction template for the primary path and get its size.
der_desc
.plan(&assets)
.expect("Always satisfiable")
.witness_size()
+ witscript_size
let plan = der_desc.plan(&assets).expect("Always satisfiable");
plan.witness_size()
+ witscript_size.unwrap_or_else(|_| {
plan.witness_template()
.iter()
.map(|elem| match elem {
// We need to calculate the size manually before calculating the varint length.
// See https://docs.rs/miniscript/11.0.0/src/miniscript/util.rs.html#35-36.
Placeholder::TapScript(s) => varint_len(s.len()),
Placeholder::TapControlBlock(cb) => varint_len(cb.serialize().len()),
_ => 0,
})
.sum()
})
} else {
// We add one to account for the witness stack size, as the values above give the
// difference in size for a satisfied input that was *already* in a transaction
Expand Down Expand Up @@ -1187,6 +1197,31 @@ mod tests {
);
}

#[test]
fn taproot_multisig_descriptor_sat_weight() {
// See https://mempool.space/signet/tx/84f09bddfe0f036d0390edf655636ad6092c3ab8f09b2bb1503caa393463f241
// for an example spend from this descriptor.
let desc = LianaDescriptor::from_str("tr(tpubD6NzVbkrYhZ4WUdbVsXDYBCXS8EPSYG1cAN9g4uP6uLQHMHXRvHSFkQBXy7MBeAvV8PDVJJ4o3AwYMKJHp45ci2g69UCAKteVSAJ61CnGEV/<0;1>/*,{and_v(v:pk([9e1c1983/48'/1'/0'/2']tpubDEWCLCMncbStq4BLXkQUAPqzzrh2tQUgYeQPt4NrB5D7gRraMyGbRqzPTmQGvqfdaFsXDVGSQBRgfXuNjDyfU626pxSjpQZszFNY6CzogxK/<2;3>/*),older(65535)),multi_a(2,[9e1c1983/48'/1'/0'/2']tpubDEWCLCMncbStq4BLXkQUAPqzzrh2tQUgYeQPt4NrB5D7gRraMyGbRqzPTmQGvqfdaFsXDVGSQBRgfXuNjDyfU626pxSjpQZszFNY6CzogxK/<0;1>/*,[3b1913e1/48'/1'/0'/2']tpubDFeZ2ezf4VUuTnjdhxJ1DKhLa2t6vzXZNz8NnEgeT2PN4pPqTCTeWUcaxKHPJcf1C8WzkLA71zSjDwuo4zqu4kkiL91ZUmJydC8f1gx89wM/<0;1>/*)})#ee0r4tw5").unwrap();
// varint_len(num witness elements) = 1
// varint_len(signature) + signature = 1 + 64
// varint_len(script) + script = 1 + 70
// varint_len(control block) + control block = 1 + 65
assert_eq!(
desc.max_sat_weight(true),
1 + (1 + 64) + (1 + 64) + (1 + 70) + (1 + 65)
);

// See https://mempool.space/signet/tx/63095cf6b5a57e5f3a7f0af0e22c8234cc4a4c1531c3236b00bd2a009f70e801
// for an example of a recovery transaction from the following descriptor:
// tr(tpubD6NzVbkrYhZ4XcC4HC7TDGrhraymFg9xo31hVtc5sh3dtsXbB5ZXewwMXi6HSmR2PyLeG8VwD3anqavSJVtXYJAAJcaEGCZdkBnnWTmhz3X/<0;1>/*,{and_v(v:pk([9e1c1983/48'/1'/0'/2']tpubDEWCLCMncbStq4BLXkQUAPqzzrh2tQUgYeQPt4NrB5D7gRraMyGbRqzPTmQGvqfdaFsXDVGSQBRgfXuNjDyfU626pxSjpQZszFNY6CzogxK/<2;3>/*),older(1)),multi_a(2,[88d8b4b9/48'/1'/0'/2']tpubDENzCJsHPDzX1EAP9eUPumw2hFUyjuUtBK8CWNPkudZTQ1mchX1hiAwog3fd6BKbq1rdZbLW3Q1d79AcvQCCMdehuSZ8GcShDcHaYTosCRa/<0;1>/*,[9e1c1983/48'/1'/0'/2']tpubDEWCLCMncbStq4BLXkQUAPqzzrh2tQUgYeQPt4NrB5D7gRraMyGbRqzPTmQGvqfdaFsXDVGSQBRgfXuNjDyfU626pxSjpQZszFNY6CzogxK/<0;1>/*)})#pepfj0gd
// Recovery path would use 1 + (1+64) + (1+36) + (1+65), but `max_sat_weight` considers all
// spending paths when passing `false`. So it currently gives the same as passing `true`.
// This `true` branch assumes a Schnorr signature of size 1+64+1, where the final +1 is for the sighash suffix:
// https://docs.rs/miniscript/11.0.0/src/miniscript/descriptor/tr.rs.html#254-301
// So we need to add 2, 1 for each signature.
assert_eq!(desc.max_sat_weight(false), desc.max_sat_weight(true) + 2);
}

#[test]
fn liana_desc_keys() {
let secp = secp256k1::Secp256k1::signing_only();
Expand Down
27 changes: 27 additions & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,33 @@ def lianad_multisig(bitcoin_backend, directory):
lianad.cleanup()


@pytest.fixture
def lianad_multisig_2_of_2(bitcoin_backend, directory):
datadir = os.path.join(directory, "lianad")
os.makedirs(datadir, exist_ok=True)

# A 2-of-2 that degrades into a 1-of-1 after 10 blocks
csv_value = 10
signer = MultiSigner(2, {csv_value: 1}, is_taproot=USE_TAPROOT)
main_desc = Descriptor.from_str(multisig_desc(signer, csv_value, USE_TAPROOT, 2, 1))

lianad = Lianad(
datadir,
signer,
main_desc,
bitcoin_backend,
)

try:
lianad.start()
yield lianad
except Exception:
lianad.cleanup()
raise

lianad.cleanup()


def multipath_desc(multi_signer, csv_values, is_taproot):
prim_multi = multi_expression(3, multi_signer.prim_hds, is_taproot)
first_recov_multi = multi_expression(
Expand Down
47 changes: 47 additions & 0 deletions tests/test_spend.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import math

from fixtures import *
from test_framework.serializations import PSBT, uint256_from_str
from test_framework.utils import (
Expand Down Expand Up @@ -583,3 +585,48 @@ def test_sweep(lianad, bitcoind):
c["amount"] for c in lianad.rpc.listcoins(["unconfirmed", "confirmed"])["coins"]
)
assert balance == int((0.2 + 0.1 + 0.3) * COIN)


@pytest.mark.parametrize("feerate", [1, 2])
@pytest.mark.skipif(not USE_TAPROOT, reason="This tests a Taproot-specific bug.")
def test_tr_multisig_2_of_2_feerate_is_met(feerate, lianad_multisig_2_of_2, bitcoind):
"""
A regression test for https://github.com/wizardsardine/liana/issues/1371.
Test that a 2-of-2 Taproot primary path spend pays a high enough fee for
the target feerate.
See https://mempool.space/signet/tx/a63c4a69be71fcba0e16f742a2697401fbc47ad7dff10a790b8f961004aa0ab4
for an example of such a transaction.
"""
# Get a coin.
deposit_txid = bitcoind.rpc.sendtoaddress(
lianad_multisig_2_of_2.rpc.getnewaddress()["address"], 0.001
)
bitcoind.generate_block(1, wait_for_mempool=deposit_txid)
wait_for(
lambda: len(lianad_multisig_2_of_2.rpc.listcoins(["confirmed"])["coins"]) == 1
)
coin = lianad_multisig_2_of_2.rpc.listcoins(["confirmed"])["coins"][0]

# Refresh the coin at the given feerate.
res = lianad_multisig_2_of_2.rpc.createspend({}, [coin["outpoint"]], feerate)
spend_psbt = PSBT.from_base64(res["psbt"])
signed_psbt = lianad_multisig_2_of_2.signer.sign_psbt(spend_psbt, [0, 1])
lianad_multisig_2_of_2.rpc.updatespend(signed_psbt.to_base64())
spend_txid = signed_psbt.tx.txid().hex()
lianad_multisig_2_of_2.rpc.broadcastspend(spend_txid)

# Check the tx fee and weight in mempool are as expected.
res = bitcoind.rpc.getmempoolentry(spend_txid)
spend_fee = res["fees"]["base"] * COIN
spend_weight = res["weight"]
assert spend_weight == 646

# Due to https://github.com/wizardsardine/liana/pull/1323 we currently
# add 10 sats if feerate is 1.
extra = 10 if feerate == 1 else 0

# Note that due to https://github.com/wizardsardine/liana/issues/1132
# we do not round up vbytes before multiplying by feerate.
assert spend_fee == math.ceil((646.0 / 4.0) * feerate) + extra

0 comments on commit cf88aca

Please sign in to comment.