Skip to content

Commit cb5bed1

Browse files
OttoAllmendingerllm-git
andcommitted
feat(utxo-lib): document taproot key aggregation differences
This commit adds comprehensive documentation to explain the differences between BitGo's legacy p2tr (chains 30, 31) and standard p2trMusig2 (chains 40, 41) address types. It clarifies how their key aggregation algorithms differ, particularly regarding when x-only conversion happens. Additionally, it adds thorough tests with fixtures for both address types to demonstrate their behavior and ensure compatibility. Issue: BTC-2652 Co-authored-by: llm-git <[email protected]>
1 parent 38d02a5 commit cb5bed1

File tree

7 files changed

+429
-46
lines changed

7 files changed

+429
-46
lines changed

modules/utxo-lib/src/bitgo/outputScripts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ function getRedeemIndex(keyCombinations: [Buffer, Buffer][], signer: Buffer, cos
268268
throw new Error(`could not find singer/cosigner combination`);
269269
}
270270

271-
function createPaymentP2trCommon(
271+
export function createPaymentP2trCommon(
272272
scriptType: 'p2tr' | 'p2trMusig2',
273273
pubkeys: Triple<Buffer>,
274274
redeemIndex?: number | { signer: Buffer; cosigner: Buffer }

modules/utxo-lib/src/payments/p2tr.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,12 @@ export function p2tr(a: Payment, opts?: PaymentOpts): Payment {
125125
} else if (a.pubkeys && a.pubkeys.length === 1) {
126126
return a.pubkeys[0];
127127
} else if (a.pubkeys && a.pubkeys.length > 1) {
128-
// multiple pubkeys
129128
if (isPlainPubkeys(a.pubkeys)) {
129+
// p2trMusig2 address type
130130
return Buffer.from(musig.getXOnlyPubkey(musig.keyAgg(a.pubkeys)));
131131
}
132132

133+
// legacy p2tr address type
133134
return Buffer.from(taproot.aggregateMuSigPubkeys(ecc, a.pubkeys));
134135
} else if (_parsedControlBlock()) {
135136
return _parsedControlBlock()?.internalPubkey;

modules/utxo-lib/src/taproot.ts

Lines changed: 108 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
1-
// Taproot-specific key aggregation and taptree logic as defined in:
2-
// https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
3-
// https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki
1+
/**
2+
* This module provides taproot utilities including the legacy MuSig2 key aggregation
3+
* algorithm used by BitGo's deprecated `p2tr` script type (chains 30, 31).
4+
*
5+
* ## Legacy p2tr vs Standard p2trMusig2
6+
*
7+
* BitGo supports two taproot address types:
8+
*
9+
* 1. **Legacy `p2tr` (chains 30, 31) - DEPRECATED**
10+
* - Uses the `aggregateMuSigPubkeys()` function in this module
11+
* - Based on an older MuSig2 variant that predates BIP327
12+
* - Expects 32-byte x-only pubkeys with even Y coordinates
13+
* - Sorts keys AFTER x-only conversion
14+
* - Corresponds to MuSig2 before the 32-byte to 33-byte key change
15+
* - See: https://github.com/jonasnick/bips/pull/37
16+
*
17+
* 2. **Standard `p2trMusig2` (chains 40, 41) - RECOMMENDED**
18+
* - Uses the `@brandonblack/musig` library (standard BIP327 implementation)
19+
* - Uses full 33-byte compressed pubkeys throughout aggregation
20+
* - Key order affects the resulting aggregate key
21+
* - Fully compatible with the BIP327 specification
22+
*
23+
* ## Key Difference
24+
*
25+
* The critical difference is **when x-only conversion happens**:
26+
* - Legacy: Converts to x-only BEFORE sorting (produces order-independent keys)
27+
* - Standard: Uses 33-byte keys throughout (key order matters)
28+
*
29+
* For the same two pubkeys, these methods produce DIFFERENT aggregate keys because
30+
* the sort order differs between 33-byte and 32-byte representations.
31+
*
32+
* See `modules/utxo-lib/bip-0327/README.md` for detailed comparison and test cases.
33+
*/
434

535
import { TapTree as PsbtTapTree, TapLeaf as PsbtTapLeaf } from 'bip174/src/lib/interfaces';
636
import assert = require('assert');
@@ -27,59 +57,114 @@ export interface TinySecp256k1Interface {
2757
}
2858

2959
/**
30-
* Aggregates a list of public keys into a single MuSig2* public key
31-
* according to the MuSig2 paper.
60+
* Aggregates a list of public keys into a single public key using the legacy MuSig2 algorithm.
61+
*
62+
* This implements the deprecated key aggregation method used by BitGo's `p2tr` script type
63+
* (chains 30, 31). It corresponds to an older variant of MuSig2 that predates the change
64+
* from 32-byte to 33-byte keys in the BIP327 specification.
65+
*
66+
* ## Algorithm
67+
*
68+
* The implementation follows the MuSig2 key aggregation scheme:
69+
*
70+
* ```
71+
* P = sum_i (μ_i * P_i)
72+
* ```
73+
*
74+
* where:
75+
* - `P_i` is the public key of the i-th signer
76+
* - `μ_i` is the MuSig coefficient computed as:
77+
* - `L = TaggedHash("KeyAgg list", P_1 || P_2 || ... || P_n)`
78+
* - `μ_i = TaggedHash("KeyAgg coefficient", L || P_i)` for most keys
79+
* - `μ_i = 1` for the second unique key (optimization to save an exponentiation)
80+
*
81+
* ## Key Characteristics (Legacy Variant)
82+
*
83+
* 1. **X-only pubkeys**: Expects 32-byte x-only pubkeys (assumes even Y coordinates)
84+
* 2. **Pre-aggregation sorting**: Sorts keys in ascending order BEFORE aggregation
85+
* 3. **Order-independent**: Due to sorting, key order doesn't affect the result
86+
* 4. **33-byte internal representation**: Internally prepends 0x02 prefix for elliptic curve operations
87+
*
88+
* ## Differences from Standard MuSig2 (BIP327)
89+
*
90+
* The standard MuSig2 implementation (`@brandonblack/musig` library):
91+
* - Uses 33-byte compressed pubkeys throughout
92+
* - Does NOT sort keys before aggregation (key order matters)
93+
* - Produces different aggregate keys even for the same set of pubkeys
94+
*
95+
* ## Reference Implementation
96+
*
97+
* This corresponds to `key_agg_bitgo_p2tr_legacy()` in the Python reference implementation
98+
* at `modules/utxo-lib/bip-0327/reference.py`.
99+
*
32100
* @param ecc Elliptic curve implementation
33-
* @param pubkeys The list of pub keys to aggregate
34-
* @returns a 32 byte Buffer representing the aggregate key
101+
* @param pubkeys List of 32-byte x-only public keys to aggregate (must have even Y coordinates)
102+
* @returns 32-byte Buffer representing the x-only aggregate public key
103+
* @throws {Error} if fewer than 2 pubkeys provided or elliptic curve operations fail
104+
*
105+
* @see modules/utxo-lib/bip-0327/README.md for detailed comparison with standard MuSig2
106+
* @see https://github.com/jonasnick/bips/pull/37 for the MuSig2 specification change
35107
*/
36108
export function aggregateMuSigPubkeys(ecc: TinySecp256k1Interface, pubkeys: Buffer[]): Uint8Array {
37109
// TODO: Consider enforcing key uniqueness.
38110
assert(pubkeys.length > 1, 'at least two pubkeys are required for musig key aggregation');
39111

40-
// Sort the keys in ascending order
112+
// LEGACY BEHAVIOR: Sort the keys in ascending order BEFORE aggregation.
113+
// This makes the aggregate key independent of input order.
114+
// Standard MuSig2 (BIP327) does NOT sort, making key order significant.
41115
pubkeys.sort(Buffer.compare);
42116

43-
// In MuSig all signers contribute key material to a single signing key,
44-
// using the equation
45-
//
46-
// P = sum_i µ_i * P_i
47-
//
48-
// where `P_i` is the public key of the `i`th signer and `µ_i` is a so-called
49-
// _MuSig coefficient_ computed according to the following equation
50-
//
51-
// L = H(P_1 || P_2 || ... || P_n)
52-
// µ_i = H(L || P_i)
53-
117+
// Compute the "KeyAgg list" hash L = TaggedHash("KeyAgg list", P_1 || P_2 || ... || P_n)
118+
// This hash is used to derive the MuSig coefficients for each pubkey.
119+
// In the reference implementation (reference.py), this is done in hash_keys().
54120
const L = bcrypto.taggedHash('KeyAgg list', Buffer.concat(pubkeys));
55121

122+
// Find the second unique pubkey in the sorted list.
123+
// This key (and any keys identical to it) will use coefficient μ = 1 as an optimization.
124+
// This saves an expensive point multiplication (see MuSig2* appendix in the MuSig2 paper).
125+
// In the reference implementation, this is get_second_key().
56126
const secondUniquePubkey = pubkeys.find((pubkey) => !pubkeys[0].equals(pubkey));
57127

128+
// For each pubkey P_i, compute the tweaked pubkey μ_i * P_i
129+
// where μ_i is the MuSig coefficient for that key.
58130
const tweakedPubkeys: Uint8Array[] = pubkeys.map((pubkey) => {
131+
// Convert 32-byte x-only pubkey to 33-byte compressed format by prepending 0x02.
132+
// This assumes an even Y coordinate (standard for x-only pubkeys in taproot).
133+
// In the reference implementation, this is done implicitly in key_agg() via lift_x().
59134
const xyPubkey = Buffer.concat([EVEN_Y_COORD_PREFIX, pubkey]);
60135

61136
if (secondUniquePubkey !== undefined && secondUniquePubkey.equals(pubkey)) {
62-
// The second unique key in the pubkey list given to ''KeyAgg'' (as well
63-
// as any keys identical to this key) gets the constant KeyAgg
64-
// coefficient 1 which saves an exponentiation (see the MuSig2* appendix
65-
// in the MuSig2 paper).
137+
// Optimization: The second unique key gets coefficient μ = 1.
138+
// This means μ_i * P_i = 1 * P_i = P_i (no multiplication needed).
139+
// This saves an expensive elliptic curve point multiplication operation.
140+
// See key_agg_coeff_internal() in reference.py where it returns 1 for pk_ == pk2.
66141
return xyPubkey;
67142
}
68143

144+
// Compute the MuSig coefficient: μ_i = TaggedHash("KeyAgg coefficient", L || P_i)
145+
// In the reference implementation, this is key_agg_coeff_internal().
69146
const c = bcrypto.taggedHash('KeyAgg coefficient', Buffer.concat([L, pubkey]));
70147

148+
// Compute the tweaked pubkey: μ_i * P_i (elliptic curve point multiplication)
71149
const tweakedPubkey = ecc.pointMultiply(xyPubkey, c);
72150
if (!tweakedPubkey) {
73151
throw new Error('Failed to multiply pubkey by coefficient');
74152
}
75153
return tweakedPubkey;
76154
});
155+
156+
// Sum all the tweaked pubkeys to get the aggregate pubkey:
157+
// P = sum_i (μ_i * P_i) = μ_1*P_1 + μ_2*P_2 + ... + μ_n*P_n
158+
// This is elliptic curve point addition.
77159
const aggregatePubkey = tweakedPubkeys.reduce((prev, curr) => {
78160
const next = ecc.pointAdd(prev, curr);
79161
if (!next) throw new Error('Failed to sum pubkeys');
80162
return next;
81163
});
82164

165+
// Convert the aggregate pubkey from 33-byte compressed format back to 32-byte x-only format
166+
// by removing the first byte (the Y coordinate prefix).
167+
// In the reference implementation, this is done by get_xonly_pk() which calls xbytes().
83168
return aggregatePubkey.slice(1);
84169
}
85170

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
[
2+
{
3+
"scriptType": "p2tr",
4+
"privkeys": [
5+
"a07e682489dad68834f7df8a5c8b34f3b9ff9fdd8809e2ba53ae29df65fc146b",
6+
"34cdbbaf791590f0f774b4f96327efec72930b068d5ee348cd3acef615800602",
7+
"2d210ff6703d0fae0e9ca91e1d0bbab006b03e8e699f49becbaf554066fa79aa"
8+
],
9+
"pubkeys": [
10+
"02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7",
11+
"028714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2",
12+
"03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64"
13+
],
14+
"internalPubkey": "cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa",
15+
"controlBlocks": [
16+
{
17+
"redeemIndex": 0,
18+
"controlBlock": "c0cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aad88b89f6f10f490bb6e1e61585cb3e78f8b4993e574b4031cacc6859c5adbc45"
19+
},
20+
{
21+
"redeemIndex": 1,
22+
"controlBlock": "c0cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aab33e39fb32e503897e9cdc949597dac7b156017bf55a4f9802b619db07d3070a62959ac7472a3cd0ea894b23888341247d3c890c711fff8ac9b02177609e3e27"
23+
},
24+
{
25+
"redeemIndex": 2,
26+
"controlBlock": "c0cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa0e87e7b2bddc1e2f2cde702b5cbe51119df98538b35fa91c40a7c74fa9f5d39862959ac7472a3cd0ea894b23888341247d3c890c711fff8ac9b02177609e3e27"
27+
}
28+
],
29+
"tapTree": {
30+
"leaves": [
31+
{
32+
"script": "20d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7ad20203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64ac",
33+
"leafVersion": 192,
34+
"depth": 1
35+
},
36+
{
37+
"script": "20d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7ad208714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2ac",
38+
"leafVersion": 192,
39+
"depth": 2
40+
},
41+
{
42+
"script": "208714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2ad20203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64ac",
43+
"leafVersion": 192,
44+
"depth": 2
45+
}
46+
]
47+
},
48+
"taptreeRoot": "b69e64804422cb6cac96df1d742055b41aca27017dfcf79ef68482fad348b5c3",
49+
"output": "5120ef88931a66e09d2777276f13fc99305aa51d38642fd1c01efe461a4c84c8915a"
50+
},
51+
{
52+
"scriptType": "p2tr",
53+
"privkeys": [
54+
"2d210ff6703d0fae0e9ca91e1d0bbab006b03e8e699f49becbaf554066fa79aa",
55+
"34cdbbaf791590f0f774b4f96327efec72930b068d5ee348cd3acef615800602",
56+
"a07e682489dad68834f7df8a5c8b34f3b9ff9fdd8809e2ba53ae29df65fc146b"
57+
],
58+
"pubkeys": [
59+
"03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64",
60+
"028714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2",
61+
"02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7"
62+
],
63+
"internalPubkey": "cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa",
64+
"controlBlocks": [
65+
{
66+
"redeemIndex": 0,
67+
"controlBlock": "c0cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa154989ec963f9639848d336c522641b38bf5540ca0934318ac824e623ffd9e14"
68+
},
69+
{
70+
"redeemIndex": 1,
71+
"controlBlock": "c0cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa9f8d752c1becee80ffd87719934911d9c8aef659fc3ab512ba67f920ffc47545c3a4b27e58190225770a6cf2fb7ee0d9c536951637b3b0cea693d8ba9528853d"
72+
},
73+
{
74+
"redeemIndex": 2,
75+
"controlBlock": "c0cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa45fc694de6d51e7c6fcd37c35377b99e4e6e9a19adb600256a20dc0dd34561bcc3a4b27e58190225770a6cf2fb7ee0d9c536951637b3b0cea693d8ba9528853d"
76+
}
77+
],
78+
"tapTree": {
79+
"leaves": [
80+
{
81+
"script": "20203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64ad20d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7ac",
82+
"leafVersion": 192,
83+
"depth": 1
84+
},
85+
{
86+
"script": "20203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64ad208714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2ac",
87+
"leafVersion": 192,
88+
"depth": 2
89+
},
90+
{
91+
"script": "208714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2ad20d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7ac",
92+
"leafVersion": 192,
93+
"depth": 2
94+
}
95+
]
96+
},
97+
"taptreeRoot": "e4ca158ee6f82dec51f1ecec71665f0735c170bf89c1fe9f9e568ad6257fabc0",
98+
"output": "51209e609b5cbf529784691f5cb92dd2cf0ceb3c13d8b9539d7ba765f62e5e036379"
99+
}
100+
]
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
[
2+
{
3+
"scriptType": "p2trMusig2",
4+
"privkeys": [
5+
"a07e682489dad68834f7df8a5c8b34f3b9ff9fdd8809e2ba53ae29df65fc146b",
6+
"34cdbbaf791590f0f774b4f96327efec72930b068d5ee348cd3acef615800602",
7+
"2d210ff6703d0fae0e9ca91e1d0bbab006b03e8e699f49becbaf554066fa79aa"
8+
],
9+
"pubkeys": [
10+
"02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7",
11+
"028714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2",
12+
"03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64"
13+
],
14+
"internalPubkey": "c0e255b4510e041ab81151091d875687a618de314344dff4b73b1bcd366cdbd8",
15+
"controlBlocks": [
16+
{
17+
"redeemIndex": 0,
18+
"controlBlock": "c0c0e255b4510e041ab81151091d875687a618de314344dff4b73b1bcd366cdbd8b33e39fb32e503897e9cdc949597dac7b156017bf55a4f9802b619db07d3070a"
19+
},
20+
{
21+
"redeemIndex": 1,
22+
"controlBlock": "c0c0e255b4510e041ab81151091d875687a618de314344dff4b73b1bcd366cdbd80e87e7b2bddc1e2f2cde702b5cbe51119df98538b35fa91c40a7c74fa9f5d398"
23+
}
24+
],
25+
"tapTree": {
26+
"leaves": [
27+
{
28+
"script": "20d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7ad208714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2ac",
29+
"leafVersion": 192,
30+
"depth": 1
31+
},
32+
{
33+
"script": "208714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2ad20203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64ac",
34+
"leafVersion": 192,
35+
"depth": 1
36+
}
37+
]
38+
},
39+
"taptreeRoot": "d88b89f6f10f490bb6e1e61585cb3e78f8b4993e574b4031cacc6859c5adbc45",
40+
"output": "5120b1b559f099d5480951944bb9e5560b1485c51f7d15c9bb2864b2354de739beaf"
41+
},
42+
{
43+
"scriptType": "p2trMusig2",
44+
"privkeys": [
45+
"2d210ff6703d0fae0e9ca91e1d0bbab006b03e8e699f49becbaf554066fa79aa",
46+
"34cdbbaf791590f0f774b4f96327efec72930b068d5ee348cd3acef615800602",
47+
"a07e682489dad68834f7df8a5c8b34f3b9ff9fdd8809e2ba53ae29df65fc146b"
48+
],
49+
"pubkeys": [
50+
"03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64",
51+
"028714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2",
52+
"02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7"
53+
],
54+
"internalPubkey": "e48d309b535811eb0b148c4b0600a10e82e289899429e40aee05577504eca356",
55+
"controlBlocks": [
56+
{
57+
"redeemIndex": 0,
58+
"controlBlock": "c0e48d309b535811eb0b148c4b0600a10e82e289899429e40aee05577504eca3569f8d752c1becee80ffd87719934911d9c8aef659fc3ab512ba67f920ffc47545"
59+
},
60+
{
61+
"redeemIndex": 1,
62+
"controlBlock": "c0e48d309b535811eb0b148c4b0600a10e82e289899429e40aee05577504eca35645fc694de6d51e7c6fcd37c35377b99e4e6e9a19adb600256a20dc0dd34561bc"
63+
}
64+
],
65+
"tapTree": {
66+
"leaves": [
67+
{
68+
"script": "20203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64ad208714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2ac",
69+
"leafVersion": 192,
70+
"depth": 1
71+
},
72+
{
73+
"script": "208714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2ad20d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7ac",
74+
"leafVersion": 192,
75+
"depth": 1
76+
}
77+
]
78+
},
79+
"taptreeRoot": "154989ec963f9639848d336c522641b38bf5540ca0934318ac824e623ffd9e14",
80+
"output": "5120b402ebe79e4563cbc4619a7b2af7e1f9ff124edca871f688987221a09f17c4a7"
81+
}
82+
]

0 commit comments

Comments
 (0)