@mysten-incubation/hashi 0.3.0 → 0.4.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,21 @@
1
1
  # @mysten-incubation/hashi
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 874ec08: feat: add a `client.hashi.guardian.*` namespace (`info`, `limiterStatus`, `canWithdraw`) that reads the guardian's rate-limiter headroom from its read-only `/info` endpoint, resolving the guardian URL from `guardianUrl`, a `guardianInfoProvider`, or the on-chain `guardian_url` config
8
+
9
+ ### Patch Changes
10
+
11
+ - 8f7606f: Track the redeployed devnet contracts: regenerate bindings against hashi's `testnet` tip (`0e67b619`) (`config_value::Value` gained `U128`/`U256`, shifting the BCS tags the SDK decodes the on-chain config with), follow the `DepositRequested`/`WithdrawalRequested` event renames and request-object field renames, and point `NETWORK_CONFIG.devnet` at the new package and Hashi object.
12
+
13
+ ## 0.3.1
14
+
15
+ ### Patch Changes
16
+
17
+ - 0af8dfa: Derive Bitcoin deposit addresses with Hashi's delayed MPC recovery taproot leaf.
18
+
3
19
  ## 0.3.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -45,7 +45,7 @@ await client.hashi.deposit({
45
45
  });
46
46
  ```
47
47
 
48
- End-user actions only: **deposit**, **request withdrawal**, **cancel withdrawal**.
48
+ End-user actions only: **deposit**, **request withdrawal**, **cancel withdrawal**. An optional `client.hashi.guardian.*` namespace reads the guardian's rate-limiter headroom (`limiterStatus`, `canWithdraw`) — see the root README.
49
49
 
50
50
  ## Documentation
51
51
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { RawTransactionArgument } from "./contracts/utils/index.mjs";
2
- import { BitcoinNetwork, CancelWithdrawalParams, DepositFees, DepositInfo, DepositParams, GovernanceConfig, HashiClientOptions, HbtcBalance, SuiNetwork, TransactionHistoryItem, UtxoId, UtxoLookupResult, UtxoUsageResult, WaitOptions, WithdrawalFees, WithdrawalInfo, WithdrawalParams } from "./types.mjs";
2
+ import { BitcoinNetwork, CancelWithdrawalParams, DepositFees, DepositInfo, DepositParams, GovernanceConfig, GuardianInfoProvider, GuardianLimiterSnapshot, GuardianWithdrawCheck, HashiClientOptions, HbtcBalance, RawGuardianInfo, SuiNetwork, TransactionHistoryItem, UtxoId, UtxoLookupResult, UtxoUsageResult, WaitOptions, WithdrawalFees, WithdrawalInfo, WithdrawalParams } from "./types.mjs";
3
3
  import * as _mysten_sui_transactions0 from "@mysten/sui/transactions";
4
4
  import { Transaction } from "@mysten/sui/transactions";
5
5
  import { ClientWithCoreApi, SuiClientTypes } from "@mysten/sui/client";
@@ -37,7 +37,9 @@ declare class HashiClient {
37
37
  packageId,
38
38
  bitcoinNetwork,
39
39
  btcRpcUrl,
40
- graphqlUrl
40
+ graphqlUrl,
41
+ guardianUrl,
42
+ guardianInfoProvider
41
43
  }: {
42
44
  client: ClientWithCoreApi;
43
45
  network: SuiNetwork;
@@ -46,16 +48,18 @@ declare class HashiClient {
46
48
  bitcoinNetwork?: BitcoinNetwork;
47
49
  btcRpcUrl?: string;
48
50
  graphqlUrl?: string;
51
+ guardianUrl?: string;
52
+ guardianInfoProvider?: GuardianInfoProvider;
49
53
  });
50
54
  /**
51
55
  * Generates a unique Bitcoin P2TR deposit address for a Sui address.
52
56
  *
53
57
  * Fetches the MPC committee public key and the guardian's BTC public key
54
58
  * 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
+ * builds the Hashi taproot script tree: an immediate 2-of-2 leaf
60
+ * (`multi_a(2, guardian, derived_mpc)`) plus a delayed MPC-only recovery
61
+ * leaf. The address matches the bridge's on-chain
62
+ * `validate_deposit_request_derivation_path` check byte-for-byte.
59
63
  *
60
64
  * The MPC key (`committee_set.mpc_public_key`) and the guardian key
61
65
  * (`guardian_btc_public_key` config) come from a single fetch of the
@@ -85,7 +89,7 @@ declare class HashiClient {
85
89
  * into a single Sui PTB. Signs with `signer` and submits, returning the
86
90
  * execution result (`$kind: "Transaction" | "FailedTransaction"`). The
87
91
  * result includes `effects` and `events` so callers can confirm
88
- * `DepositRequestedEvent` without an extra round-trip.
92
+ * `DepositRequested` without an extra round-trip.
89
93
  *
90
94
  * The method runs three preflight stages before signing:
91
95
  *
@@ -124,7 +128,7 @@ declare class HashiClient {
124
128
  * from the signer's balance and enqueues a request for the committee to
125
129
  * send `amountSats` to `bitcoinAddress` on the Bitcoin network. Signs
126
130
  * with `signer` and submits, returning the execution result including
127
- * `effects` and `events` (`WithdrawalRequestedEvent`).
131
+ * `effects` and `events` (`WithdrawalRequested`).
128
132
  *
129
133
  * The method runs three preflight stages before signing:
130
134
  *
@@ -331,7 +335,7 @@ declare class HashiClient {
331
335
  /**
332
336
  * Get the status and details of a deposit by its Sui transaction digest.
333
337
  *
334
- * Fetches the `DepositRequestedEvent` from the transaction, extracts the
338
+ * Fetches the `DepositRequested` event from the transaction, extracts the
335
339
  * request ID, then probes on-chain state to determine whether the deposit
336
340
  * is pending (still in `requests` ObjectBag), confirmed (object exists
337
341
  * but not in requests), or expired (object destroyed).
@@ -340,7 +344,7 @@ declare class HashiClient {
340
344
  /**
341
345
  * Get the status and details of a withdrawal by its Sui transaction digest.
342
346
  *
343
- * Fetches the `WithdrawalRequestedEvent` from the transaction, extracts the
347
+ * Fetches the `WithdrawalRequested` event from the transaction, extracts the
344
348
  * request ID, then reads the `WithdrawalRequest` object to determine the
345
349
  * current lifecycle state. If a `WithdrawalTransaction` is linked, its
346
350
  * Bitcoin txid is populated.
@@ -361,7 +365,7 @@ declare class HashiClient {
361
365
  * Fetch the unified transaction history (deposits + withdrawals) for
362
366
  * a Sui address. Confirmed requests come from the on-chain
363
367
  * `user_requests` index; in-flight deposits are discovered via
364
- * GraphQL `DepositRequestedEvent` queries (indexed by sender).
368
+ * GraphQL `DepositRequested` event queries (indexed by sender).
365
369
  */
366
370
  transactionHistory: (suiAddress: string) => Promise<TransactionHistoryItem[]>;
367
371
  };
@@ -396,6 +400,28 @@ declare class HashiClient {
396
400
  */
397
401
  confirmations: (txid: string) => Promise<number>;
398
402
  };
403
+ guardian: {
404
+ /**
405
+ * Fetch the guardian's curated `/info` (identity + limiter). `limiter`
406
+ * is `null` when the guardian is not yet provisioned/activated; this
407
+ * method never throws for that state, so it can detect an uninitialized
408
+ * guardian without a try/catch.
409
+ */
410
+ info: () => Promise<RawGuardianInfo>;
411
+ /**
412
+ * Fetch the limiter and compute derived fields: capacity projected to
413
+ * now, bucket fill percentage, and the refill-to-full ETA. Throws
414
+ * `HashiGuardianError` (`code: "not-initialized"`) if the guardian has
415
+ * no limiter yet.
416
+ */
417
+ limiterStatus: () => Promise<GuardianLimiterSnapshot>;
418
+ /**
419
+ * Check whether the guardian can sign a withdrawal of `amountSats` right
420
+ * now, with an estimated wait if not. Throws `HashiGuardianError`
421
+ * (`code: "not-initialized"`) if the guardian has no limiter yet.
422
+ */
423
+ canWithdraw: (amountSats: bigint) => Promise<GuardianWithdrawCheck>;
424
+ };
399
425
  }
400
426
  //#endregion
401
427
  export { HashiClient, hashi };
package/dist/client.mjs CHANGED
@@ -7,9 +7,10 @@ import { BitcoinState, BitcoinStateKey } from "./contracts/hashi/bitcoin_state.m
7
7
  import { deposit } from "./contracts/hashi/deposit.mjs";
8
8
  import { cancelWithdrawal, requestWithdrawal } from "./contracts/hashi/withdraw.mjs";
9
9
  import { DUST_RELAY_MIN_VALUE, GUARDIAN_BTC_PUBLIC_KEY_LEN, GUARDIAN_PUBLIC_KEY_LEN, NETWORK_CONFIG } from "./constants.mjs";
10
- import { AmountBelowMinimumError, HashiConfigError, HashiFetchError, HashiPausedError, InvalidParamsError } from "./errors.mjs";
10
+ import { AmountBelowMinimumError, HashiConfigError, HashiFetchError, HashiGuardianError, 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 { estimateWaitSecs, fetchGuardianInfo, projectCapacity } from "./guardian.mjs";
13
14
  import { assertHex32, configBytes, entry, reverseTxidBytes } from "./util.mjs";
14
15
  import { TypeTagSerializer, bcs } from "@mysten/sui/bcs";
15
16
  import { deriveDynamicFieldID, fromHex, normalizeSuiAddress } from "@mysten/sui/utils";
@@ -90,7 +91,10 @@ var HashiClient = class {
90
91
  #bitcoinNetwork;
91
92
  #btcRpcUrl;
92
93
  #graphqlUrl;
93
- constructor({ client, network, hashiObjectId, packageId, bitcoinNetwork, btcRpcUrl, graphqlUrl }) {
94
+ #guardianUrl;
95
+ #guardianInfoProvider;
96
+ #resolvedGuardianUrl;
97
+ constructor({ client, network, hashiObjectId, packageId, bitcoinNetwork, btcRpcUrl, graphqlUrl, guardianUrl, guardianInfoProvider }) {
94
98
  this.tx = {
95
99
  deposit: (params) => {
96
100
  const tx = new Transaction();
@@ -252,7 +256,7 @@ var HashiClient = class {
252
256
  });
253
257
  const txData = txResult.Transaction ?? txResult.FailedTransaction;
254
258
  if (!txData?.events) return null;
255
- const depositEvent = txData.events.find((e) => e.eventType.includes("::deposit::DepositRequestedEvent"));
259
+ const depositEvent = txData.events.find((e) => e.eventType.includes("::deposit::DepositRequested"));
256
260
  if (!depositEvent?.json) return null;
257
261
  const parsed = depositEvent.json;
258
262
  let status = "unknown";
@@ -263,7 +267,7 @@ var HashiClient = class {
263
267
  client: this.#client,
264
268
  objectId: parsed.request_id
265
269
  });
266
- if (reqObj.json.approval_timestamp_ms != null) approvalTimestampMs = BigInt(reqObj.json.approval_timestamp_ms);
270
+ if (reqObj.json.approved_timestamp_ms != null) approvalTimestampMs = BigInt(reqObj.json.approved_timestamp_ms);
267
271
  const [btcState, config$1] = await Promise.all([this.#fetchBitcoinState(), this.view.all().catch(() => null)]);
268
272
  if (approvalTimestampMs !== null && config$1) confirmableAtMs = approvalTimestampMs + config$1.bitcoinDepositTimeDelayMs;
269
273
  const requestsBagId = btcState.deposit_queue.requests.id;
@@ -298,7 +302,7 @@ var HashiClient = class {
298
302
  });
299
303
  const txData = txResult.Transaction ?? txResult.FailedTransaction;
300
304
  if (!txData?.events) return null;
301
- const withdrawEvent = txData.events.find((e) => e.eventType.includes("::withdrawal_queue::WithdrawalRequestedEvent"));
305
+ const withdrawEvent = txData.events.find((e) => e.eventType.includes("::withdrawal_queue::WithdrawalRequested"));
302
306
  if (!withdrawEvent?.json) return null;
303
307
  const parsed = withdrawEvent.json;
304
308
  let status = "Requested";
@@ -402,7 +406,7 @@ var HashiClient = class {
402
406
  }
403
407
  }
404
408
  try {
405
- const depositEventType = `${this.#packageId}::deposit::DepositRequestedEvent`;
409
+ const depositEventType = `${this.#packageId}::deposit::DepositRequested`;
406
410
  const pendingIds = (await this.#queryEventRequestIds(suiAddress, depositEventType)).filter((id) => !confirmedIds.has(id));
407
411
  if (pendingIds.length > 0) {
408
412
  const objects = await this.#batchGetObjects(pendingIds, { content: true });
@@ -428,6 +432,38 @@ var HashiClient = class {
428
432
  return getTxConfirmations(this.#btcRpcUrl, txid);
429
433
  }
430
434
  };
435
+ this.guardian = {
436
+ info: async () => {
437
+ return (await this.#resolveGuardianProvider())();
438
+ },
439
+ limiterStatus: async () => {
440
+ const { state, config: config$1 } = this.#requireLimiter(await this.guardian.info());
441
+ const nowSecs = BigInt(Math.floor(Date.now() / 1e3));
442
+ const availableNowSats = projectCapacity(config$1, state, nowSecs);
443
+ const max = config$1.maxBucketCapacitySats;
444
+ const bucketFillPercent = max > 0n ? Number(availableNowSats) / Number(max) * 100 : 0;
445
+ let fullAtSecs = null;
446
+ if (availableNowSats < max && config$1.refillRateSatsPerSec > 0n) fullAtSecs = nowSecs + (max - availableNowSats + config$1.refillRateSatsPerSec - 1n) / config$1.refillRateSatsPerSec;
447
+ return {
448
+ state,
449
+ config: config$1,
450
+ availableNowSats,
451
+ bucketFillPercent,
452
+ fullAtSecs
453
+ };
454
+ },
455
+ canWithdraw: async (amountSats) => {
456
+ const { state, config: config$1 } = this.#requireLimiter(await this.guardian.info());
457
+ const nowSecs = BigInt(Math.floor(Date.now() / 1e3));
458
+ const availableNowSats = projectCapacity(config$1, state, nowSecs);
459
+ const estimatedWaitSecs = estimateWaitSecs(config$1, state, amountSats, nowSecs);
460
+ return {
461
+ allowed: availableNowSats >= amountSats,
462
+ availableNowSats,
463
+ estimatedWaitSecs
464
+ };
465
+ }
466
+ };
431
467
  const config = NETWORK_CONFIG[network];
432
468
  const resolvedObjectId = hashiObjectId ?? config?.hashiObjectId;
433
469
  const resolvedPackageId = packageId ?? config?.packageId;
@@ -438,16 +474,18 @@ var HashiClient = class {
438
474
  this.#bitcoinNetwork = bitcoinNetwork ?? config?.bitcoinNetwork ?? "testnet";
439
475
  this.#btcRpcUrl = btcRpcUrl;
440
476
  this.#graphqlUrl = graphqlUrl ?? defaultGraphqlUrl(network);
477
+ this.#guardianUrl = guardianUrl;
478
+ this.#guardianInfoProvider = guardianInfoProvider;
441
479
  }
442
480
  /**
443
481
  * Generates a unique Bitcoin P2TR deposit address for a Sui address.
444
482
  *
445
483
  * Fetches the MPC committee public key and the guardian's BTC public key
446
484
  * 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.
485
+ * builds the Hashi taproot script tree: an immediate 2-of-2 leaf
486
+ * (`multi_a(2, guardian, derived_mpc)`) plus a delayed MPC-only recovery
487
+ * leaf. The address matches the bridge's on-chain
488
+ * `validate_deposit_request_derivation_path` check byte-for-byte.
451
489
  *
452
490
  * The MPC key (`committee_set.mpc_public_key`) and the guardian key
453
491
  * (`guardian_btc_public_key` config) come from a single fetch of the
@@ -479,7 +517,7 @@ var HashiClient = class {
479
517
  * into a single Sui PTB. Signs with `signer` and submits, returning the
480
518
  * execution result (`$kind: "Transaction" | "FailedTransaction"`). The
481
519
  * result includes `effects` and `events` so callers can confirm
482
- * `DepositRequestedEvent` without an extra round-trip.
520
+ * `DepositRequested` without an extra round-trip.
483
521
  *
484
522
  * The method runs three preflight stages before signing:
485
523
  *
@@ -530,7 +568,7 @@ var HashiClient = class {
530
568
  * from the signer's balance and enqueues a request for the committee to
531
569
  * send `amountSats` to `bitcoinAddress` on the Bitcoin network. Signs
532
570
  * with `signer` and submits, returning the execution result including
533
- * `effects` and `events` (`WithdrawalRequestedEvent`).
571
+ * `effects` and `events` (`WithdrawalRequested`).
534
572
  *
535
573
  * The method runs three preflight stages before signing:
536
574
  *
@@ -705,6 +743,43 @@ var HashiClient = class {
705
743
  #requireBtcRpc() {
706
744
  if (!this.#btcRpcUrl) throw new Error("btcRpcUrl is required for Bitcoin RPC operations. Pass it in HashiClientOptions.");
707
745
  }
746
+ #requireLimiter(info) {
747
+ if (info.limiter === null) throw new HashiGuardianError({
748
+ message: "Guardian rate limiter is not initialized (limiter is null).",
749
+ code: "not-initialized"
750
+ });
751
+ return info.limiter;
752
+ }
753
+ async #resolveGuardianProvider() {
754
+ if (this.#guardianInfoProvider) return this.#guardianInfoProvider;
755
+ const url = this.#guardianUrl || await this.#resolveOnChainGuardianUrl();
756
+ if (!url) throw new HashiGuardianError({
757
+ message: "Guardian URL is not configured. Pass `guardianUrl` or `guardianInfoProvider` to hashi({...}), or set `guardian_url` in the on-chain config.",
758
+ code: "not-configured"
759
+ });
760
+ return () => fetchGuardianInfo(url);
761
+ }
762
+ /**
763
+ * Resolve the guardian origin from the on-chain `guardian_url`, caching only
764
+ * a found URL. `guardian_url` is absent until launch (`finish_publish`
765
+ * publishes it), so a missing value is left uncached and each call re-reads
766
+ * the chain — a client created before launch starts working once the URL is
767
+ * published, rather than caching the absence forever (mirrors the node's
768
+ * lazy `guardian_client()` resolution). Returns `undefined` while unresolved.
769
+ */
770
+ async #resolveOnChainGuardianUrl() {
771
+ if (this.#resolvedGuardianUrl) return this.#resolvedGuardianUrl;
772
+ let onChain;
773
+ try {
774
+ onChain = (await this.view.all()).guardianUrl;
775
+ } catch (cause) {
776
+ throw new HashiGuardianError({
777
+ message: "Could not read the on-chain guardian_url config. Pass `guardianUrl` or `guardianInfoProvider` to bypass the on-chain read.",
778
+ code: "not-configured"
779
+ }, { cause });
780
+ }
781
+ return this.#resolvedGuardianUrl = onChain || void 0;
782
+ }
708
783
  async #estimateGas(tx) {
709
784
  try {
710
785
  const result = await this.#client.core.simulateTransaction({
@@ -913,12 +988,12 @@ function parseMpcPublicKey(raw) {
913
988
  }
914
989
  function parseDepositHistoryItem(content, timeDelayMs) {
915
990
  const parsed = DepositRequest.parse(content);
916
- const approvalTimestampMs = parsed.approval_timestamp_ms === null ? null : BigInt(parsed.approval_timestamp_ms);
991
+ const approvalTimestampMs = parsed.approved_timestamp_ms === null ? null : BigInt(parsed.approved_timestamp_ms);
917
992
  return {
918
993
  kind: "deposit",
919
994
  requestId: parsed.id,
920
995
  sender: parsed.sender,
921
- timestampMs: BigInt(parsed.timestamp_ms),
996
+ timestampMs: BigInt(parsed.created_timestamp_ms),
922
997
  suiTxDigest: base58.encode(new Uint8Array(parsed.sui_tx_digest)),
923
998
  amountSats: BigInt(parsed.utxo.amount),
924
999
  btcTxid: reverseTxidBytes(parsed.utxo.id.txid),
@@ -936,7 +1011,7 @@ function parseWithdrawalHistoryItem(content) {
936
1011
  sender: parsed.sender,
937
1012
  btcAmountSats: BigInt(parsed.btc_amount),
938
1013
  bitcoinAddress: new Uint8Array(parsed.bitcoin_address),
939
- timestampMs: BigInt(parsed.timestamp_ms),
1014
+ timestampMs: BigInt(parsed.created_timestamp_ms),
940
1015
  suiTxDigest: base58.encode(new Uint8Array(parsed.sui_tx_digest)),
941
1016
  status: parsed.status.$kind,
942
1017
  withdrawalTxnId: parsed.withdrawal_txn_id ?? null,
@@ -61,8 +61,8 @@ const GUARDIAN_PUBLIC_KEY_LEN = 32;
61
61
  */
62
62
  const GUARDIAN_BTC_PUBLIC_KEY_LEN = 32;
63
63
  const NETWORK_CONFIG = { devnet: {
64
- hashiObjectId: "0x61f1acbf8dd79d284cb312853389747a55a158b1b12a68554afcade05dec5f40",
65
- packageId: "0xc377616c626d73556cae080706acf37e862d24d26c690cc7be5a957f53fadf17",
64
+ hashiObjectId: "0x84081242ebb05eac5e09ab2a930a60b1357d3d8bc6f927380979f72de991ccca",
65
+ packageId: "0xa877d4d97b6a8bae1da982a84980c502c5ad2ead4b24e6c8e50c57cd6ddc3771",
66
66
  bitcoinNetwork: "signet"
67
67
  } };
68
68
 
@@ -9,6 +9,13 @@ import { bcs } from "@mysten/sui/bcs";
9
9
  /**************************************************************
10
10
  * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED *
11
11
  **************************************************************/
12
+ /**
13
+ * Per-chain Bitcoin state, attached to the `Hashi` shared object as a dynamic
14
+ * field keyed by `BitcoinStateKey`. Bundles the deposit queue, withdrawal queue,
15
+ * and UTXO pool behind package-only accessors, and maintains a per-user index of
16
+ * request IDs so clients can discover all deposits and withdrawals belonging to an
17
+ * address.
18
+ */
12
19
  const $moduleName = "@local-pkg/hashi::bitcoin_state";
13
20
  const BitcoinStateKey = new MoveStruct({
14
21
  name: `${$moduleName}::BitcoinStateKey`,
@@ -17,6 +24,7 @@ const BitcoinStateKey = new MoveStruct({
17
24
  const BitcoinState = new MoveStruct({
18
25
  name: `${$moduleName}::BitcoinState`,
19
26
  fields: {
27
+ id: bcs.Address,
20
28
  deposit_queue: DepositRequestQueue,
21
29
  withdrawal_queue: WithdrawalRequestQueue,
22
30
  utxo_pool: UtxoPool,
@@ -1,11 +1,20 @@
1
1
  import { MoveStruct } from "../utils/index.mjs";
2
2
  import { Element } from "./deps/sui/group_ops.mjs";
3
+ import { Config } from "./config.mjs";
3
4
  import { bcs } from "@mysten/sui/bcs";
4
5
 
5
6
  //#region src/contracts/hashi/committee.ts
6
7
  /**************************************************************
7
8
  * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED *
8
9
  **************************************************************/
10
+ /**
11
+ * BLS signing committees and certificate verification. A `Committee` pins an
12
+ * epoch's members (validator addresses, BLS public keys, encryption keys, voting
13
+ * weights) together with the MPC parameters snapshotted at reconfig time.
14
+ * `verify_certificate` checks an aggregate BLS12-381 min-pk signature against a
15
+ * signers bitmap, enforces the stake threshold, and wraps the payload in a
16
+ * `CertifiedMessage` as proof of committee approval.
17
+ */
9
18
  const $moduleName = "@local-pkg/hashi::committee";
10
19
  const CommitteeMember = new MoveStruct({
11
20
  name: `${$moduleName}::CommitteeMember`,
@@ -22,9 +31,7 @@ const Committee = new MoveStruct({
22
31
  epoch: bcs.u64(),
23
32
  members: bcs.vector(CommitteeMember),
24
33
  total_weight: bcs.u64(),
25
- mpc_threshold_in_basis_points: bcs.u64(),
26
- mpc_weight_reduction_allowed_delta: bcs.u64(),
27
- mpc_max_faulty_in_basis_points: bcs.u64()
34
+ config: Config
28
35
  }
29
36
  });
30
37
  const CommitteeSignature = new MoveStruct({