@mysten-incubation/hashi 0.2.0 → 0.3.0

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.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ced85d2: Derive deposit addresses as 2-of-2 (guardian, MPC-child) taproot to match the on-chain bridge (hashi#609). `generateDepositAddress` (pure helper) now takes a named-args object including `guardianBtcXOnly`; `HashiClient.generateDepositAddress` reads the guardian key from on-chain and fails fast with `HashiConfigError` when the deployment is not guardian-provisioned. `GovernanceConfig` gains `guardianUrl`, `guardianPublicKey`, `guardianBtcPublicKey`. Adds `twoOfTwoTaprootScriptPathAddress` as a public primitive and removes the single-key `taprootScriptPathAddress` helper, which the bridge no longer accepts.
8
+
3
9
  ## 0.2.0
4
10
 
5
11
  ### Minor Changes
@@ -56,61 +56,93 @@ declare function arkworksToSec1Compressed(ark: Uint8Array): Uint8Array;
56
56
  */
57
57
  declare function deriveChildPubkey(mpcKeyCompressed: Uint8Array, suiAddress: Uint8Array): Uint8Array;
58
58
  /**
59
- * Builds a P2TR script-path-only address: `tr(NUMS, pk(pubkey))`.
59
+ * Builds a 2-of-2 P2TR script-path-only address:
60
+ * `tr(NUMS, multi_a(2, guardian, derived_mpc))`.
60
61
  *
61
- * A BIP-341 taproot output has two spending paths: the *key path* (spend with
62
- * the internal key) and the *script path* (reveal and satisfy a script in the
63
- * Merkle tree). By using the NUMS (Nothing-Up-My-Sleeve) point as the internal
64
- * key a point with no known discrete logarithm — the key path is provably
65
- * unspendable, forcing every spend through the script path.
62
+ * The leaf script is a BIP-342 `multi_a` 2-of-2 both Schnorr signatures must
63
+ * be present to spend, ordered so that the witness stack is
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`.
66
67
  *
67
- * The script tree contains a single leaf: `<pubkey> OP_CHECKSIG`, meaning the
68
- * MPC committee must produce a valid Schnorr signature for `pubkey` to spend
69
- * the output. This is how the committee authorises withdrawals.
68
+ * The leaf script is exactly 70 bytes:
69
+ *
70
+ * ```text
71
+ * 0x20 ‖ guardian (32) // OP_PUSHBYTES_32 <pk1>
72
+ * 0xAC // OP_CHECKSIG
73
+ * 0x20 ‖ derived_mpc(32) // OP_PUSHBYTES_32 <pk2>
74
+ * 0xBA // OP_CHECKSIGADD
75
+ * 0x52 // OP_2 (push small int 2)
76
+ * 0x9C // OP_NUMEQUAL
77
+ * ```
78
+ *
79
+ * Note `0x52` (OP_2) — not `0x01 0x02` — because `rust-miniscript`'s `multi_a`
80
+ * codegen routes through `Builder::push_int(2)` which emits the small-num
81
+ * opcode. A literal pushdata would change the leaf hash and the address.
70
82
  *
71
83
  * The taproot output key is computed per BIP-341:
84
+ *
72
85
  * ```text
73
- * leafHash = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(script) ‖ script)
74
- * tweak = tagged_hash("TapTweak", NUMS ‖ leafHash)
75
- * outputKey = NUMS + tweak × G
86
+ * leafHash = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(script) ‖ script)
87
+ * tweak = tagged_hash("TapTweak", NUMS ‖ leafHash)
88
+ * outputKey = NUMS + tweak × G
76
89
  * ```
77
90
  *
78
- * The resulting output key is encoded as a SegWit v1 witness program in a
79
- * bech32m address.
91
+ * **Argument ordering is load-bearing.** Guardian first, derived-MPC second.
92
+ * Swapping them produces a different (but real-looking) `tb1p…` whose
93
+ * withdrawals the bridge cannot spend.
80
94
  *
81
- * @param pubkey - 32-byte x-only public key for the script leaf
95
+ * @param guardianBtcXOnly - 32-byte BIP-340 x-only guardian BTC public key
96
+ * (from the on-chain `guardian_btc_public_key` config)
97
+ * @param derivedMpcXOnly - 32-byte x-only public key for the MPC-derived child
98
+ * (output of {@link deriveChildPubkey})
82
99
  * @param network - Bitcoin network for the bech32m human-readable prefix
83
100
  * @returns bech32m-encoded P2TR address (e.g. `bc1p…`, `tb1p…`, `bcrt1p…`)
84
101
  */
85
- declare function taprootScriptPathAddress(pubkey: Uint8Array, network: BitcoinNetwork): string;
102
+ declare function twoOfTwoTaprootScriptPathAddress(guardianBtcXOnly: Uint8Array, derivedMpcXOnly: Uint8Array, network: BitcoinNetwork): string;
103
+ /**
104
+ * Named-args input bundle for {@link generateDepositAddress}. Using named args
105
+ * avoids the foot-gun of two 32-byte `Uint8Array`s in adjacent positions —
106
+ * swapping `guardianBtcXOnly` and `suiAddress` would silently produce a valid
107
+ * but wrong address.
108
+ */
109
+ interface DepositAddressInputs {
110
+ /**
111
+ * 33-byte SEC1-compressed secp256k1 MPC master key (post-arkworks
112
+ * conversion). See {@link arkworksToSec1Compressed}.
113
+ */
114
+ readonly mpcMasterCompressed: Uint8Array;
115
+ /**
116
+ * 32-byte BIP-340 x-only guardian BTC public key (from the on-chain
117
+ * `guardian_btc_public_key` config).
118
+ */
119
+ readonly guardianBtcXOnly: Uint8Array;
120
+ /** 32-byte Sui address used as the derivation path. */
121
+ readonly suiAddress: Uint8Array;
122
+ /** Bitcoin network (determines the bech32m address prefix). */
123
+ readonly network: BitcoinNetwork;
124
+ }
86
125
  /**
87
126
  * Generates a Bitcoin P2TR deposit address for a Sui address.
88
127
  *
89
- * This is the main entry point for the address derivation pipeline. Given the
90
- * MPC master key and a Sui address, it produces the unique Bitcoin deposit
91
- * address where a user should send BTC in order to receive bridged tokens on
92
- * Sui.
93
- *
94
- * Combines {@link deriveChildPubkey} (child key derivation) and
95
- * {@link taprootScriptPathAddress} (taproot address construction) into a
96
- * single call.
128
+ * Main entry point for the address derivation pipeline. Combines
129
+ * {@link deriveChildPubkey} and {@link twoOfTwoTaprootScriptPathAddress} into
130
+ * a single call. The produced address matches the Rust node's
131
+ * `bitcoin_utils::two_of_two_taproot_script_path_address` byte-for-byte.
97
132
  *
98
- * The current devnet address scheme uses a single-key script path:
133
+ * The address scheme is:
99
134
  * ```text
100
- * tr(NUMS, pk(derive(H, d)))
135
+ * tr(NUMS, multi_a(2, guardian, derive(mpc_master, sui_address)))
101
136
  * ```
102
- * where `H` is the MPC master key and `d` is the depositor's Sui address.
103
137
  *
104
- * For mainnet the descriptor will include the guardian key:
105
- * `tr(NUMS, multi_a(2, guardian, derive(H, d)))` — not yet implemented here.
106
- *
107
- * @param mpcKeyCompressed - 33-byte SEC1 compressed secp256k1 MPC public key
108
- * (the MPC master key, after arkworks-to-SEC1 conversion)
109
- * @param suiAddress - 32-byte Sui address
110
- * @param network - Bitcoin network (determines the bech32m address prefix)
111
138
  * @returns bech32m-encoded P2TR deposit address (e.g. `tb1p…` for signet)
112
139
  */
113
- declare function generateDepositAddress(mpcKeyCompressed: Uint8Array, suiAddress: Uint8Array, network: BitcoinNetwork): string;
140
+ declare function generateDepositAddress({
141
+ mpcMasterCompressed,
142
+ guardianBtcXOnly,
143
+ suiAddress,
144
+ network
145
+ }: DepositAddressInputs): string;
114
146
  /**
115
147
  * Decodes a bech32/bech32m SegWit Bitcoin address into a witness program.
116
148
  *
@@ -149,4 +181,4 @@ declare function bitcoinAddressToWitnessProgram(address: string, network: Bitcoi
149
181
  */
150
182
  declare function witnessProgramToAddress(program: Uint8Array, network: BitcoinNetwork): string;
151
183
  //#endregion
152
- export { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, taprootScriptPathAddress, witnessProgramToAddress };
184
+ export { DepositAddressInputs, arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, twoOfTwoTaprootScriptPathAddress, witnessProgramToAddress };
package/dist/bitcoin.mjs CHANGED
@@ -13,30 +13,39 @@ import { concatBytes } from "@noble/hashes/utils.js";
13
13
  *
14
14
  * Hashi bridges Bitcoin and Sui by assigning each Sui address a unique Bitcoin
15
15
  * deposit address. When BTC is sent to that address, the Hashi MPC committee
16
- * detects the deposit and mints equivalent tokens on Sui.
16
+ * and the Hashi guardian co-sign the withdrawal that spends it, and the bridge
17
+ * mints equivalent tokens on Sui.
17
18
  *
18
- * The deposit address is a Pay-to-Taproot (P2TR / BIP-341) script-path address
19
- * whose spending condition is a single `OP_CHECKSIG` against a child public key
20
- * derived from the MPC committee's master key and the depositor's Sui address.
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.
21
23
  *
22
24
  * The full derivation pipeline is:
23
25
  *
24
- * 1. **Fetch** the MPC master key from on-chain (`CommitteeSet.mpc_public_key`).
25
- * The on-chain bytes use the arkworks compressed format (little-endian x +
26
- * flag byte), so they must first be converted to SEC1 via
27
- * {@link arkworksToSec1Compressed} this is done automatically by the
28
- * client's `view.mpcPublicKey()` method.
26
+ * 1. **Fetch** the MPC master key from on-chain (`CommitteeSet.mpc_public_key`)
27
+ * and the guardian's BTC public key from the on-chain config
28
+ * (`guardian_btc_public_key`). The MPC bytes use the arkworks compressed
29
+ * format and must first be converted to SEC1 via
30
+ * {@link arkworksToSec1Compressed} — done automatically by the client's
31
+ * `view.mpcPublicKey()`. The guardian key is already in BIP-340 x-only form.
29
32
  *
30
- * 2. **Derive** a child key: `child = masterKey + HKDF-SHA3-256(x ‖ suiAddr) × G`
33
+ * 2. **Derive** a child MPC key: `child = masterKey + HKDF-SHA3-256(x ‖ suiAddr) × G`
31
34
  * (see {@link deriveChildPubkey}). This replicates the Rust function
32
35
  * `fastcrypto_tbls::threshold_schnorr::key_derivation::derive_verifying_key`.
33
36
  *
34
- * 3. **Build** the taproot address: `tr(NUMS, pk(child))` where NUMS is a
35
- * Nothing-Up-My-Sleeve point with no known private key, forcing all spends
36
- * through the script path (see {@link taprootScriptPathAddress}).
37
+ * 3. **Build** the taproot address: `tr(NUMS, multi_a(2, guardian, child))`
38
+ * where NUMS is a Nothing-Up-My-Sleeve point with no known private key,
39
+ * forcing all spends through the script path (see
40
+ * {@link twoOfTwoTaprootScriptPathAddress}).
37
41
  *
38
42
  * The end-to-end helper {@link generateDepositAddress} combines steps 2–3.
39
43
  *
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`.
48
+ *
40
49
  * @see https://mystenlabs.github.io/hashi/design/address-scheme.html
41
50
  */
42
51
  const Point = secp256k1.Point;
@@ -127,38 +136,61 @@ function deriveChildPubkey(mpcKeyCompressed, suiAddress) {
127
136
  return parentPoint.add(Point.BASE.multiply(tweakScalar)).toBytes(true).slice(1);
128
137
  }
129
138
  /**
130
- * Builds a P2TR script-path-only address: `tr(NUMS, pk(pubkey))`.
139
+ * Builds a 2-of-2 P2TR script-path-only address:
140
+ * `tr(NUMS, multi_a(2, guardian, derived_mpc))`.
141
+ *
142
+ * The leaf script is a BIP-342 `multi_a` 2-of-2 — both Schnorr signatures must
143
+ * be present to spend, ordered so that the witness stack is
144
+ * `[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`.
147
+ *
148
+ * The leaf script is exactly 70 bytes:
131
149
  *
132
- * A BIP-341 taproot output has two spending paths: the *key path* (spend with
133
- * the internal key) and the *script path* (reveal and satisfy a script in the
134
- * Merkle tree). By using the NUMS (Nothing-Up-My-Sleeve) point as the internal
135
- * key a point with no known discrete logarithm — the key path is provably
136
- * unspendable, forcing every spend through the script path.
150
+ * ```text
151
+ * 0x20 guardian (32) // OP_PUSHBYTES_32 <pk1>
152
+ * 0xAC // OP_CHECKSIG
153
+ * 0x20 derived_mpc(32) // OP_PUSHBYTES_32 <pk2>
154
+ * 0xBA // OP_CHECKSIGADD
155
+ * 0x52 // OP_2 (push small int 2)
156
+ * 0x9C // OP_NUMEQUAL
157
+ * ```
137
158
  *
138
- * The script tree contains a single leaf: `<pubkey> OP_CHECKSIG`, meaning the
139
- * MPC committee must produce a valid Schnorr signature for `pubkey` to spend
140
- * the output. This is how the committee authorises withdrawals.
159
+ * Note `0x52` (OP_2) not `0x01 0x02` because `rust-miniscript`'s `multi_a`
160
+ * codegen routes through `Builder::push_int(2)` which emits the small-num
161
+ * opcode. A literal pushdata would change the leaf hash and the address.
141
162
  *
142
163
  * The taproot output key is computed per BIP-341:
164
+ *
143
165
  * ```text
144
- * leafHash = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(script) ‖ script)
145
- * tweak = tagged_hash("TapTweak", NUMS ‖ leafHash)
146
- * outputKey = NUMS + tweak × G
166
+ * leafHash = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(script) ‖ script)
167
+ * tweak = tagged_hash("TapTweak", NUMS ‖ leafHash)
168
+ * outputKey = NUMS + tweak × G
147
169
  * ```
148
170
  *
149
- * The resulting output key is encoded as a SegWit v1 witness program in a
150
- * bech32m address.
171
+ * **Argument ordering is load-bearing.** Guardian first, derived-MPC second.
172
+ * Swapping them produces a different (but real-looking) `tb1p…` whose
173
+ * withdrawals the bridge cannot spend.
151
174
  *
152
- * @param pubkey - 32-byte x-only public key for the script leaf
175
+ * @param guardianBtcXOnly - 32-byte BIP-340 x-only guardian BTC public key
176
+ * (from the on-chain `guardian_btc_public_key` config)
177
+ * @param derivedMpcXOnly - 32-byte x-only public key for the MPC-derived child
178
+ * (output of {@link deriveChildPubkey})
153
179
  * @param network - Bitcoin network for the bech32m human-readable prefix
154
180
  * @returns bech32m-encoded P2TR address (e.g. `bc1p…`, `tb1p…`, `bcrt1p…`)
155
181
  */
156
- function taprootScriptPathAddress(pubkey, network) {
157
- if (pubkey.length !== 32) throw new Error(`Expected 32-byte x-only pubkey, got ${pubkey.length}`);
158
- const tapscript = new Uint8Array(34);
182
+ function twoOfTwoTaprootScriptPathAddress(guardianBtcXOnly, derivedMpcXOnly, network) {
183
+ if (guardianBtcXOnly.length !== 32) throw new Error(`Expected 32-byte x-only guardian pubkey, got ${guardianBtcXOnly.length}`);
184
+ if (derivedMpcXOnly.length !== 32) throw new Error(`Expected 32-byte x-only derived MPC pubkey, got ${derivedMpcXOnly.length}`);
185
+ const tapscript = new Uint8Array(70);
159
186
  tapscript[0] = 32;
160
- tapscript.set(pubkey, 1);
187
+ tapscript.set(guardianBtcXOnly, 1);
161
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;
162
194
  const tweakScalar = bytesToNumberBE(taggedHash("TapTweak", NUMS_KEY, taggedHash("TapLeaf", new Uint8Array([192, tapscript.length]), tapscript))) % CURVE_ORDER;
163
195
  const outputKey = NUMS_POINT.add(Point.BASE.multiply(tweakScalar)).toBytes(true).slice(1);
164
196
  const words = [1, ...bech32m.toWords(outputKey)];
@@ -167,32 +199,20 @@ function taprootScriptPathAddress(pubkey, network) {
167
199
  /**
168
200
  * Generates a Bitcoin P2TR deposit address for a Sui address.
169
201
  *
170
- * This is the main entry point for the address derivation pipeline. Given the
171
- * MPC master key and a Sui address, it produces the unique Bitcoin deposit
172
- * address where a user should send BTC in order to receive bridged tokens on
173
- * Sui.
174
- *
175
- * Combines {@link deriveChildPubkey} (child key derivation) and
176
- * {@link taprootScriptPathAddress} (taproot address construction) into a
177
- * single call.
202
+ * Main entry point for the address derivation pipeline. Combines
203
+ * {@link deriveChildPubkey} and {@link twoOfTwoTaprootScriptPathAddress} into
204
+ * a single call. The produced address matches the Rust node's
205
+ * `bitcoin_utils::two_of_two_taproot_script_path_address` byte-for-byte.
178
206
  *
179
- * The current devnet address scheme uses a single-key script path:
207
+ * The address scheme is:
180
208
  * ```text
