@mysten-incubation/hashi 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @mysten-incubation/hashi
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 0af8dfa: Derive Bitcoin deposit addresses with Hashi's delayed MPC recovery taproot leaf.
8
+
3
9
  ## 0.3.0
4
10
 
5
11
  ### Minor Changes
@@ -56,14 +56,14 @@ declare function arkworksToSec1Compressed(ark: Uint8Array): Uint8Array;
56
56
  */
57
57
  declare function deriveChildPubkey(mpcKeyCompressed: Uint8Array, suiAddress: Uint8Array): Uint8Array;
58
58
  /**
59
- * Builds a 2-of-2 P2TR script-path-only address:
60
- * `tr(NUMS, multi_a(2, guardian, derived_mpc))`.
59
+ * Builds Hashi's P2TR script-path-only deposit address:
60
+ * `tr(NUMS, {multi_a(2, guardian, derived_mpc), and_v(v:older(delay), pk(derived_mpc))})`.
61
61
  *
62
62
  * The leaf script is a BIP-342 `multi_a` 2-of-2 — both Schnorr signatures must
63
63
  * be present to spend, ordered so that the witness stack is
64
64
  * `[derived_mpc_sig, guardian_sig, leaf_script, control_block]` (LIFO). This
65
- * is the exact script the bridge's withdrawal path constructs in
66
- * `crates/hashi-types/src/guardian/bitcoin_utils.rs`'s `compute_taproot_descriptor`.
65
+ * is the exact immediate-spend script the bridge's withdrawal path constructs
66
+ * in `crates/hashi-types/src/bitcoin/taproot.rs`'s `compute_taproot_descriptor`.
67
67
  *
68
68
  * The leaf script is exactly 70 bytes:
69
69
  *
@@ -80,11 +80,17 @@ declare function deriveChildPubkey(mpcKeyCompressed: Uint8Array, suiAddress: Uin
80
80
  * codegen routes through `Builder::push_int(2)` which emits the small-num
81
81
  * opcode. A literal pushdata would change the leaf hash and the address.
82
82
  *
83
+ * The recovery leaf script is a BIP-342 `and_v(v:older(delay), pk(derived_mpc))`:
84
+ * after Hashi's 60-day BIP-68 relative timelock, the MPC child key alone can
85
+ * spend the output if the guardian key is unavailable.
86
+ *
83
87
  * The taproot output key is computed per BIP-341:
84
88
  *
85
89
  * ```text
86
- * leafHash = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(script) ‖ script)
87
- * tweak = tagged_hash("TapTweak", NUMSleafHash)
90
+ * leaf1 = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(two_of_two_script) ‖ two_of_two_script)
91
+ * leaf2 = tagged_hash("TapLeaf", 0xC0 compact_size(recovery_script) recovery_script)
92
+ * root = tagged_hash("TapBranch", min(leaf1, leaf2) ‖ max(leaf1, leaf2))
93
+ * tweak = tagged_hash("TapTweak", NUMS ‖ root)
88
94
  * outputKey = NUMS + tweak × G
89
95
  * ```
90
96
  *
@@ -128,11 +134,12 @@ interface DepositAddressInputs {
128
134
  * Main entry point for the address derivation pipeline. Combines
129
135
  * {@link deriveChildPubkey} and {@link twoOfTwoTaprootScriptPathAddress} into
130
136
  * a single call. The produced address matches the Rust node's
131
- * `bitcoin_utils::two_of_two_taproot_script_path_address` byte-for-byte.
137
+ * `hashi_types::bitcoin::taproot::taproot_address` byte-for-byte.
132
138
  *
133
139
  * The address scheme is:
134
140
  * ```text
135
- * tr(NUMS, multi_a(2, guardian, derive(mpc_master, sui_address)))
141
+ * tr(NUMS, {multi_a(2, guardian, derive(mpc_master, sui_address)),
142
+ * and_v(v:older(delay), pk(derive(mpc_master, sui_address)))})
136
143
  * ```
137
144
  *
138
145
  * @returns bech32m-encoded P2TR deposit address (e.g. `tb1p…` for signet)
package/dist/bitcoin.mjs CHANGED
@@ -16,10 +16,10 @@ import { concatBytes } from "@noble/hashes/utils.js";
16
16
  * and the Hashi guardian co-sign the withdrawal that spends it, and the bridge
17
17
  * mints equivalent tokens on Sui.
18
18
  *
19
- * The deposit address is a 2-of-2 Pay-to-Taproot (P2TR / BIP-341) script-path
20
- * address whose spending condition is `multi_a(2, guardian_btc_pubkey,
21
- * derive(mpc_master, sui_address))` both the guardian enclave's BIP-340 key
22
- * and the MPC-derived child key must produce a Schnorr signature to spend.
19
+ * The deposit address is a Pay-to-Taproot (P2TR / BIP-341) script-path address
20
+ * with two leaves: an immediate `multi_a(2, guardian_btc_pubkey,
21
+ * derive(mpc_master, sui_address))` spend, and a delayed MPC-only recovery
22
+ * spend after Hashi's BIP-68 relative timelock.
23
23
  *
24
24
  * The full derivation pipeline is:
25
25
  *
@@ -34,23 +34,24 @@ import { concatBytes } from "@noble/hashes/utils.js";
34
34
  * (see {@link deriveChildPubkey}). This replicates the Rust function
35
35
  * `fastcrypto_tbls::threshold_schnorr::key_derivation::derive_verifying_key`.
36
36
  *
37
- * 3. **Build** the taproot address: `tr(NUMS, multi_a(2, guardian, child))`
37
+ * 3. **Build** the taproot address:
38
+ * `tr(NUMS, {multi_a(2, guardian, child), and_v(v:older(delay), pk(child))})`
38
39
  * where NUMS is a Nothing-Up-My-Sleeve point with no known private key,
39
40
  * forcing all spends through the script path (see
40
41
  * {@link twoOfTwoTaprootScriptPathAddress}).
41
42
  *
42
43
  * The end-to-end helper {@link generateDepositAddress} combines steps 2–3.
43
44
  *
44
- * Mirrors `two_of_two_taproot_script_path_address` in
45
- * `crates/hashi-types/src/guardian/bitcoin_utils.rs`. Cross-language test
46
- * vectors live in both this file's unit tests and the matching Rust unit test
47
- * `cross_lang_2of2_test_vectors`.
45
+ * Mirrors `taproot_address` in `crates/hashi-types/src/bitcoin/taproot.rs`.
46
+ * Cross-language test vectors live in both this file's unit tests and the
47
+ * matching Rust unit test `cross_lang_2of2_test_vectors`.
48
48
  *
49
49
  * @see https://mystenlabs.github.io/hashi/design/address-scheme.html
50
50
  */
