Skip to content

Commit 8609b2c

Browse files
authored
Merge pull request #7264 from BitGo/BTC-2652.improve-p2tr-tests
feat(utxo-lib): improve test coverage for p2tr and p2trMusig
2 parents 7540459 + cb5bed1 commit 8609b2c

20 files changed

+2683
-46
lines changed
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# BIP 0327 with BitGo legacy p2tr variant
2+
3+
This directory contains a modified version of the BIP-0327 MuSig2
4+
reference implementation by @jonasnick.
5+
6+
The original code was taken from the following file:
7+
https://github.com/bitcoin/bips/blob/ab9d5b8/bip-0327/reference.py
8+
9+
The modifications add support for an older aggregation method that is
10+
used at BitGo in a deprecated address type (`p2tr`, chain 30 and 31).
11+
12+
The aggregation method is based on an older version of MuSig2 that predated this PR:
13+
https://github.com/jonasnick/bips/pull/37
14+
15+
The recommended address type for taproot at BitGo is `p2trMusig2` (chains 40 and 41),
16+
which uses the standard MuSig2 aggregation scheme.
17+
18+
## Implementation Differences
19+
20+
### 1. X-Only Pubkey Support
21+
22+
The `key_agg()` function has been enhanced to accept both 33-byte compressed pubkeys and 32-byte x-only pubkeys:
23+
24+
```python
25+
def key_agg(pubkeys: List[bytes]) -> KeyAggContext:
26+
for pk in pubkeys:
27+
if len(pk) != len(pubkeys[0]):
28+
raise ValueError('all pubkeys must be the same length')
29+
30+
# ...
31+
for i in range(u):
32+
# if the pubkey is 32 bytes, it is an xonly pubkey
33+
if len(pubkeys[i]) == 32:
34+
P_i = lift_x(pubkeys[i])
35+
else:
36+
P_i = cpoint(pubkeys[i])
37+
```
38+
39+
This allows the implementation to work with both pubkey formats, checking the length to determine the appropriate parsing method.
40+
41+
### 2. Legacy p2tr Aggregation Function
42+
43+
A new function `key_agg_bitgo_p2tr_legacy()` implements the deprecated aggregation method:
44+
45+
```python
46+
def key_agg_bitgo_p2tr_legacy(pubkeys: List[PlainPk]) -> KeyAggContext:
47+
# Convert compressed pubkeys to x-only format
48+
pubkeys = [pk[-32:] for pk in pubkeys]
49+
50+
# Sort keys AFTER x-only conversion
51+
pubkeys = key_sort(pubkeys)
52+
53+
# Aggregate using standard algorithm
54+
return key_agg(pubkeys)
55+
```
56+
57+
**Key difference**: This method converts pubkeys to x-only format **before** sorting, whereas standard MuSig2 uses full 33-byte compressed keys throughout. This difference stems from the MuSig2 specification change documented in [jonasnick/bips#37](https://github.com/jonasnick/bips/pull/37).
58+
59+
### 3. Enhanced Signing and Verification Functions
60+
61+
Several functions were updated to handle x-only pubkeys properly:
62+
63+
**`get_session_key_agg_coeff()`**: Detects x-only pubkeys and uses appropriate format for coefficient calculation:
64+
65+
```python
66+
# If pubkeys are x-only, use x-only for coefficient calculation
67+
if len(pubkeys[0]) == 32:
68+
pk_for_coeff = pk[-32:]
69+
else:
70+
pk_for_coeff = pk
71+
return key_agg_coeff(pubkeys, pk_for_coeff)
72+
```
73+
74+
**`sign()`**: Validates the secnonce against both compressed and x-only pubkey formats:
75+
76+
```python
77+
if not pk == secnonce[64:97] and not pk[-32:] == secnonce[64:97]:
78+
raise ValueError('Public key does not match nonce_gen argument')
79+
```
80+
81+
**`partial_sig_verify_internal()`**: Handles x-only pubkeys by prepending the appropriate prefix:
82+
83+
```python
84+
# prepend a 0x02 if the pk is 32 bytes
85+
P = cpoint(b'\x02' + pk) if len(pk) == 32 else cpoint(pk)
86+
```
87+
88+
## Testing Differences
89+
90+
The testing code has been significantly restructured to validate BitGo-specific behavior.
91+
92+
### Refactored Test Helpers
93+
94+
The previous monolithic `test_sign_and_verify_random()` function has been broken down into reusable components:
95+
96+
**`sign_and_verify_with_aggpk()`**: Core signing workflow that:
97+
98+
- Generates nonces for two signers
99+
- Supports both random nonce generation and deterministic signing
100+
- Performs partial signature verification
101+
- Tests nonce reuse protection
102+
- Verifies the final aggregated signature
103+
104+
**`sign_and_verify_with_keys()`**: Simplified wrapper that generates random tweaks and calls the core signing function.
105+
106+
**`sign_and_verify_with_aggpk_bitgo()`**: BitGo-specific wrapper with no tweaks applied (BitGo doesn't use tweaks in production).
107+
108+
**`sign_and_verify_with_aggpk_bitgo_legacy()`**: Special handler for legacy p2tr that:
109+
110+
- Normalizes secret keys to produce even y-coordinate pubkeys
111+
- Converts to x-only format
112+
- Sorts by x-only pubkey order
113+
- Validates the expected aggregate pubkey
114+
- Performs full signing workflow
115+
116+
### BitGo-Specific Test Cases
117+
118+
Three new test functions validate BitGo's taproot implementations:
119+
120+
#### `test_agg_bitgo_derive()`
121+
122+
Sanity check that the test fixture private keys correctly derive to their expected public keys.
123+
124+
#### `test_agg_bitgo_p2tr_legacy()`
125+
126+
Tests the legacy p2tr aggregation (chains 30/31):
127+
128+
- Expected aggregate key: `cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa`
129+
- Verifies order-independence: aggregating `[user, bitgo]` and `[bitgo, user]` produce the same result (due to sorting after x-only conversion)
130+
- Tests complete signing and verification workflow
131+
132+
#### `test_agg_bitgo_p2tr_musig2()`
133+
134+
Tests the standard MuSig2 aggregation (chains 40/41):
135+
136+
- Expected aggregate key `[user, bitgo]`: `c0e255b4510e041ab81151091d875687a618de314344dff4b73b1bcd366cdbd8`
137+
- Expected aggregate key `[bitgo, user]`: `e48d309b535811eb0b148c4b0600a10e82e289899429e40aee05577504eca356`
138+
- Verifies order-dependence: different key orders produce different aggregate keys (standard MuSig2 behavior)
139+
- Tests both orderings with complete signing workflows
140+
141+
### Shared Test Fixtures
142+
143+
All BitGo tests use consistent keypairs:
144+
145+
```python
146+
# Private keys from test fixtures
147+
privkey_user = bytes.fromhex("a07e682489dad68834f7df8a5c8b34f3b9ff9fdd8809e2ba53ae29df65fc146b")
148+
privkey_bitgo = bytes.fromhex("2d210ff6703d0fae0e9ca91e1d0bbab006b03e8e699f49becbaf554066fa79aa")
149+
150+
# Corresponding public keys
151+
pubkey_user = PlainPk(bytes.fromhex("02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7"))
152+
pubkey_bitgo = PlainPk(bytes.fromhex("03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64"))
153+
```
154+
155+
**Important note**: These pubkeys have different sort orders depending on whether comparison is done on the full 33-byte compressed format or the 32-byte x-only format. This is precisely why the legacy and standard methods produce different aggregate keys.
156+
157+
## Running Tests
158+
159+
Execute all tests including BitGo-specific ones:
160+
161+
```bash
162+
cd modules/utxo-lib/bip-0327
163+
python3 reference.py
164+
```
165+
166+
The test suite runs:
167+
168+
1. Standard BIP327 test vectors (key sorting, aggregation, nonces, signing, tweaks, deterministic signing, signature aggregation)
169+
2. Random signing/verification tests (6 iterations)
170+
3. BitGo derivation tests
171+
4. BitGo legacy p2tr tests
172+
5. BitGo standard p2trMusig2 tests
173+
174+
## References
175+
176+
- [BIP327 Specification](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki)
177+
- [BIP340 Schnorr Signatures](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)
178+
- [MuSig2 32-byte to 33-byte key change](https://github.com/jonasnick/bips/pull/37)
179+
- [Original BIP327 reference implementation](https://github.com/bitcoin/bips/blob/ab9d5b8/bip-0327/reference.py)
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
from reference import *
2+
3+
def gen_key_agg_vectors():
4+
print("key_agg_vectors.json: Intermediate tweaking result is point at infinity")
5+
sk = bytes.fromhex("7FB9E0E687ADA1EEBF7ECFE2F21E73EBDB51A7D450948DFE8D76D7F2D1007671")
6+
pk = individual_pk(sk)
7+
keygen_ctx = key_agg([pk])
8+
aggpoint, _, _ = keygen_ctx
9+
aggsk = key_agg_coeff([pk], pk)*int_from_bytes(sk) % n
10+
t = n - aggsk
11+
assert point_add(point_mul(G, t), aggpoint) == None
12+
is_xonly = False
13+
tweak = bytes_from_int(t)
14+
assert_raises(ValueError, lambda: apply_tweak(keygen_ctx, tweak, is_xonly), lambda e: True)
15+
print(" pubkey:", pk.hex().upper())
16+
print(" tweak: ", tweak.hex().upper())
17+
18+
def check_sign_verify_vectors():
19+
with open(os.path.join(sys.path[0], 'vectors', 'sign_verify_vectors.json')) as f:
20+
test_data = json.load(f)
21+
X = fromhex_all(test_data["pubkeys"])
22+
pnonce = fromhex_all(test_data["pnonces"])
23+
aggnonces = fromhex_all(test_data["aggnonces"])
24+
msgs = fromhex_all(test_data["msgs"])
25+
26+
valid_test_cases = test_data["valid_test_cases"]
27+
for (i, test_case) in enumerate(valid_test_cases):
28+
pubkeys = [X[i] for i in test_case["key_indices"]]
29+
pubnonces = [pnonce[i] for i in test_case["nonce_indices"]]
30+
aggnonce = aggnonces[test_case["aggnonce_index"]]
31+
assert nonce_agg(pubnonces) == aggnonce
32+
msg = msgs[test_case["msg_index"]]
33+
signer_index = test_case["signer_index"]
34+
expected = bytes.fromhex(test_case["expected"])
35+
36+
session_ctx = SessionContext(aggnonce, pubkeys, [], [], msg)
37+
(Q, _, _, _, R, _) = get_session_values(session_ctx)
38+
# Make sure the vectors include tests for both variants of Q and R
39+
if i == 0:
40+
assert has_even_y(Q) and not has_even_y(R)
41+
if i == 1:
42+
assert not has_even_y(Q) and has_even_y(R)
43+
if i == 2:
44+
assert has_even_y(Q) and has_even_y(R)
45+
46+
def check_tweak_vectors():
47+
with open(os.path.join(sys.path[0], 'vectors', 'tweak_vectors.json')) as f:
48+
test_data = json.load(f)
49+
50+
X = fromhex_all(test_data["pubkeys"])
51+
pnonce = fromhex_all(test_data["pnonces"])
52+
tweak = fromhex_all(test_data["tweaks"])
53+
valid_test_cases = test_data["valid_test_cases"]
54+
55+
for (i, test_case) in enumerate(valid_test_cases):
56+
pubkeys = [X[i] for i in test_case["key_indices"]]
57+
tweaks = [tweak[i] for i in test_case["tweak_indices"]]
58+
is_xonly = test_case["is_xonly"]
59+
60+
_, gacc, _ = key_agg_and_tweak(pubkeys, tweaks, is_xonly)
61+
# Make sure the vectors include tests for gacc = 1 and -1
62+
if i == 0:
63+
assert gacc == n - 1
64+
if i == 1:
65+
assert gacc == 1
66+
67+
def sig_agg_vectors():
68+
print("sig_agg_vectors.json:")
69+
sk = fromhex_all([
70+
"7FB9E0E687ADA1EEBF7ECFE2F21E73EBDB51A7D450948DFE8D76D7F2D1007671",
71+
"3874D22DE7A7290C49CE7F1DC17D1A8CD8918E1F799055139D57FC0988D04D10",
72+
"D0EA1B84481ED1BCFAA39D6775F97BDC9BF8D7C02FD0C009D6D85BAE5EC7B87A",
73+
"FC2BF9E056B273AF0A8AABB815E541A3552C142AC10D4FE584F01D2CAB84F577"])
74+
pubkeys = list(map(lambda secret: individual_pk(secret), sk))
75+
indices32 = [i.to_bytes(32, 'big') for i in range(6)]
76+
secnonces, pnonces = zip(*[nonce_gen_internal(r, None, pubkeys[0], None, None, None) for r in indices32])
77+
tweaks = fromhex_all([
78+
"B511DA492182A91B0FFB9A98020D55F260AE86D7ECBD0399C7383D59A5F2AF7C",
79+
"A815FE049EE3C5AAB66310477FBC8BCCCAC2F3395F59F921C364ACD78A2F48DC",
80+
"75448A87274B056468B977BE06EB1E9F657577B7320B0A3376EA51FD420D18A8"])
81+
msg = bytes.fromhex("599C67EA410D005B9DA90817CF03ED3B1C868E4DA4EDF00A5880B0082C237869")
82+
83+
psigs = [None] * 9
84+
85+
valid_test_cases = [
86+
{
87+
"aggnonce": None,
88+
"nonce_indices": [0, 1],
89+
"key_indices": [0, 1],
90+
"tweak_indices": [],
91+
"is_xonly": [],
92+
"psig_indices": [0, 1],
93+
}, {
94+
"aggnonce": None,
95+
"nonce_indices": [0, 2],
96+
"key_indices": [0, 2],
97+
"tweak_indices": [],
98+
"is_xonly": [],
99+
"psig_indices": [2, 3],
100+
}, {
101+
"aggnonce": None,
102+
"nonce_indices": [0, 3],
103+
"key_indices": [0, 2],
104+
"tweak_indices": [0],
105+
"is_xonly": [False],
106+
"psig_indices": [4, 5],
107+
}, {
108+
"aggnonce": None,
109+
"nonce_indices": [0, 4],
110+
"key_indices": [0, 3],
111+
"tweak_indices": [0, 1, 2],
112+
"is_xonly": [True, False, True],
113+
"psig_indices": [6, 7],
114+
},
115+
]
116+
for (i, test_case) in enumerate(valid_test_cases):
117+
is_xonly = test_case["is_xonly"]
118+
nonce_indices = test_case["nonce_indices"]
119+
key_indices = test_case["key_indices"]
120+
psig_indices = test_case["psig_indices"]
121+
vec_pnonces = [pnonces[i] for i in nonce_indices]
122+
vec_pubkeys = [pubkeys[i] for i in key_indices]
123+
vec_tweaks = [tweaks[i] for i in test_case["tweak_indices"]]
124+
125+
aggnonce = nonce_agg(vec_pnonces)
126+
test_case["aggnonce"] = aggnonce.hex().upper()
127+
session_ctx = SessionContext(aggnonce, vec_pubkeys, vec_tweaks, is_xonly, msg)
128+
129+
for j in range(len(key_indices)):
130+
# WARNING: An actual implementation should _not_ copy the secnonce.
131+
# Reusing the secnonce, as we do here for testing purposes, can leak the
132+
# secret key.
133+
secnonce_tmp = bytearray(secnonces[nonce_indices[j]][:64] + pubkeys[key_indices[j]])
134+
psigs[psig_indices[j]] = sign(secnonce_tmp, sk[key_indices[j]], session_ctx)
135+
sig = partial_sig_agg([psigs[i] for i in psig_indices], session_ctx)
136+
keygen_ctx = key_agg_and_tweak(vec_pubkeys, vec_tweaks, is_xonly)
137+
# To maximize coverage of the sig_agg algorithm, we want one public key
138+
# point with an even and one with an odd Y coordinate.
139+
if i == 0:
140+
assert(has_even_y(keygen_ctx[0]))
141+
if i == 1:
142+
assert(not has_even_y(keygen_ctx[0]))
143+
aggpk = get_xonly_pk(keygen_ctx)
144+
assert schnorr_verify(msg, aggpk, sig)
145+
test_case["expected"] = sig.hex().upper()
146+
147+
error_test_case = {
148+
"aggnonce": None,
149+
"nonce_indices": [0, 4],
150+
"key_indices": [0, 3],
151+
"tweak_indices": [0, 1, 2],
152+
"is_xonly": [True, False, True],
153+
"psig_indices": [7, 8],
154+
"error": {
155+
"type": "invalid_contribution",
156+
"signer": 1,
157+
"contrib": "psig",
158+
},
159+
"comment": "Partial signature is invalid because it exceeds group size"
160+
}
161+
162+
psigs[8] = bytes.fromhex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141")
163+
164+
vec_pnonces = [pnonces[i] for i in error_test_case["nonce_indices"]]
165+
aggnonce = nonce_agg(vec_pnonces)
166+
error_test_case["aggnonce"] = aggnonce.hex().upper()
167+
168+
def tohex_all(l):
169+
return list(map(lambda e: e.hex().upper(), l))
170+
171+
print(json.dumps({
172+
"pubkeys": tohex_all(pubkeys),
173+
"pnonces": tohex_all(pnonces),
174+
"tweaks": tohex_all(tweaks),
175+
"psigs": tohex_all(psigs),
176+
"msg": msg.hex().upper(),
177+
"valid_test_cases": valid_test_cases,
178+
"error_test_cases": [error_test_case]
179+
}, indent=4))
180+
181+
gen_key_agg_vectors()
182+
check_sign_verify_vectors()
183+
check_tweak_vectors()
184+
print()
185+
sig_agg_vectors()

0 commit comments

Comments
 (0)