181
- * tr(NUMS, pk(derive(H, d)))
209
+ * tr(NUMS, multi_a(2, guardian, derive(mpc_master, sui_address)))
182
210
  * ```
183
- * where `H` is the MPC master key and `d` is the depositor's Sui address.
184
211
  *
185
- * For mainnet the descriptor will include the guardian key:
186
- * `tr(NUMS, multi_a(2, guardian, derive(H, d)))` — not yet implemented here.
187
- *
188
- * @param mpcKeyCompressed - 33-byte SEC1 compressed secp256k1 MPC public key
189
- * (the MPC master key, after arkworks-to-SEC1 conversion)
190
- * @param suiAddress - 32-byte Sui address
191
- * @param network - Bitcoin network (determines the bech32m address prefix)
192
212
  * @returns bech32m-encoded P2TR deposit address (e.g. `tb1p…` for signet)
193
213
  */
194
- function generateDepositAddress(mpcKeyCompressed, suiAddress, network) {
195
- return taprootScriptPathAddress(deriveChildPubkey(mpcKeyCompressed, suiAddress), network);
214
+ function generateDepositAddress({ mpcMasterCompressed, guardianBtcXOnly, suiAddress, network }) {
215
+ return twoOfTwoTaprootScriptPathAddress(guardianBtcXOnly, deriveChildPubkey(mpcMasterCompressed, suiAddress), network);
196
216
  }
197
217
  /**
198
218
  * Decodes a bech32/bech32m SegWit Bitcoin address into a witness program.
@@ -292,4 +312,4 @@ function witnessProgramToAddress(program, network) {
292
312
  }
293
313
 
294
314
  //#endregion
295
- export { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, taprootScriptPathAddress, witnessProgramToAddress };
315
+ export { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, twoOfTwoTaprootScriptPathAddress, witnessProgramToAddress };
package/dist/client.d.mts CHANGED
@@ -50,8 +50,21 @@ declare class HashiClient {
50
50
  /**
51
51
  * Generates a unique Bitcoin P2TR deposit address for a Sui address.
52
52
  *
53
- * Fetches the MPC committee public key from on-chain, derives a child key
54
- * using the Sui address, and builds a taproot script-path address.
53
+ * Fetches the MPC committee public key and the guardian's BTC public key
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.
59
+ *
60
+ * The MPC key (`committee_set.mpc_public_key`) and the guardian key
61
+ * (`guardian_btc_public_key` config) come from a single fetch of the
62
+ * Hashi object, and only those two fields are parsed — an unrelated
63
+ * malformed config entry can't block deposit-address generation.
64
+ *
65
+ * Throws `HashiConfigError` if the deployment isn't guardian-provisioned
66
+ * yet (no `guardian_btc_public_key` on chain). The SDK refuses to fall
67
+ * back to a single-key address because the bridge validator rejects it.
55
68
  *
56
69
  * @example
57
70
  * ```ts