51
51
  const Point = secp256k1.Point;
52
52
  const CURVE_ORDER = Point.CURVE().n;
53
53
  const NUMS_POINT = Point.fromBytes(concatBytes(new Uint8Array([2]), NUMS_KEY));
54
+ const HASHI_MPC_RECOVERY_DELAY_SEQUENCE = 1 << 22 | Math.ceil(1440 * 60 * 60 / 512);
54
55
  /** BIP-340 tagged hash: SHA256(SHA256(tag) ‖ SHA256(tag) ‖ msg) */
55
56
  function taggedHash(tag, ...msgs) {
56
57
  const tagHash = sha256(new TextEncoder().encode(tag));
@@ -62,6 +63,37 @@ function bytesToNumberBE(bytes) {
62
63
  for (const byte of bytes) n = n << 8n | BigInt(byte);
63
64
  return n;
64
65
  }
66
+ /** CompactSize for scripts small enough to fit in one byte. */
67
+ function compactSize(len) {
68
+ if (len >= 253) throw new Error(`Unsupported script length ${len}`);
69
+ return new Uint8Array([len]);
70
+ }
71
+ function scriptNum(n) {
72
+ if (n === 0) return new Uint8Array();
73
+ const bytes = [];
74
+ let value = n;
75
+ while (value > 0) {
76
+ bytes.push(value & 255);
77
+ value >>= 8;
78
+ }
79
+ if ((bytes[bytes.length - 1] & 128) !== 0) bytes.push(0);
80
+ return new Uint8Array(bytes);
81
+ }
82
+ function pushBytes(bytes) {
83
+ if (bytes.length >= 76) throw new Error(`Unsupported push length ${bytes.length}`);
84
+ return concatBytes(new Uint8Array([bytes.length]), bytes);
85
+ }
86
+ function lexicographicCompare(a, b) {
87
+ const len = Math.min(a.length, b.length);
88
+ for (let i = 0; i < len; i++) if (a[i] !== b[i]) return a[i] - b[i];
89
+ return a.length - b.length;
90
+ }
91
+ function tapLeafHash(script) {
92
+ return taggedHash("TapLeaf", new Uint8Array([192]), compactSize(script.length), script);
93
+ }
94
+ function tapBranchHash(a, b) {
95
+ return lexicographicCompare(a, b) <= 0 ? taggedHash("TapBranch", a, b) : taggedHash("TapBranch", b, a);
96
+ }
65
97
  /**
66
98
  * Converts a 33-byte arkworks-compressed secp256k1 point to 33-byte SEC1 compressed format.
67
99
  *
@@ -136,14 +168,14 @@ function deriveChildPubkey(mpcKeyCompressed, suiAddress) {
136
168
  return parentPoint.add(Point.BASE.multiply(tweakScalar)).toBytes(true).slice(1);
137
169
  }
138
170
  /**
139
- * Builds a 2-of-2 P2TR script-path-only address:
140
- * `tr(NUMS, multi_a(2, guardian, derived_mpc))`.
171
+ * Builds Hashi's P2TR script-path-only deposit address:
172
+ * `tr(NUMS, {multi_a(2, guardian, derived_mpc), and_v(v:older(delay), pk(derived_mpc))})`.
141
173
  *
142
174
  * The leaf script is a BIP-342 `multi_a` 2-of-2 — both Schnorr signatures must
143
175
  * be present to spend, ordered so that the witness stack is
144
176
  * `[derived_mpc_sig, guardian_sig, leaf_script, control_block]` (LIFO). This
145
- * is the exact script the bridge's withdrawal path constructs in
146
- * `crates/hashi-types/src/guardian/bitcoin_utils.rs`'s `compute_taproot_descriptor`.
177
+ * is the exact immediate-spend script the bridge's withdrawal path constructs
178
+ * in `crates/hashi-types/src/bitcoin/taproot.rs`'s `compute_taproot_descriptor`.
147
179
  *
148
180
  * The leaf script is exactly 70 bytes:
149
181
  *
@@ -160,11 +192,17 @@ function deriveChildPubkey(mpcKeyCompressed, suiAddress) {
160
192
  * codegen routes through `Builder::push_int(2)` which emits the small-num
161
193
  * opcode. A literal pushdata would change the leaf hash and the address.
162
194
  *
195
+ * The recovery leaf script is a BIP-342 `and_v(v:older(delay), pk(derived_mpc))`:
196
+ * after Hashi's 60-day BIP-68 relative timelock, the MPC child key alone can
197
+ * spend the output if the guardian key is unavailable.
198
+ *
163
199
  * The taproot output key is computed per BIP-341:
164
200
  *
165
201
  * ```text
166
- * leafHash = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(script) ‖ script)
167
- * tweak = tagged_hash("TapTweak", NUMSleafHash)
202
+ * leaf1 = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(two_of_two_script) ‖ two_of_two_script)
203
+ * leaf2 = tagged_hash("TapLeaf", 0xC0 compact_size(recovery_script) recovery_script)
204
+ * root = tagged_hash("TapBranch", min(leaf1, leaf2) ‖ max(leaf1, leaf2))
205
+ * tweak = tagged_hash("TapTweak", NUMS ‖ root)
168
206
  * outputKey = NUMS + tweak × G
169
207
  * ```
170
208
  *
@@ -182,16 +220,21 @@ function deriveChildPubkey(mpcKeyCompressed, suiAddress) {
182
220
  function twoOfTwoTaprootScriptPathAddress(guardianBtcXOnly, derivedMpcXOnly, network) {
183
221
  if (guardianBtcXOnly.length !== 32) throw new Error(`Expected 32-byte x-only guardian pubkey, got ${guardianBtcXOnly.length}`);
184
222
  if (derivedMpcXOnly.length !== 32) throw new Error(`Expected 32-byte x-only derived MPC pubkey, got ${derivedMpcXOnly.length}`);
185
- const tapscript = new Uint8Array(70);
186
- tapscript[0] = 32;
187
- tapscript.set(guardianBtcXOnly, 1);
188
- tapscript[33] = 172;
189
- tapscript[34] = 32;
190
- tapscript.set(derivedMpcXOnly, 35);
191
- tapscript[67] = 186;
192
- tapscript[68] = 82;
193
- tapscript[69] = 156;
194
- const tweakScalar = bytesToNumberBE(taggedHash("TapTweak", NUMS_KEY, taggedHash("TapLeaf", new Uint8Array([192, tapscript.length]), tapscript))) % CURVE_ORDER;
223
+ const twoOfTwoScript = new Uint8Array(70);
224
+ twoOfTwoScript[0] = 32;
225
+ twoOfTwoScript.set(guardianBtcXOnly, 1);
226
+ twoOfTwoScript[33] = 172;
227
+ twoOfTwoScript[34] = 32;
228
+ twoOfTwoScript.set(derivedMpcXOnly, 35);
229
+ twoOfTwoScript[67] = 186;
230
+ twoOfTwoScript[68] = 82;
231
+ twoOfTwoScript[69] = 156;
232
+ const recoveryScript = concatBytes(pushBytes(scriptNum(HASHI_MPC_RECOVERY_DELAY_SEQUENCE)), new Uint8Array([
233
+ 178,
234
+ 105,
235
+ 32
236
+ ]), derivedMpcXOnly, new Uint8Array([172]));
237
+ const tweakScalar = bytesToNumberBE(taggedHash("TapTweak", NUMS_KEY, tapBranchHash(tapLeafHash(twoOfTwoScript), tapLeafHash(recoveryScript)))) % CURVE_ORDER;
195
238
  const outputKey = NUMS_POINT.add(Point.BASE.multiply(tweakScalar)).toBytes(true).slice(1);
196
239
  const words = [1, ...bech32m.toWords(outputKey)];
197
240
  return bech32m.encode(NETWORK_HRP[network], words);
@@ -202,11 +245,12 @@ function twoOfTwoTaprootScriptPathAddress(guardianBtcXOnly, derivedMpcXOnly, net
202
245
  * Main entry point for the address derivation pipeline. Combines
203
246
  * {@link deriveChildPubkey} and {@link twoOfTwoTaprootScriptPathAddress} into
204
247
  * a single call. The produced address matches the Rust node's
205
- * `bitcoin_utils::two_of_two_taproot_script_path_address` byte-for-byte.
248
+ * `hashi_types::bitcoin::taproot::taproot_address` byte-for-byte.
206
249
  *
207
250
  * The address scheme is:
208
251
  * ```text
209
- * tr(NUMS, multi_a(2, guardian, derive(mpc_master, sui_address)))
252
+ * tr(NUMS, {multi_a(2, guardian, derive(mpc_master, sui_address)),
253
+ * and_v(v:older(delay), pk(derive(mpc_master, sui_address)))})
210
254
  * ```
211
255
  *
212
256
  * @returns bech32m-encoded P2TR deposit address (e.g. `tb1p…` for signet)
package/dist/client.d.mts CHANGED
@@ -52,10 +52,10 @@ declare class HashiClient {
52
52
  *
53
53
  * Fetches the MPC committee public key and the guardian's BTC public key
54
54
  * from on-chain, derives an MPC child key against the Sui address, and
55
- * builds a 2-of-2 taproot script-path address
56
- * (`tr(NUMS, multi_a(2, guardian, derived_mpc))`). The address matches
57
- * the bridge's on-chain `validate_deposit_request_derivation_path` check
58
- * byte-for-byte.
55
+ * builds the Hashi taproot script tree: an immediate 2-of-2 leaf
56
+ * (`multi_a(2, guardian, derived_mpc)`) plus a delayed MPC-only recovery
57
+ * leaf. The address matches the bridge's on-chain
58
+ * `validate_deposit_request_derivation_path` check byte-for-byte.
59
59
  *
60
60
  * The MPC key (`committee_set.mpc_public_key`) and the guardian key
61
61
  * (`guardian_btc_public_key` config) come from a single fetch of the
package/dist/client.mjs CHANGED
@@ -444,10 +444,10 @@ var HashiClient = class {
444
444
  *
445
445
  * Fetches the MPC committee public key and the guardian's BTC public key
446
446
  * from on-chain, derives an MPC child key against the Sui address, and
447
- * builds a 2-of-2 taproot script-path address
448
- * (`tr(NUMS, multi_a(2, guardian, derived_mpc))`). The address matches
449
- * the bridge's on-chain `validate_deposit_request_derivation_path` check
450
- * byte-for-byte.
447
+ * builds the Hashi taproot script tree: an immediate 2-of-2 leaf
448
+ * (`multi_a(2, guardian, derived_mpc)`) plus a delayed MPC-only recovery
449
+ * leaf. The address matches the bridge's on-chain
450
+ * `validate_deposit_request_derivation_path` check byte-for-byte.
451
451
  *
452
452
  * The MPC key (`committee_set.mpc_public_key`) and the guardian key
453
453
  * (`guardian_btc_public_key` config) come from a single fetch of the
package/dist/types.d.mts CHANGED
@@ -53,8 +53,8 @@ interface GovernanceConfig {
53
53
  readonly guardianPublicKey: Uint8Array | null;
54
54
  /**
55
55
  * Guardian's BIP-340 x-only secp256k1 BTC public key (32 bytes), the
56
- * `pk1` slot of the on-chain 2-of-2 deposit-address descriptor. `null`
57
- * if unset (deposit-address derivation will fail).
56
+ * `pk1` slot of the immediate 2-of-2 leaf in the on-chain deposit-address
57
+ * descriptor. `null` if unset (deposit-address derivation will fail).
58
58
  */
59
59
  readonly guardianBtcPublicKey: Uint8Array | null;
60
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mysten-incubation/hashi",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "TypeScript SDK for the Hashi Sui Move smart contracts",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Mysten Labs <build@mystenlabs.com>",