Add integration test

This commit is contained in:
Jon Staab
2026-02-19 16:44:12 -08:00
parent 77bd576c0a
commit 1f9670e6df
6 changed files with 500 additions and 22 deletions
+7
View File
@@ -135,6 +135,7 @@ dependencies = [
name = "frost-taproot"
version = "0.1.0"
dependencies = [
"hex",
"k256",
"rand",
"sha2",
@@ -175,6 +176,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
+7
View File
@@ -9,3 +9,10 @@ sha2 = "0.10"
rand = "0.8"
thiserror = "1"
signature = "2"
[dev-dependencies]
hex = "0.4"
[[test]]
name = "interop"
path = "tests/integration/interop.rs"
+41 -20
View File
@@ -19,37 +19,58 @@ fn dst(sub: &str) -> Vec<u8> {
}
/// hash_to_field using XMD with SHA-256, outputting one field element mod N.
/// Mirrors `hash_to_field(msg, 1, { m:1, p:N, k:128, expand:'xmd', hash:sha256, DST })`.
///
/// Mirrors noble's `hash_to_field(msg, 1, { m:1, p:N, k:128, expand:'xmd', hash:sha256, DST })`:
/// 1. expand_message_xmd → 48 uniform bytes
/// 2. interpret as a big-endian 384-bit integer
/// 3. reduce mod N
///
/// The 384-bit → mod-N reduction is done as:
/// value = hi_128_bits * 2^256 + lo_256_bits (mod N)
fn hash_to_field_n(msg: &[u8], dst_bytes: &[u8]) -> Scalar {
// expand_message_xmd with len_in_bytes = 48 (ceil((log2(N)+k)/8) = ceil((256+128)/8) = 48)
// Step 1: expand_message_xmd → 48 bytes
let mut uniform = [0u8; 48];
ExpandMsgXmd::<Sha256>::expand_message(&[msg], &[dst_bytes], 48)
.expect("expand_message failed")
.fill_bytes(&mut uniform);
// Interpret as a 384-bit integer mod N.
// We take the low 256 bits after reduction — use U256 from the 48-byte big-endian value.
// Noble does: interpret as big-endian integer, then mod N.
// We replicate by taking the full 48 bytes as a big integer mod N.
// k256 U256 is only 256 bits, so we do two-step reduction:
// high 16 bytes * 2^256 + low 32 bytes, all mod N.
let (hi, lo) = uniform.split_at(16);
let mut hi32 = [0u8; 32];
hi32[16..].copy_from_slice(hi);
let mut lo32 = [0u8; 32];
lo32.copy_from_slice(lo);
// Step 2 & 3: interpret 48 bytes as big-endian integer, reduce mod N.
// Split into high 16 bytes and low 32 bytes.
// value = hi * 2^256 + lo (mod N)
let (hi16, lo32_slice) = uniform.split_at(16);
let hi_scalar = mod_n(U256::from_be_slice(&hi32));
let mut lo32 = [0u8; 32];
lo32.copy_from_slice(lo32_slice);
let lo_scalar = mod_n(U256::from_be_slice(&lo32));
// 2^128 mod N as a scalar
let shift = {
let mut s = [0u8; 32];
s[16] = 1; // 2^128
mod_n(U256::from_be_slice(&s))
// 2^256 mod N: since 2^256 = N + (2^256 - N), we compute it as a scalar.
// 2^256 - N = 0x14551231950b75fc4402da1732fc9bebf
// Represented as 32 bytes (fits in 17 bytes):
let two256_mod_n = {
// 2^256 mod N = 2^256 - N (since 2^256 > N)
// = 0x014551231950b75fc4402da1732fc9bebf (17 bytes)
// As 32-byte big-endian:
let bytes =
hex_literal_32("000000000000000000000000000000014551231950b75fc4402da1732fc9bebf");
mod_n(U256::from_be_slice(&bytes))
};
hi_scalar * shift + lo_scalar
// hi as a scalar (16 bytes → pad to 32)
let mut hi32 = [0u8; 32];
hi32[16..].copy_from_slice(hi16);
let hi_scalar = mod_n(U256::from_be_slice(&hi32));
hi_scalar * two256_mod_n + lo_scalar
}
/// Parse a hex literal into a 32-byte array (must be exactly 64 hex chars).
fn hex_literal_32(s: &str) -> [u8; 32] {
// Pad or truncate to 64 hex chars (32 bytes), right-aligned.
let padded = format!("{:0>64}", s);
let b = (0..32)
.map(|i| u8::from_str_radix(&padded[i * 2..i * 2 + 2], 16).unwrap())
.collect::<Vec<_>>();
b.try_into().unwrap()
}
/// H1: rho — binding factor hash.
+4 -2
View File
@@ -124,18 +124,20 @@ pub fn verify_partial_sig(
/// Verify a final aggregated BIP340 Schnorr signature.
/// Mirrors `verify_final_sig` in the TS implementation.
///
/// Uses `verify_raw` because noble's `schnorr.verify` passes the message directly
/// into the BIP340 challenge hash without pre-hashing it with SHA-256.
pub fn verify_final_sig(
ctx: &GroupKeyContext,
message: &[u8],
signature: &[u8; 64],
) -> Result<bool, Error> {
use k256::schnorr::{Signature, VerifyingKey};
use signature::Verifier;
// group_pk is 33-byte compressed; BIP340 uses x-only (32 bytes).
let pk_bytes: [u8; 32] = ctx.group_pk[1..].try_into().unwrap();
let vk = VerifyingKey::from_bytes(&pk_bytes).map_err(|_| Error::InvalidPoint)?;
let sig = Signature::try_from(signature.as_slice()).map_err(|_| Error::InvalidPoint)?;
Ok(vk.verify(message, &sig).is_ok())
Ok(vk.verify_raw(message, &sig).is_ok())
}
@@ -0,0 +1,84 @@
{
"secrets": [
"0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f",
"0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443"
],
"threshold": 2,
"share_max": 3,
"message": "68656c6c6f20776f726c6421",
"hidden_seed": "0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f",
"binder_seed": "0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443",
"tweaks": [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
],
"group_pk": "021ae63bc9ddaffe52d44c3018e83115bfb22195bd8112fcad112310714e6fd5ec",
"vss_commits": [
"021ae63bc9ddaffe52d44c3018e83115bfb22195bd8112fcad112310714e6fd5ec",
"024f75a5478deda1102eba931e19425e59c1750533a54218ce215ced343fbfb6cf"
],
"shares": [
{
"idx": 1,
"seckey": "0e74411caa9ef5ba058d0ccf4e54e1ee773e625fb7d6258097466cedab0de152"
},
{
"idx": 2,
"seckey": "1c77b7c3c2a14987be430edb4d63bc410e9f3cc59eeb8bbeb89951abdad59595"
},
{
"idx": 3,
"seckey": "2a7b2e6adaa39d5576f910e74c729693a600172b8600f1fcd9ec366a0a9d49d8"
}
],
"commits": [
{
"idx": 1,
"hidden_sn": "189aeb1bf3a453673cb144a459f0b644183ff02808cad807b672067da4f33357",
"binder_sn": "162f3098066a9407c7ce156cb0c49c58ab34b6e195b6435fa4be759e827b9b4c",
"hidden_pn": "024d837d707dfa4b56be26da22b9ff5cb0fd220d011351ba79334003f16871801c",
"binder_pn": "0263c0d31a58799213f5210685b8bc2ce4539819a90c09c216a983e8f8c67a12f5"
},
{
"idx": 2,
"hidden_sn": "1c48193192f4a7ba98b04f21246da1925fdacd387ac79bb9708062c705f37a17",
"binder_sn": "f3ede9cd66b93ce18af27792521c929c10cf21a45d72892db7d8a5088bd2ea2e",
"hidden_pn": "034bc9f2ef5cc5eb741cc00d763e1077e8bc624df82d198781c71a0757617d8d44",
"binder_pn": "03a1e7d63fd0665b9255df5f6d781762f7e7298a2c42ee6d67cfd287780fb3c2a6"
}
],
"ctx": {
"group_pk": "025731d4d57552d12877e3db13061d7f6ca09198963e003d1d6e0960d6651e42d3",
"group_pn": "03e76328e49c27c12392a117d39ef9f5def368590d5e72438907fb63c1006fd589",
"bind_prefix": "025731d4d57552d12877e3db13061d7f6ca09198963e003d1d6e0960d6651e42d3c00982b3526dcd6b7bcb4f685ddb41c7d00fecd032aa479f5df03601701bf5232ba4662301918443c019abba4a752cdcb8d4f572ad78a88ec416f17b60bb866c",
"bind_factors": [
{
"idx": 1,
"factor": "de9fa47304afaa64b5baddfccf4a8da6705edd162201ce55e1f9a478e6ec2a57"
},
{
"idx": 2,
"factor": "97aa7e9649ea9086359b7ba8fe815f54d98a5956ad63d2cf670d465d3b5d0f1f"
}
],
"challenge": "99e6637f68e223b0f6b4caa36b48cc277bf036ece4f14bab657200b43ecb0d55",
"indexes": [
1,
2
],
"message": "68656c6c6f20776f726c6421"
},
"psigs": [
{
"idx": 1,
"pubkey": "0278f55809a11a1016d13ec4f54674810abe4a6fec8b586e14f90d0c1f80de33eb",
"psig": "89ce878d8aa2f6c6565e963c6bbe99c45af811a2892c402b4c4e3f9ad972a48b"
},
{
"idx": 2,
"pubkey": "02f00d2b4d3b761ed6317310ed791234dfcad643c00000690e9601adc412b1a22d",
"psig": "67488a00fc37386e12eddfd8eee2dcf997fc01255e7ea66f605db448c86363b1"
}
],
"signature": "e76328e49c27c12392a117d39ef9f5def368590d5e72438907fb63c1006fd5891d715fa750b5840610aaf531949f633c4555ac20caf290c3f22cc0771f074447"
}
+357
View File
@@ -0,0 +1,357 @@
/// Interoperability test between cmdruid/frost (TypeScript) and frost-taproot (Rust).
///
/// The fixture was generated by `ref/frost/gen_fixture.mjs`, which runs the full
/// cmdruid/frost signing protocol with deterministic inputs and records every
/// intermediate value. This test re-runs the same steps in Rust and asserts that
/// every output matches the TypeScript reference exactly.
use frost_taproot::{
commit::create_commit_pkg,
context::get_group_signing_ctx,
group::create_dealer_set,
sign::{combine_partial_sigs, sign_msg, verify_final_sig, verify_partial_sig},
types::{PublicNonce, SecretNonce, SecretShare, ShareSignature},
};
// ── Fixture helpers ───────────────────────────────────────────────────────────
fn hex_to_32(s: &str) -> [u8; 32] {
let b = hex::decode(s).expect("invalid hex");
b.try_into().expect("expected 32 bytes")
}
fn hex_to_33(s: &str) -> [u8; 33] {
let b = hex::decode(s).expect("invalid hex");
b.try_into().expect("expected 33 bytes")
}
fn hex_to_vec(s: &str) -> Vec<u8> {
hex::decode(s).expect("invalid hex")
}
fn to_hex(b: &[u8]) -> String {
hex::encode(b)
}
// ── Fixture (generated by gen_fixture.mjs) ────────────────────────────────────
struct Fixture {
secrets: [&'static str; 2],
threshold: usize,
share_max: u32,
message: &'static str,
hidden_seed: &'static str,
binder_seed: &'static str,
tweaks: [&'static str; 2],
// Expected dealer outputs
group_pk: &'static str,
vss_commits: [&'static str; 2],
shares: [(u32, &'static str); 3],
// Expected nonce commitments for the signing subset (idx 1 and 2)
commits: [(u32, &'static str, &'static str, &'static str, &'static str); 2],
// Expected signing context values
ctx_group_pk: &'static str,
ctx_group_pn: &'static str,
ctx_bind_prefix: &'static str,
ctx_bind_factors: [(u32, &'static str); 2],
ctx_challenge: &'static str,
// Expected partial signatures
psigs: [(u32, &'static str, &'static str); 2],
// Expected final signature
signature: &'static str,
}
const FX: Fixture = Fixture {
secrets: [
"0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f",
"0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443",
],
threshold: 2,
share_max: 3,
message: "68656c6c6f20776f726c6421",
hidden_seed: "0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f",
binder_seed: "0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443",
tweaks: [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
],
group_pk: "021ae63bc9ddaffe52d44c3018e83115bfb22195bd8112fcad112310714e6fd5ec",
vss_commits: [
"021ae63bc9ddaffe52d44c3018e83115bfb22195bd8112fcad112310714e6fd5ec",
"024f75a5478deda1102eba931e19425e59c1750533a54218ce215ced343fbfb6cf",
],
shares: [
(1, "0e74411caa9ef5ba058d0ccf4e54e1ee773e625fb7d6258097466cedab0de152"),
(2, "1c77b7c3c2a14987be430edb4d63bc410e9f3cc59eeb8bbeb89951abdad59595"),
(3, "2a7b2e6adaa39d5576f910e74c729693a600172b8600f1fcd9ec366a0a9d49d8"),
],
// (idx, hidden_sn, binder_sn, hidden_pn, binder_pn)
commits: [
(1,
"189aeb1bf3a453673cb144a459f0b644183ff02808cad807b672067da4f33357",
"162f3098066a9407c7ce156cb0c49c58ab34b6e195b6435fa4be759e827b9b4c",
"024d837d707dfa4b56be26da22b9ff5cb0fd220d011351ba79334003f16871801c",
"0263c0d31a58799213f5210685b8bc2ce4539819a90c09c216a983e8f8c67a12f5"),
(2,
"1c48193192f4a7ba98b04f21246da1925fdacd387ac79bb9708062c705f37a17",
"f3ede9cd66b93ce18af27792521c929c10cf21a45d72892db7d8a5088bd2ea2e",
"034bc9f2ef5cc5eb741cc00d763e1077e8bc624df82d198781c71a0757617d8d44",
"03a1e7d63fd0665b9255df5f6d781762f7e7298a2c42ee6d67cfd287780fb3c2a6"),
],
ctx_group_pk: "025731d4d57552d12877e3db13061d7f6ca09198963e003d1d6e0960d6651e42d3",
ctx_group_pn: "03e76328e49c27c12392a117d39ef9f5def368590d5e72438907fb63c1006fd589",
ctx_bind_prefix: "025731d4d57552d12877e3db13061d7f6ca09198963e003d1d6e0960d6651e42d3c00982b3526dcd6b7bcb4f685ddb41c7d00fecd032aa479f5df03601701bf5232ba4662301918443c019abba4a752cdcb8d4f572ad78a88ec416f17b60bb866c",
ctx_bind_factors: [
(1, "de9fa47304afaa64b5baddfccf4a8da6705edd162201ce55e1f9a478e6ec2a57"),
(2, "97aa7e9649ea9086359b7ba8fe815f54d98a5956ad63d2cf670d465d3b5d0f1f"),
],
ctx_challenge: "99e6637f68e223b0f6b4caa36b48cc277bf036ece4f14bab657200b43ecb0d55",
// (idx, pubkey, psig)
psigs: [
(1,
"0278f55809a11a1016d13ec4f54674810abe4a6fec8b586e14f90d0c1f80de33eb",
"89ce878d8aa2f6c6565e963c6bbe99c45af811a2892c402b4c4e3f9ad972a48b"),
(2,
"02f00d2b4d3b761ed6317310ed791234dfcad643c00000690e9601adc412b1a22d",
"67488a00fc37386e12eddfd8eee2dcf997fc01255e7ea66f605db448c86363b1"),
],
signature: "e76328e49c27c12392a117d39ef9f5def368590d5e72438907fb63c1006fd5891d715fa750b5840610aaf531949f633c4555ac20caf290c3f22cc0771f074447",
};
// ── Tests ─────────────────────────────────────────────────────────────────────
#[test]
fn test_dealer_set_matches_ts() {
let secrets: Vec<[u8; 32]> = FX.secrets.iter().map(|s| hex_to_32(s)).collect();
let group =
create_dealer_set(FX.threshold, FX.share_max, &secrets).expect("create_dealer_set failed");
assert_eq!(to_hex(&group.group_pk), FX.group_pk, "group_pk mismatch");
for (i, expected) in FX.vss_commits.iter().enumerate() {
assert_eq!(
to_hex(&group.vss_commits[i]),
*expected,
"vss_commit[{i}] mismatch"
);
}
for (idx, expected_seckey) in &FX.shares {
let share = group.shares.iter().find(|s| s.idx == *idx).unwrap();
assert_eq!(
to_hex(&share.seckey),
*expected_seckey,
"share[{idx}].seckey mismatch"
);
}
}
#[test]
fn test_nonce_commitments_match_ts() {
for (idx, hidden_sn, binder_sn, hidden_pn, binder_pn) in &FX.commits {
let share = SecretShare {
idx: *idx,
seckey: hex_to_32(FX.shares[(*idx as usize) - 1].1),
};
let hidden_seed = hex_to_32(FX.hidden_seed);
let binder_seed = hex_to_32(FX.binder_seed);
let commit = create_commit_pkg(&share, Some(&hidden_seed), Some(&binder_seed));
assert_eq!(
to_hex(&commit.hidden_sn),
*hidden_sn,
"hidden_sn mismatch for idx {idx}"
);
assert_eq!(
to_hex(&commit.binder_sn),
*binder_sn,
"binder_sn mismatch for idx {idx}"
);
assert_eq!(
to_hex(&commit.hidden_pn),
*hidden_pn,
"hidden_pn mismatch for idx {idx}"
);
assert_eq!(
to_hex(&commit.binder_pn),
*binder_pn,
"binder_pn mismatch for idx {idx}"
);
}
}
#[test]
fn test_signing_context_matches_ts() {
let pnonces: Vec<PublicNonce> = FX
.commits
.iter()
.map(|(idx, _, _, hidden_pn, binder_pn)| PublicNonce {
idx: *idx,
hidden_pn: hex_to_33(hidden_pn),
binder_pn: hex_to_33(binder_pn),
})
.collect();
let tweaks: Vec<[u8; 32]> = FX.tweaks.iter().map(|t| hex_to_32(t)).collect();
let message = hex_to_vec(FX.message);
let group_pk_bytes = hex_to_33(FX.group_pk);
let ctx = get_group_signing_ctx(&group_pk_bytes, &pnonces, &message, &tweaks)
.expect("get_group_signing_ctx failed");
assert_eq!(
to_hex(&ctx.group_pk),
FX.ctx_group_pk,
"ctx.group_pk mismatch"
);
assert_eq!(
to_hex(&ctx.group_pn),
FX.ctx_group_pn,
"ctx.group_pn mismatch"
);
assert_eq!(
to_hex(&ctx.bind_prefix),
FX.ctx_bind_prefix,
"ctx.bind_prefix mismatch"
);
for (idx, expected_factor) in &FX.ctx_bind_factors {
let bf = ctx.bind_factors.iter().find(|b| b.idx == *idx).unwrap();
assert_eq!(
to_hex(&bf.factor),
*expected_factor,
"bind_factor[{idx}] mismatch"
);
}
use frost_taproot::ecc::util::scalar_to_bytes;
assert_eq!(
to_hex(&scalar_to_bytes(&ctx.challenge)),
FX.ctx_challenge,
"challenge mismatch"
);
}
#[test]
fn test_partial_signatures_match_ts() {
let pnonces: Vec<PublicNonce> = FX
.commits
.iter()
.map(|(idx, _, _, hidden_pn, binder_pn)| PublicNonce {
idx: *idx,
hidden_pn: hex_to_33(hidden_pn),
binder_pn: hex_to_33(binder_pn),
})
.collect();
let tweaks: Vec<[u8; 32]> = FX.tweaks.iter().map(|t| hex_to_32(t)).collect();
let message = hex_to_vec(FX.message);
let group_pk_bytes = hex_to_33(FX.group_pk);
let ctx = get_group_signing_ctx(&group_pk_bytes, &pnonces, &message, &tweaks)
.expect("get_group_signing_ctx failed");
for (idx, expected_pubkey, expected_psig) in &FX.psigs {
let share = SecretShare {
idx: *idx,
seckey: hex_to_32(FX.shares[(*idx as usize) - 1].1),
};
let (_, hidden_sn, binder_sn, _, _) = FX.commits[(*idx as usize) - 1];
let snonce = SecretNonce {
idx: *idx,
hidden_sn: hex_to_32(hidden_sn),
binder_sn: hex_to_32(binder_sn),
};
let sig = sign_msg(&ctx, &share, &snonce).expect("sign_msg failed");
assert_eq!(
to_hex(&sig.pubkey),
*expected_pubkey,
"psig[{idx}].pubkey mismatch"
);
assert_eq!(
to_hex(&sig.psig),
*expected_psig,
"psig[{idx}].psig mismatch"
);
}
}
#[test]
fn test_partial_sig_verification() {
let pnonces: Vec<PublicNonce> = FX
.commits
.iter()
.map(|(idx, _, _, hidden_pn, binder_pn)| PublicNonce {
idx: *idx,
hidden_pn: hex_to_33(hidden_pn),
binder_pn: hex_to_33(binder_pn),
})
.collect();
let tweaks: Vec<[u8; 32]> = FX.tweaks.iter().map(|t| hex_to_32(t)).collect();
let message = hex_to_vec(FX.message);
let group_pk_bytes = hex_to_33(FX.group_pk);
let ctx = get_group_signing_ctx(&group_pk_bytes, &pnonces, &message, &tweaks)
.expect("get_group_signing_ctx failed");
for (idx, expected_pubkey, expected_psig) in &FX.psigs {
let pnonce = pnonces.iter().find(|p| p.idx == *idx).unwrap();
let share_pk = hex_to_33(expected_pubkey);
let psig = hex_to_32(expected_psig);
let ok =
verify_partial_sig(&ctx, pnonce, &share_pk, &psig).expect("verify_partial_sig failed");
assert!(ok, "partial sig verification failed for idx {idx}");
}
}
#[test]
fn test_final_signature_matches_ts() {
let pnonces: Vec<PublicNonce> = FX
.commits
.iter()
.map(|(idx, _, _, hidden_pn, binder_pn)| PublicNonce {
idx: *idx,
hidden_pn: hex_to_33(hidden_pn),
binder_pn: hex_to_33(binder_pn),
})
.collect();
let tweaks: Vec<[u8; 32]> = FX.tweaks.iter().map(|t| hex_to_32(t)).collect();
let message = hex_to_vec(FX.message);
let group_pk_bytes = hex_to_33(FX.group_pk);
let ctx = get_group_signing_ctx(&group_pk_bytes, &pnonces, &message, &tweaks)
.expect("get_group_signing_ctx failed");
let psigs: Vec<ShareSignature> = FX
.psigs
.iter()
.map(|(idx, pubkey, psig)| ShareSignature {
idx: *idx,
pubkey: hex_to_33(pubkey),
psig: hex_to_32(psig),
})
.collect();
let sig = combine_partial_sigs(&ctx, &psigs).expect("combine_partial_sigs failed");
assert_eq!(to_hex(&sig), FX.signature, "final signature mismatch");
let key_ctx = ctx.key_context();
let valid = verify_final_sig(&key_ctx, &message, &sig).expect("verify_final_sig failed");
assert!(valid, "final signature failed BIP340 verification");
}