package/dist/client.mjs CHANGED
@@ -6,13 +6,13 @@ import { WithdrawalRequest, WithdrawalTransaction } from "./contracts/hashi/with
6
6
  import { BitcoinState, BitcoinStateKey } from "./contracts/hashi/bitcoin_state.mjs";
7
7
  import { deposit } from "./contracts/hashi/deposit.mjs";
8
8
  import { cancelWithdrawal, requestWithdrawal } from "./contracts/hashi/withdraw.mjs";
9
- import { DUST_RELAY_MIN_VALUE, NETWORK_CONFIG } from "./constants.mjs";
9
+ import { DUST_RELAY_MIN_VALUE, GUARDIAN_BTC_PUBLIC_KEY_LEN, GUARDIAN_PUBLIC_KEY_LEN, NETWORK_CONFIG } from "./constants.mjs";
10
10
  import { AmountBelowMinimumError, HashiConfigError, HashiFetchError, HashiPausedError, InvalidParamsError } from "./errors.mjs";
11
11
  import { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, generateDepositAddress } from "./bitcoin.mjs";
12
12
  import { getTxConfirmations, lookupAllVouts, lookupVout } from "./btc-rpc.mjs";
13
- import { assertHex32, entry, reverseTxidBytes } from "./util.mjs";
13
+ import { assertHex32, configBytes, entry, reverseTxidBytes } from "./util.mjs";
14
14
  import { TypeTagSerializer, bcs } from "@mysten/sui/bcs";
