Skip to content

Commit 0c7480a

Browse files
jurvisjesseposner
authored andcommitted
BIP 89: Chain Code Delegation for Private Collaborative Custody (bitcoin#2004)
* Add Chaincode Delegation BIP * Update license to BSD-3-Clause and expand blinded signing documentation * Address initial PR comments * Update with BIP number assignment * Fix delegator_sign test vector * Upgrade secp256k1lab and add license file - Upgrade vendored secp256k1lab to commit a265da1 (adds type annotations) - Add COPYING file to satisfy MIT license requirements - Document secp256k1lab commit reference in BIP text * Fix type checker and linter issues in reference implementation - Fix TweakContext to use Scalar types for gacc/tacc - Replace HashFunction enum with Callable type alias - Fix bytearray to bytes conversion in blind_sign - Move imports to top of file - Fix boolean comparison style (use 'not' instead of '== False') - Add proper type annotations and casts for dict handling - Remove unused imports and type ignore comments * Address PR review comments on terminology and clarity - Add intro explaining delegation naming (chain code is delegated, not signing authority) - Reorder terminology to list Delegator before Delegatee - Replace "quorum" with clearer "can co-sign for UTXOs" language - Clarify derivation constraints in terms of delegatee's extended key - Rename "Delegatee Signing" section to "Signing Modes" - Fix "delegatee can apply" to "delegator can produce" (line 112) - Replace undefined "caller" with "delegatee" (line 173) - Clarify "Change outputs" to "Tweaks for change outputs" (line 98) - Add note that message is separate from CCD bundle - Add note on application-specific verification (addresses, amounts) - Add transition sentence clarifying non-concurrent protocol scope * Add changelog entry for 0.1.3 * Fix header: use Authors (plural) for multiple authors * Fix BIP header format for CI compliance - Change Type from 'Standards Track' to 'Specification' (valid type) - Change 'Created' to 'Assigned' (correct field name per BIP format) - Change 'Post-History' to 'Discussion' (recognized field in buildtable.pl) * Apply suggestion from @murchandamus --------- Co-authored-by: Jesse Posner <jesse.posner@gmail.com>
1 parent fe188c6 commit 0c7480a

21 files changed

Lines changed: 2385 additions & 0 deletions

README.mediawiki

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,13 @@ users (see also: [https://en.bitcoin.it/wiki/Economic_majority economic majority
512512
| Dmitry Petukhov
513513
| Informational
514514
| Complete
515+
|-
516+
| [[bip-0089.mediawiki|89]]
517+
| Applications
518+
| Chain Code Delegation
519+
| Jesse Posner, Jurvis Tan
520+
| Specification
521+
| Draft
515522
|- style="background-color: #cfffcf"
516523
| [[bip-0090.mediawiki|90]]
517524
|

bip-0089.mediawiki

Lines changed: 405 additions & 0 deletions
Large diffs are not rendered by default.

bip-0089/bip32.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""BIP32 helpers for the CCD reference implementation."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
import hmac
7+
from hashlib import new as hashlib_new, sha256, sha512
8+
from typing import List, Tuple, Mapping, Sequence
9+
10+
from secp256k1lab.secp256k1 import G, GE, Scalar
11+
12+
CURVE_N = Scalar.SIZE
13+
14+
def int_to_bytes(value: int, length: int) -> bytes:
15+
return value.to_bytes(length, "big")
16+
17+
18+
def bytes_to_int(data: bytes) -> int:
19+
return int.from_bytes(data, "big")
20+
21+
def compress_point(point: GE) -> bytes:
22+
if point.infinity:
23+
raise ValueError("Cannot compress point at infinity")
24+
return point.to_bytes_compressed()
25+
26+
27+
def decompress_point(data: bytes) -> GE:
28+
return GE.from_bytes_compressed(data)
29+
30+
def apply_tweak_to_public(base_public: bytes, tweak: int) -> bytes:
31+
base_point = GE.from_bytes_compressed(base_public)
32+
tweaked_point = base_point + (tweak % CURVE_N) * G
33+
if tweaked_point.infinity:
34+
raise ValueError("Tweaked key is at infinity")
35+
return tweaked_point.to_bytes_compressed()
36+
37+
38+
def apply_tweak_to_secret(base_secret: int, tweak: int) -> int:
39+
if not (0 < base_secret < CURVE_N):
40+
raise ValueError("Invalid base secret scalar")
41+
return (base_secret + tweak) % CURVE_N
42+
43+
def decode_path(path_elements: Sequence[object]) -> List[int]:
44+
result: List[int] = []
45+
for element in path_elements:
46+
if isinstance(element, int):
47+
index = element
48+
else:
49+
element_str = str(element)
50+
hardened = element_str.endswith("'") or element_str.endswith("h")
51+
suffix = element_str[:-1] if hardened else element_str
52+
if not suffix:
53+
raise AssertionError("invalid derivation index")
54+
index = int(suffix)
55+
if hardened:
56+
index |= HARDENED_INDEX
57+
result.append(index)
58+
return result
59+
60+
HARDENED_INDEX = 0x80000000
61+
62+
63+
def _hash160(data: bytes) -> bytes:
64+
return hashlib_new("ripemd160", sha256(data).digest()).digest()
65+
66+
67+
@dataclass
68+
class ExtendedPublicKey:
69+
point: GE
70+
chain_code: bytes
71+
depth: int = 0
72+
parent_fingerprint: bytes = b"\x00\x00\x00\x00"
73+
child_number: int = 0
74+
75+
def fingerprint(self) -> bytes:
76+
return _hash160(compress_point(self.point))[:4]
77+
78+
def derive_child(self, index: int) -> Tuple[int, "ExtendedPublicKey"]:
79+
tweak, child_point, child_chain = derive_public_child(self.point, self.chain_code, index)
80+
child = ExtendedPublicKey(
81+
point=child_point,
82+
chain_code=child_chain,
83+
depth=self.depth + 1,
84+
parent_fingerprint=self.fingerprint(),
85+
child_number=index,
86+
)
87+
return tweak, child
88+
89+
90+
def derive_public_child(parent_point: GE, chain_code: bytes, index: int) -> Tuple[int, GE, bytes]:
91+
if index >= HARDENED_INDEX:
92+
raise ValueError("Hardened derivations are not supported for delegates")
93+
94+
data = compress_point(parent_point) + int_to_bytes(index, 4)
95+
il_ir = hmac.new(chain_code, data, sha512).digest()
96+
il, ir = il_ir[:32], il_ir[32:]
97+
tweak = bytes_to_int(il)
98+
if tweak >= CURVE_N:
99+
raise ValueError("Invalid tweak derived (>= curve order)")
100+
101+
child_point_bytes = apply_tweak_to_public(compress_point(parent_point), tweak)
102+
child_point = decompress_point(child_point_bytes)
103+
return tweak, child_point, ir
104+
105+
106+
def parse_path(path: str) -> List[int]:
107+
if not path or path in {"m", "M"}:
108+
return []
109+
if path.startswith(("m/", "M/")):
110+
path = path[2:]
111+
112+
components: List[int] = []
113+
for element in path.split("/"):
114+
if element.endswith("'") or element.endswith("h"):
115+
raise ValueError("Hardened steps are not allowed in CCD derivations")
116+
index = int(element)
117+
if index < 0 or index >= HARDENED_INDEX:
118+
raise ValueError("Derivation index out of range")
119+
components.append(index)
120+
return components
121+
122+
def parse_extended_public_key(data: Mapping[str, object]) -> ExtendedPublicKey:
123+
compressed_hex = data.get("compressed")
124+
if not isinstance(compressed_hex, str):
125+
raise ValueError("Compressed must be a string")
126+
127+
chain_code_hex = data.get("chain_code")
128+
if not isinstance(chain_code_hex, str):
129+
raise ValueError("Chain code must be a string")
130+
131+
depth = data.get("depth")
132+
if not isinstance(depth, int):
133+
raise ValueError("Depth must be an integer")
134+
135+
child_number = data.get("child_number", 0)
136+
if not isinstance(child_number, int):
137+
raise ValueError("Child number must be an integer")
138+
139+
parent_fp_hex = data.get("parent_fingerprint", "00000000")
140+
141+
compressed = bytes.fromhex(compressed_hex)
142+
chain_code = bytes.fromhex(chain_code_hex)
143+
parent_fp = bytes.fromhex(str(parent_fp_hex))
144+
return build_extended_public_key(
145+
compressed,
146+
chain_code,
147+
depth=depth,
148+
parent_fingerprint=parent_fp,
149+
child_number=child_number,
150+
)
151+
152+
153+
def build_extended_public_key(
154+
compressed: bytes,
155+
chain_code: bytes,
156+
*,
157+
depth: int = 0,
158+
parent_fingerprint: bytes = b"\x00\x00\x00\x00",
159+
child_number: int = 0,
160+
) -> ExtendedPublicKey:
161+
if len(chain_code) != 32:
162+
raise ValueError("Chain code must be 32 bytes")
163+
point = decompress_point(compressed)
164+
return ExtendedPublicKey(
165+
point=point,
166+
chain_code=chain_code,
167+
depth=depth,
168+
parent_fingerprint=parent_fingerprint,
169+
child_number=child_number,
170+
)

bip-0089/descriptor.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Helpers for working with minimal SortedMulti descriptor templates."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Sequence
7+
8+
9+
@dataclass(frozen=True)
10+
class SortedMultiDescriptorTemplate:
11+
"""Minimal representation of a ``wsh(sortedmulti(m, ...))`` descriptor."""
12+
13+
threshold: int
14+
15+
def witness_script(self, tweaked_keys: Sequence[bytes]) -> bytes:
16+
"""Return the witness script for ``wsh(sortedmulti(threshold, tweaked_keys))``."""
17+
18+
if not tweaked_keys:
19+
raise ValueError("sortedmulti requires at least one key")
20+
if not 1 <= self.threshold <= len(tweaked_keys):
21+
raise ValueError("threshold must satisfy 1 <= m <= n")
22+
23+
for key in tweaked_keys:
24+
if len(key) != 33:
25+
raise ValueError("sortedmulti keys must be 33-byte compressed pubkeys")
26+
27+
sorted_keys = sorted(tweaked_keys)
28+
script = bytearray()
29+
script.append(_op_n(self.threshold))
30+
for key in sorted_keys:
31+
script.append(len(key))
32+
script.extend(key)
33+
script.append(_op_n(len(sorted_keys)))
34+
script.append(0xAE) # OP_CHECKMULTISIG
35+
return bytes(script)
36+
37+
def _op_n(value: int) -> int:
38+
if not 0 <= value <= 16:
39+
raise ValueError("OP_N value out of range")
40+
if value == 0:
41+
return 0x00
42+
return 0x50 + value

0 commit comments

Comments
 (0)