15
- import { deriveDynamicFieldID, fromHex } from "@mysten/sui/utils";
15
+ import { deriveDynamicFieldID, fromHex, normalizeSuiAddress } from "@mysten/sui/utils";
16
16
  import { base58 } from "@scure/base";
17
17
  import { Transaction, coinWithBalance } from "@mysten/sui/transactions";
18
18
 
@@ -176,26 +176,13 @@ var HashiClient = class {
176
176
  };
177
177
  this.view = {
178
178
  mpcPublicKey: async () => {
179
- const result = await Hashi.get({
179
+ return parseMpcPublicKey((await Hashi.get({
180
180
  client: this.#client,
181
181
  objectId: this.#hashiObjectId
182
- });
183
- const mpcKey = new Uint8Array(result.json.committee_set.mpc_public_key);
184
- if (mpcKey.length === 0) throw new Error("MPC public key not available on-chain. Has the committee completed DKG?");
185
- return arkworksToSec1Compressed(mpcKey);
182
+ })).json.committee_set.mpc_public_key);
186
183
  },
187
184
  all: async () => {
188
- let result;
189
- try {
190
- result = await Hashi.get({
191
- client: this.#client,
192
- objectId: this.#hashiObjectId
193
- });
194
- } catch (cause) {
195
- throw new HashiFetchError(`Failed to fetch Hashi shared object ${this.#hashiObjectId}.`, this.#hashiObjectId, { cause });
196
- }
197
- const contents = result.json?.config?.config?.contents;
198
- if (!Array.isArray(contents)) throw new HashiFetchError(`Hashi object ${this.#hashiObjectId} returned an unexpected shape: config.config.contents is not an array.`, this.#hashiObjectId);
185
+ const { contents } = await this.#fetchHashiObject();
199
186
  return this.#parseConfig(contents);
200
187
  },
201
188
  paused: async () => (await this.view.all()).paused,
@@ -455,8 +442,21 @@ var HashiClient = class {
455
442
  /**
456
443
  * Generates a unique Bitcoin P2TR deposit address for a Sui address.
457
444
  *
458
- * Fetches the MPC committee public key from on-chain, derives a child key
459
- * using the Sui address, and builds a taproot script-path address.
445
+ * Fetches the MPC committee public key and the guardian's BTC public key
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.
451
+ *
452
+ * The MPC key (`committee_set.mpc_public_key`) and the guardian key
453
+ * (`guardian_btc_public_key` config) come from a single fetch of the
454
+ * Hashi object, and only those two fields are parsed — an unrelated
455
+ * malformed config entry can't block deposit-address generation.
456
+ *
457
+ * Throws `HashiConfigError` if the deployment isn't guardian-provisioned
458
+ * yet (no `guardian_btc_public_key` on chain). The SDK refuses to fall
459
+ * back to a single-key address because the bridge validator rejects it.
460
460
  *
461
461
  * @example
462
462
  * ```ts
@@ -466,7 +466,13 @@ var HashiClient = class {
466
466
  * ```
467
467
  */
468
468
  async generateDepositAddress({ suiAddress, bitcoinNetwork = this.#bitcoinNetwork }) {
469
- return generateDepositAddress(await this.view.mpcPublicKey(), fromHex(suiAddress), bitcoinNetwork);
469
+ const { json, contents } = await this.#fetchHashiObject();
470
+ return generateDepositAddress({
471
+ mpcMasterCompressed: parseMpcPublicKey(json.committee_set.mpc_public_key),
472
+ guardianBtcXOnly: configBytes(contents, "guardian_btc_public_key", GUARDIAN_BTC_PUBLIC_KEY_LEN),
473
+ suiAddress: fromHex(normalizeSuiAddress(suiAddress)),
474
+ network: bitcoinNetwork
475
+ });
470
476
  }
471
477
  /**
472
478
  * Submit one or more Bitcoin deposits for committee confirmation, batched
@@ -633,6 +639,22 @@ var HashiClient = class {
633
639
  };
634
640
  const bool = (key) => entry(contents, key, "Bool").Bool;
635
641
  const addr = (key) => entry(contents, key, "Address").Address;
642
+ const optionalString = (key) => {
643
+ try {
644
+ return entry(contents, key, "String").String;
645
+ } catch (e) {
646
+ if (e instanceof HashiConfigError && e.actualVariant === void 0) return null;
647
+ throw e;
648
+ }
649
+ };
650
+ const optionalBytes = (key, expectedLen) => {
651
+ try {
652
+ return configBytes(contents, key, expectedLen);
653
+ } catch (e) {
654
+ if (e instanceof HashiConfigError && e.actualVariant === void 0) return null;
655
+ throw e;
656
+ }
657
+ };
636
658
  const rawDepositMin = u64("bitcoin_deposit_minimum");
637
659
  const rawWithdrawalMin = u64("bitcoin_withdrawal_minimum");
638
660
  const bitcoinDepositMinimum = rawDepositMin < DUST_RELAY_MIN_VALUE ? DUST_RELAY_MIN_VALUE : rawDepositMin;
@@ -646,7 +668,10 @@ var HashiClient = class {
646
668
  withdrawalCancellationCooldownMs: u64("withdrawal_cancellation_cooldown_ms"),
647
669
  bitcoinDepositTimeDelayMs: u64("bitcoin_deposit_time_delay_ms"),
648
670
  depositMinimum: bitcoinDepositMinimum,
649
- worstCaseNetworkFee: bitcoinWithdrawalMinimum - DUST_RELAY_MIN_VALUE
671
+ worstCaseNetworkFee: bitcoinWithdrawalMinimum - DUST_RELAY_MIN_VALUE,
672
+ guardianUrl: optionalString("guardian_url"),
673
+ guardianPublicKey: optionalBytes("guardian_public_key", GUARDIAN_PUBLIC_KEY_LEN),
674
+ guardianBtcPublicKey: optionalBytes("guardian_btc_public_key", GUARDIAN_BTC_PUBLIC_KEY_LEN)
650
675
  };
651
676
  }
652
677
  /**
@@ -696,6 +721,31 @@ var HashiClient = class {
696
721
  return 0n;
697
722
  }
698
723
  /**
724
+ * Fetches the Hashi shared object once and returns its decoded `json`
725
+ * alongside the validated governance-config `contents` array. Wraps
726
+ * transport failures and unexpected shapes in `HashiFetchError`. Shared by
727
+ * `view.all()` and `generateDepositAddress` so a single round-trip serves
728
+ * both the committee key (from `json`) and the config reads (from
729
+ * `contents`).
730
+ */
731
+ async #fetchHashiObject() {
732
+ let result;
733
+ try {
734
+ result = await Hashi.get({
735
+ client: this.#client,
736
+ objectId: this.#hashiObjectId
737
+ });
738
+ } catch (cause) {
739
+ throw new HashiFetchError(`Failed to fetch Hashi shared object ${this.#hashiObjectId}.`, this.#hashiObjectId, { cause });
740
+ }
741
+ const contents = result.json?.config?.config?.contents;
742
+ if (!Array.isArray(contents)) throw new HashiFetchError(`Hashi object ${this.#hashiObjectId} returned an unexpected shape: config.config.contents is not an array.`, this.#hashiObjectId);
743
+ return {
744
+ json: result.json,
745
+ contents
746
+ };
747
+ }
748
+ /**
699
749
  * Fetches the `BitcoinState` dynamic field from the Hashi shared object.
700
750
  * Returns the BCS-parsed struct whose nested Bag/Table IDs are used by
701
751
  * `findUsedUtxos` and `transactionHistory`.
@@ -851,6 +901,16 @@ var HashiClient = class {
851
901
  }
852
902
  }
853
903
  };
904
+ /**
905
+ * Convert the on-chain `committee_set.mpc_public_key` (arkworks-compressed)
906
+ * into a 33-byte SEC1 key. Throws `HashiConfigError` if DKG hasn't populated
907
+ * it yet (empty vector).
908
+ */
909
+ function parseMpcPublicKey(raw) {
910
+ const mpcKey = new Uint8Array(raw);
911
+ if (mpcKey.length === 0) throw HashiConfigError.missing("committee_set.mpc_public_key", "Bytes");
912
+ return arkworksToSec1Compressed(mpcKey);
913
+ }
854
914
  function parseDepositHistoryItem(content, timeDelayMs) {
855
915
  const parsed = DepositRequest.parse(content);
856
916
  const approvalTimestampMs = parsed.approval_timestamp_ms === null ? null : BigInt(parsed.approval_timestamp_ms);
@@ -50,6 +50,16 @@ const NUMS_KEY = new Uint8Array([
50
50
  * `hashi::btc_config`.
51
51
  */
52
52
  const DUST_RELAY_MIN_VALUE = 546n;
53
+ /**
54
+ * Length of the Guardian's Ed25519 attestation public key, in bytes. Matches
55
+ * `GUARDIAN_PUBLIC_KEY_LEN` in `hashi::config`.
56
+ */
57
+ const GUARDIAN_PUBLIC_KEY_LEN = 32;
58
+ /**
59
+ * Length of the Guardian's BIP-340 x-only BTC public key, in bytes. Matches
60
+ * `GUARDIAN_BTC_PUBLIC_KEY_LEN` in `hashi::config`.
61
+ */
62
+ const GUARDIAN_BTC_PUBLIC_KEY_LEN = 32;
53
63
  const NETWORK_CONFIG = { devnet: {
54
64
  hashiObjectId: "0x61f1acbf8dd79d284cb312853389747a55a158b1b12a68554afcade05dec5f40",
55
65
  packageId: "0xc377616c626d73556cae080706acf37e862d24d26c690cc7be5a957f53fadf17",
@@ -57,4 +67,4 @@ const NETWORK_CONFIG = { devnet: {
57
67
  } };
58
68
 
59
69
  //#endregion
60
- export { DUST_RELAY_MIN_VALUE, NETWORK_CONFIG, NETWORK_HRP, NUMS_KEY };
70
+ export { DUST_RELAY_MIN_VALUE, GUARDIAN_BTC_PUBLIC_KEY_LEN, GUARDIAN_PUBLIC_KEY_LEN, NETWORK_CONFIG, NETWORK_HRP, NUMS_KEY };
@@ -20,7 +20,8 @@ const UtxoRecord = new MoveStruct({
20
20
  fields: {
21
21
  utxo: Utxo,
22
22
  produced_by: bcs.option(bcs.Address),
23
- locked_by: bcs.option(bcs.Address)
23
+ locked_by: bcs.option(bcs.Address),
24
+ spent_epoch: bcs.option(bcs.u64())
24
25
  }
25
26
  });
26
27
  const UtxoSpentEvent = new MoveStruct({
@@ -27,7 +27,8 @@ const WithdrawalSignedMessage = new MoveStruct({
27
27
  fields: {
28
28
  withdrawal_id: bcs.Address,
29
29
  request_ids: bcs.vector(bcs.Address),
30
- signatures: bcs.vector(bcs.vector(bcs.u8()))
30
+ signatures: bcs.vector(bcs.vector(bcs.u8())),
31
+ guardian_signatures: bcs.vector(bcs.vector(bcs.u8()))
31
32
  }
32
33
  });
33
34
  const WithdrawalConfirmationMessage = new MoveStruct({
@@ -61,6 +61,7 @@ const WithdrawalTransaction = new MoveStruct({
61
61
  timestamp_ms: bcs.u64(),
62
62
  randomness: bcs.vector(bcs.u8()),
63
63
  signatures: bcs.option(bcs.vector(bcs.vector(bcs.u8()))),
64
+ guardian_signatures: bcs.option(bcs.vector(bcs.vector(bcs.u8()))),
64
65
  presig_start_index: bcs.u64(),
65
66
  epoch: bcs.u64()
66
67
  }
@@ -105,7 +106,16 @@ const WithdrawalSignedEvent = new MoveStruct({
105
106
  fields: {
106
107
  withdrawal_txn_id: bcs.Address,
107
108
  request_ids: bcs.vector(bcs.Address),
108
- signatures: bcs.vector(bcs.vector(bcs.u8()))
109
+ signatures: bcs.vector(bcs.vector(bcs.u8())),
110
+ guardian_signatures: bcs.vector(bcs.vector(bcs.u8()))
111
+ }
112
+ });
113
+ const WithdrawalPresigsReassignedEvent = new MoveStruct({
114
+ name: `${$moduleName}::WithdrawalPresigsReassignedEvent`,
115
+ fields: {
116
+ withdrawal_txn_id: bcs.Address,
117
+ epoch: bcs.u64(),
118
+ presig_start_index: bcs.u64()
109
119
  }
110
120
  });
111
121
  const WithdrawalConfirmedEvent = new MoveStruct({
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BitcoinNetwork, CancelWithdrawalParams, DepositFees, DepositHistoryItem, DepositInfo, DepositParams, DepositStatus, GovernanceConfig, HashiClientOptions, HbtcBalance, NetworkConfig, SuiNetwork, TransactionHistoryItem, UtxoId, UtxoLookupResult, UtxoOutput, UtxoUsageResult, WaitOptions, WithdrawalFees, WithdrawalHistoryItem, WithdrawalInfo, WithdrawalParams, WithdrawalStatus } from "./types.mjs";
2
2
  import { HashiClient, hashi } from "./client.mjs";
3
3
  import { AmountBelowMinimumError, AmountViolation, HashiConfigError, HashiFetchError, HashiPausedError, InvalidBitcoinAddressCode, InvalidBitcoinAddressError, InvalidParamsError } from "./errors.mjs";
4
- import { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, taprootScriptPathAddress, witnessProgramToAddress } from "./bitcoin.mjs";
5
- export { AmountBelowMinimumError, type AmountViolation, type BitcoinNetwork, type CancelWithdrawalParams, type DepositFees, type DepositHistoryItem, type DepositInfo, type DepositParams, type DepositStatus, type GovernanceConfig, HashiClient, type HashiClientOptions, HashiConfigError, HashiFetchError, HashiPausedError, type HbtcBalance, type InvalidBitcoinAddressCode, InvalidBitcoinAddressError, InvalidParamsError, type NetworkConfig, type SuiNetwork, type TransactionHistoryItem, type UtxoId, type UtxoLookupResult, type UtxoOutput, type UtxoUsageResult, type WaitOptions, type WithdrawalFees, type WithdrawalHistoryItem, type WithdrawalInfo, type WithdrawalParams, type WithdrawalStatus, arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, hashi, taprootScriptPathAddress, witnessProgramToAddress };
4
+ import { DepositAddressInputs, arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, twoOfTwoTaprootScriptPathAddress, witnessProgramToAddress } from "./bitcoin.mjs";
5
+ export { AmountBelowMinimumError, type AmountViolation, type BitcoinNetwork, type CancelWithdrawalParams, type DepositAddressInputs, type DepositFees, type DepositHistoryItem, type DepositInfo, type DepositParams, type DepositStatus, type GovernanceConfig, HashiClient, type HashiClientOptions, HashiConfigError, HashiFetchError, HashiPausedError, type HbtcBalance, type InvalidBitcoinAddressCode, InvalidBitcoinAddressError, InvalidParamsError, type NetworkConfig, type SuiNetwork, type TransactionHistoryItem, type UtxoId, type UtxoLookupResult, type UtxoOutput, type UtxoUsageResult, type WaitOptions, type WithdrawalFees, type WithdrawalHistoryItem, type WithdrawalInfo, type WithdrawalParams, type WithdrawalStatus, arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, hashi, twoOfTwoTaprootScriptPathAddress, witnessProgramToAddress };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AmountBelowMinimumError, HashiConfigError, HashiFetchError, HashiPausedError, InvalidBitcoinAddressError, InvalidParamsError } from "./errors.mjs";
2
- import { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, taprootScriptPathAddress, witnessProgramToAddress } from "./bitcoin.mjs";
2
+ import { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, twoOfTwoTaprootScriptPathAddress, witnessProgramToAddress } from "./bitcoin.mjs";
3
3
  import { HashiClient, hashi } from "./client.mjs";
4
4
 
5
- export { AmountBelowMinimumError, HashiClient, HashiConfigError, HashiFetchError, HashiPausedError, InvalidBitcoinAddressError, InvalidParamsError, arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, hashi, taprootScriptPathAddress, witnessProgramToAddress };
5
+ export { AmountBelowMinimumError, HashiClient, HashiConfigError, HashiFetchError, HashiPausedError, InvalidBitcoinAddressError, InvalidParamsError, arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, hashi, twoOfTwoTaprootScriptPathAddress, witnessProgramToAddress };
package/dist/types.d.mts CHANGED
@@ -28,6 +28,11 @@ interface HashiClientOptions<Name = "HashiClient"> {
28
28
  * state. `depositMinimum` is a Move-side alias of `bitcoinDepositMinimum`;
29
29
  * `worstCaseNetworkFee` is derived as `bitcoinWithdrawalMinimum - 546` (the
30
30
  * dust relay floor) and is always ≥ 1.
31
+ *
32
+ * The three `guardian*` fields are `null` on deployments where the guardian
33
+ * config hasn't been written yet (pre-feature chains or in-flight rollouts).
34
+ * `guardianBtcPublicKey` is required for {@link HashiClient.generateDepositAddress}
35
+ * to succeed.
31
36
  */
32
37
  interface GovernanceConfig {
33
38
  readonly paused: boolean;
@@ -39,6 +44,19 @@ interface GovernanceConfig {
39
44
  readonly bitcoinDepositTimeDelayMs: bigint;
40
45
  readonly depositMinimum: bigint;
41
46
  readonly worstCaseNetworkFee: bigint;
47
+ /** Guardian gRPC/HTTP endpoint from on-chain config. `null` if unset. */
48
+ readonly guardianUrl: string | null;
49
+ /**
50
+ * Guardian's Ed25519 attestation key (32 bytes), used to verify signed
51
+ * `GetGuardianInfo` responses. `null` if unset.
52
+ */
53
+ readonly guardianPublicKey: Uint8Array | null;
54
+ /**
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).
58
+ */
59
+ readonly guardianBtcPublicKey: Uint8Array | null;
42
60
  }
43
61
  /**
44
62
  * A single UTXO output within a Bitcoin transaction used to fund a deposit.
package/dist/util.mjs CHANGED
@@ -45,6 +45,16 @@ function entry(contents, key, expectedVariant) {
45
45
  if (e.value.$kind !== expectedVariant) throw HashiConfigError.wrongVariant(key, expectedVariant, e.value.$kind);
46
46
  return e.value;
47
47
  }
48
+ /**
49
+ * Read a `Bytes` config entry and assert its byte length. Throws
50
+ * `HashiConfigError` if the key is missing, holds a non-`Bytes` variant, or
51
+ * has the wrong length.
52
+ */
53
+ function configBytes(contents, key, expectedLen) {
54
+ const v = entry(contents, key, "Bytes");
55
+ if (v.Bytes.length !== expectedLen) throw HashiConfigError.malformedPayload(key, "Bytes", `expected ${expectedLen} bytes, got ${v.Bytes.length}`);
56
+ return new Uint8Array(v.Bytes);
57
+ }
48
58
 
49
59
  //#endregion
50
- export { assertHex32, entry, reverseTxidBytes };
60
+ export { assertHex32, configBytes, entry, reverseTxidBytes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mysten-incubation/hashi",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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>",
@@ -59,7 +59,7 @@
59
59
  "test": "vitest --project=unit",
60
60
  "test:integration": "vitest run --project=integration",
61
61
  "coverage": "vitest run --project=unit --coverage",
62
- "codegen": "sui-ts-codegen generate",
62
+ "codegen": "sui-ts-codegen generate && prettier --ignore-path /dev/null --tab-width 4 --print-width 100 --write 'src/contracts/**/*.ts'",
63
63
  "format": "prettier --write \"**/*.{ts,json,md}\""
64
64
  }
65
65
  }