@mysten-incubation/hashi 0.0.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/dist/bitcoin.d.mts +140 -0
  3. package/dist/bitcoin.mjs +272 -0
  4. package/dist/client.d.mts +318 -0
  5. package/dist/client.mjs +522 -0
  6. package/dist/constants.mjs +60 -0
  7. package/dist/contracts/hashi/bitcoin_state.mjs +28 -0
  8. package/dist/contracts/hashi/committee.mjs +40 -0
  9. package/dist/contracts/hashi/committee_set.mjs +34 -0
  10. package/dist/contracts/hashi/config.mjs +28 -0
  11. package/dist/contracts/hashi/config_value.mjs +21 -0
  12. package/dist/contracts/hashi/deposit.mjs +67 -0
  13. package/dist/contracts/hashi/deposit_queue.mjs +33 -0
  14. package/dist/contracts/hashi/deps/sui/bag.mjs +42 -0
  15. package/dist/contracts/hashi/deps/sui/balance.mjs +20 -0
  16. package/dist/contracts/hashi/deps/sui/group_ops.mjs +16 -0
  17. package/dist/contracts/hashi/deps/sui/object_bag.mjs +25 -0
  18. package/dist/contracts/hashi/deps/sui/package.mjs +26 -0
  19. package/dist/contracts/hashi/deps/sui/table.mjs +37 -0
  20. package/dist/contracts/hashi/deps/sui/vec_map.mjs +36 -0
  21. package/dist/contracts/hashi/deps/sui/vec_set.mjs +25 -0
  22. package/dist/contracts/hashi/hashi.mjs +29 -0
  23. package/dist/contracts/hashi/proposals.mjs +18 -0
  24. package/dist/contracts/hashi/treasury.mjs +28 -0
  25. package/dist/contracts/hashi/utxo.mjs +56 -0
  26. package/dist/contracts/hashi/utxo_pool.mjs +35 -0
  27. package/dist/contracts/hashi/withdraw.mjs +91 -0
  28. package/dist/contracts/hashi/withdrawal_queue.mjs +131 -0
  29. package/dist/contracts/utils/index.d.mts +8 -0
  30. package/dist/contracts/utils/index.mjs +118 -0
  31. package/dist/errors.d.mts +118 -0
  32. package/dist/errors.mjs +118 -0
  33. package/dist/index.d.mts +5 -0
  34. package/dist/index.mjs +5 -0
  35. package/dist/types.d.mts +137 -0
  36. package/dist/util.mjs +50 -0
  37. package/package.json +64 -0
@@ -0,0 +1,522 @@
1
+ import { Bag } from "./contracts/hashi/deps/sui/bag.mjs";
2
+ import { Hashi } from "./contracts/hashi/hashi.mjs";
3
+ import { UtxoId, utxo, utxoId } from "./contracts/hashi/utxo.mjs";
4
+ import { DepositRequest } from "./contracts/hashi/deposit_queue.mjs";
5
+ import { WithdrawalRequest, WithdrawalTransaction } from "./contracts/hashi/withdrawal_queue.mjs";
6
+ import { BitcoinState, BitcoinStateKey } from "./contracts/hashi/bitcoin_state.mjs";
7
+ import { deposit } from "./contracts/hashi/deposit.mjs";
8
+ import { cancelWithdrawal, requestWithdrawal } from "./contracts/hashi/withdraw.mjs";
9
+ import { DUST_RELAY_MIN_VALUE, NETWORK_CONFIG } from "./constants.mjs";
10
+ import { AmountBelowMinimumError, HashiConfigError, HashiFetchError, HashiPausedError, InvalidParamsError } from "./errors.mjs";
11
+ import { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, generateDepositAddress } from "./bitcoin.mjs";
12
+ import { assertHex32, entry, reverseTxidBytes } from "./util.mjs";
13
+ import { TypeTagSerializer, bcs } from "@mysten/sui/bcs";
14
+ import { deriveDynamicFieldID, fromHex, toHex } from "@mysten/sui/utils";
15
+ import { Transaction, coinWithBalance } from "@mysten/sui/transactions";
16
+
17
+ //#region src/client.ts
18
+ /** Max value of an unsigned 32-bit integer; vout is a u32 on the Bitcoin side. */
19
+ const U32_MAX = 4294967295;
20
+ /** Max objects per `getObjects` call — Sui transports typically cap at 50. */
21
+ const GET_OBJECTS_BATCH = 50;
22
+ function hashi({ name = "hashi", ...options }) {
23
+ return {
24
+ name,
25
+ register: (client) => {
26
+ return new HashiClient({
27
+ client,
28
+ ...options
29
+ });
30
+ }
31
+ };
32
+ }
33
+ /**
34
+ * User-facing SDK client for the Hashi protocol. Constructed via the
35
+ * `hashi({...})` factory and attached to any Sui client via `$extend`:
36
+ *
37
+ * ```ts
38
+ * const client = new SuiGrpcClient({ ... }).$extend(hashi({ network: "devnet" }));
39
+ * const result = await client.hashi.deposit({ signer, ... });
40
+ * ```
41
+ *
42
+ * **Direct methods (`deposit`, `withdraw`) sign and execute transactions** on
43
+ * behalf of the caller — pass a `Signer` and receive the execution result.
44
+ * For composable flows (bundling into a larger PTB, sponsored transactions,
45
+ * dry-run/simulation), use the `tx.*` builders instead; they return unsigned
46
+ * `Transaction` objects and leave signing to the caller.
47
+ */
48
+ var HashiClient = class {
49
+ #client;
50
+ #hashiObjectId;
51
+ #packageId;
52
+ #bitcoinNetwork;
53
+ constructor({ client, network, hashiObjectId, packageId, bitcoinNetwork }) {
54
+ this.tx = {
55
+ deposit: (params) => {
56
+ const tx = new Transaction();
57
+ const internalTxid = reverseTxidBytes(params.txid);
58
+ for (const { vout, amountSats } of params.utxos) {
59
+ const utxoId$1 = tx.add(utxoId({
60
+ package: this.#packageId,
61
+ arguments: {
62
+ txid: internalTxid,
63
+ vout
64
+ }
65
+ }));
66
+ const utxo$1 = tx.add(utxo({
67
+ package: this.#packageId,
68
+ arguments: {
69
+ utxoId: utxoId$1,
70
+ amount: amountSats,
71
+ derivationPath: params.recipient
72
+ }
73
+ }));
74
+ tx.add(this.call.deposit({ utxo: utxo$1 }));
75
+ }
76
+ return tx;
77
+ },
78
+ requestWithdrawal: (options) => {
79
+ const tx = new Transaction();
80
+ const btcType = `${this.#packageId}::btc::BTC`;
81
+ const coin = tx.add(coinWithBalance({
82
+ type: btcType,
83
+ balance: options.amount,
84
+ useGasCoin: false
85
+ }));
86
+ const [balance] = tx.moveCall({
87
+ package: "0x2",
88
+ module: "coin",
89
+ function: "into_balance",
90
+ typeArguments: [btcType],
91
+ arguments: [coin]
92
+ });
93
+ tx.add(this.call.requestWithdrawal({
94
+ btc: balance,
95
+ bitcoinAddress: Array.from(options.bitcoinAddress)
96
+ }));
97
+ return tx;
98
+ },
99
+ cancelWithdrawal: (options) => {
100
+ const tx = new Transaction();
101
+ const balance = tx.add(this.call.cancelWithdrawal({ requestId: options.requestId }));
102
+ const [coin] = tx.moveCall({
103
+ package: "0x2",
104
+ module: "coin",
105
+ function: "from_balance",
106
+ typeArguments: [`${this.#packageId}::btc::BTC`],
107
+ arguments: [balance]
108
+ });
109
+ tx.transferObjects([coin], options.recipient);
110
+ return tx;
111
+ }
112
+ };
113
+ this.call = {
114
+ deposit: (options) => deposit({
115
+ package: this.#packageId,
116
+ arguments: {
117
+ hashi: this.#hashiObjectId,
118
+ utxo: options.utxo
119
+ }
120
+ }),
121
+ requestWithdrawal: (options) => requestWithdrawal({
122
+ package: this.#packageId,
123
+ arguments: {
124
+ hashi: this.#hashiObjectId,
125
+ btc: options.btc,
126
+ bitcoinAddress: options.bitcoinAddress
127
+ }
128
+ }),
129
+ cancelWithdrawal: (options) => cancelWithdrawal({
130
+ package: this.#packageId,
131
+ arguments: {
132
+ hashi: this.#hashiObjectId,
133
+ requestId: options.requestId
134
+ }
135
+ })
136
+ };
137
+ this.view = {
138
+ mpcPublicKey: async () => {
139
+ const result = await Hashi.get({
140
+ client: this.#client,
141
+ objectId: this.#hashiObjectId
142
+ });
143
+ const mpcKey = new Uint8Array(result.json.committee_set.mpc_public_key);
144
+ if (mpcKey.length === 0) throw new Error("MPC public key not available on-chain. Has the committee completed DKG?");
145
+ return arkworksToSec1Compressed(mpcKey);
146
+ },
147
+ all: async () => {
148
+ let result;
149
+ try {
150
+ result = await Hashi.get({
151
+ client: this.#client,
152
+ objectId: this.#hashiObjectId
153
+ });
154
+ } catch (cause) {
155
+ throw new HashiFetchError(`Failed to fetch Hashi shared object ${this.#hashiObjectId}.`, this.#hashiObjectId, { cause });
156
+ }
157
+ const contents = result.json?.config?.config?.contents;
158
+ if (!Array.isArray(contents)) throw new HashiFetchError(`Hashi object ${this.#hashiObjectId} returned an unexpected shape: config.config.contents is not an array.`, this.#hashiObjectId);
159
+ return this.#parseConfig(contents);
160
+ },
161
+ paused: async () => (await this.view.all()).paused,
162
+ bitcoinDepositMinimum: async () => (await this.view.all()).bitcoinDepositMinimum,
163
+ bitcoinWithdrawalMinimum: async () => (await this.view.all()).bitcoinWithdrawalMinimum,
164
+ bitcoinConfirmationThreshold: async () => (await this.view.all()).bitcoinConfirmationThreshold,
165
+ withdrawalCancellationCooldownMs: async () => (await this.view.all()).withdrawalCancellationCooldownMs,
166
+ bitcoinChainId: async () => (await this.view.all()).bitcoinChainId,
167
+ depositMinimum: async () => (await this.view.all()).depositMinimum,
168
+ worstCaseNetworkFee: async () => (await this.view.all()).worstCaseNetworkFee,
169
+ findUsedUtxos: async (utxos) => {
170
+ if (utxos.length === 0) return [];
171
+ const btcState = await this.#fetchBitcoinState();
172
+ const activePoolId = btcState.utxo_pool.utxo_records.id;
173
+ const spentPoolId = btcState.utxo_pool.spent_utxos.id;
174
+ const typeTag = TypeTagSerializer.parseFromStr(`${this.#packageId}::utxo::UtxoId`);
175
+ const fieldIds = [];
176
+ for (const u of utxos) {
177
+ const keyBcs = UtxoId.serialize({
178
+ txid: reverseTxidBytes(u.txid),
179
+ vout: u.vout
180
+ }).toBytes();
181
+ fieldIds.push(deriveDynamicFieldID(activePoolId, typeTag, keyBcs), deriveDynamicFieldID(spentPoolId, typeTag, keyBcs));
182
+ }
183
+ const objects = await this.#batchGetObjects(fieldIds);
184
+ return utxos.map((u, i) => {
185
+ const inActivePool = !(objects[i * 2] instanceof Error);
186
+ const inSpentPool = !(objects[i * 2 + 1] instanceof Error);
187
+ return {
188
+ utxoId: u,
189
+ inActivePool,
190
+ inSpentPool,
191
+ isUsed: inActivePool || inSpentPool
192
+ };
193
+ });
194
+ },
195
+ transactionHistory: async (suiAddress) => {
196
+ const tableId = (await this.#fetchBitcoinState()).user_requests.id;
197
+ let userBagId;
198
+ try {
199
+ const { dynamicField } = await this.#client.core.getDynamicField({
200
+ parentId: tableId,
201
+ name: {
202
+ type: "address",
203
+ bcs: bcs.Address.serialize(suiAddress).toBytes()
204
+ }
205
+ });
206
+ userBagId = Bag.parse(new Uint8Array(dynamicField.value.bcs)).id;
207
+ } catch {
208
+ return [];
209
+ }
210
+ const requestIds = [];
211
+ let cursor = null;
212
+ do {
213
+ const page = await this.#client.core.listDynamicFields({
214
+ parentId: userBagId,
215
+ cursor: cursor ?? void 0
216
+ });
217
+ for (const df of page.dynamicFields) requestIds.push(bcs.Address.parse(new Uint8Array(df.name.bcs)));
218
+ cursor = page.hasNextPage ? page.cursor : null;
219
+ } while (cursor);
220
+ if (requestIds.length === 0) return [];
221
+ const objects = await this.#batchGetObjects(requestIds, { content: true });
222
+ const items = [];
223
+ const withdrawalTxnLookups = [];
224
+ for (const obj of objects) {
225
+ if (obj instanceof Error) continue;
226
+ if (obj.type.includes("::deposit_queue::DepositRequest")) {
227
+ const parsed = DepositRequest.parse(obj.content);
228
+ items.push({
229
+ kind: "deposit",
230
+ requestId: parsed.id,
231
+ sender: parsed.sender,
232
+ timestampMs: BigInt(parsed.timestamp_ms),
233
+ suiTxDigest: `0x${toHex(new Uint8Array(parsed.sui_tx_digest))}`,
234
+ amountSats: BigInt(parsed.utxo.amount),
235
+ btcTxid: reverseTxidBytes(parsed.utxo.id.txid),
236
+ btcVout: parsed.utxo.id.vout,
237
+ approved: parsed.approval_cert !== null,
238
+ approvalTimestampMs: parsed.approval_timestamp_ms !== null ? BigInt(parsed.approval_timestamp_ms) : null
239
+ });
240
+ } else if (obj.type.includes("::withdrawal_queue::WithdrawalRequest")) {
241
+ const parsed = WithdrawalRequest.parse(obj.content);
242
+ const txnId = parsed.withdrawal_txn_id ?? null;
243
+ items.push({
244
+ kind: "withdrawal",
245
+ requestId: parsed.id,
246
+ sender: parsed.sender,
247
+ btcAmountSats: BigInt(parsed.btc_amount),
248
+ bitcoinAddress: new Uint8Array(parsed.bitcoin_address),
249
+ timestampMs: BigInt(parsed.timestamp_ms),
250
+ suiTxDigest: `0x${toHex(new Uint8Array(parsed.sui_tx_digest))}`,
251
+ status: parsed.status.$kind,
252
+ withdrawalTxnId: txnId,
253
+ btcTxid: null
254
+ });
255
+ if (txnId) withdrawalTxnLookups.push({
256
+ itemIndex: items.length - 1,
257
+ txnId
258
+ });
259
+ }
260
+ }
261
+ if (withdrawalTxnLookups.length > 0) {
262
+ const txnIds = withdrawalTxnLookups.map((l) => l.txnId);
263
+ const txnObjects = await this.#batchGetObjects(txnIds, { content: true });
264
+ for (let i = 0; i < withdrawalTxnLookups.length; i++) {
265
+ const txnObj = txnObjects[i];
266
+ if (txnObj instanceof Error) continue;
267
+ const parsed = WithdrawalTransaction.parse(txnObj.content);
268
+ const item = items[withdrawalTxnLookups[i].itemIndex];
269
+ item.btcTxid = reverseTxidBytes(parsed.txid);
270
+ }
271
+ }
272
+ return items;
273
+ }
274
+ };
275
+ const config = NETWORK_CONFIG[network];
276
+ const resolvedObjectId = hashiObjectId ?? config?.hashiObjectId;
277
+ const resolvedPackageId = packageId ?? config?.packageId;
278
+ if (!resolvedObjectId || !resolvedPackageId) throw new Error(`Hashi is not yet supported on Sui ${network}. Provide a custom hashiObjectId and packageId.`);
279
+ this.#client = client;
280
+ this.#hashiObjectId = resolvedObjectId;
281
+ this.#packageId = resolvedPackageId;
282
+ this.#bitcoinNetwork = bitcoinNetwork ?? config?.bitcoinNetwork ?? "testnet";
283
+ }
284
+ /**
285
+ * Generates a unique Bitcoin P2TR deposit address for a Sui address.
286
+ *
287
+ * Fetches the MPC committee public key from on-chain, derives a child key
288
+ * using the Sui address, and builds a taproot script-path address.
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * const btcAddress = await client.hashi.generateDepositAddress({
293
+ * suiAddress: signer.toSuiAddress(),
294
+ * });
295
+ * ```
296
+ */
297
+ async generateDepositAddress({ suiAddress, bitcoinNetwork = this.#bitcoinNetwork }) {
298
+ return generateDepositAddress(await this.view.mpcPublicKey(), fromHex(suiAddress), bitcoinNetwork);
299
+ }
300
+ /**
301
+ * Submit one or more Bitcoin deposits for committee confirmation, batched
302
+ * into a single Sui PTB. Signs with `signer` and submits, returning the
303
+ * execution result (`$kind: "Transaction" | "FailedTransaction"`). The
304
+ * result includes `effects` and `events` so callers can confirm
305
+ * `DepositRequestedEvent` without an extra round-trip.
306
+ *
307
+ * The method runs three preflight stages before signing:
308
+ *
309
+ * 1. **Structural validation** — `txid` and `recipient` must be
310
+ * 0x-prefixed 32-byte hex; `utxos` must be non-empty; every `vout`
311
+ * must be a non-negative u32 and unique within the call. Violations
312
+ * throw `InvalidParamsError` without any chain read.
313
+ * 2. **Pause check** — reads the governance snapshot via `view.all()`
314
+ * and throws `HashiPausedError` if `paused` is `true`. Mirrors the
315
+ * Move-side `hashi::assert_unpaused`.
316
+ * 3. **Minimum check** — every UTXO must have `amountSats ≥
317
+ * snap.bitcoinDepositMinimum`. All offenders are collected into one
318
+ * `AmountBelowMinimumError`, so callers can fix the batch in one
319
+ * round-trip. Mirrors `EBelowMinimumDeposit` in `deposit::deposit`.
320
+ *
321
+ * Both chain-reading checks (2, 3) read from the same `view.all()`
322
+ * snapshot, so validation is internally consistent. Chain state can still
323
+ * drift between the snapshot and execution — the Move side re-asserts
324
+ * both invariants, so a genuine race simply aborts the tx.
325
+ *
326
+ * For composable flows (sponsored tx, dry-run, or bundling into a larger
327
+ * PTB), use `tx.deposit(params)` instead — it returns the unsigned
328
+ * `Transaction` and leaves signing to the caller.
329
+ */
330
+ async deposit({ signer, ...params }) {
331
+ this.#validateDepositParams(params);
332
+ const snap = await this.view.all();
333
+ if (snap.paused) throw new HashiPausedError({ operation: "deposit" });
334
+ const violations = [];
335
+ for (const { vout, amountSats } of params.utxos) if (amountSats < snap.bitcoinDepositMinimum) violations.push({
336
+ amount: amountSats,
337
+ minimum: snap.bitcoinDepositMinimum,
338
+ vout
339
+ });
340
+ if (violations.length > 0) throw new AmountBelowMinimumError({ violations });
341
+ const transaction = this.tx.deposit(params);
342
+ return this.#client.core.signAndExecuteTransaction({
343
+ signer,
344
+ transaction,
345
+ include: {
346
+ effects: true,
347
+ events: true
348
+ }
349
+ });
350
+ }
351
+ /**
352
+ * Submit a BTC withdrawal request for committee processing. Burns `hBTC`
353
+ * from the signer's balance and enqueues a request for the committee to
354
+ * send `amountSats` to `bitcoinAddress` on the Bitcoin network. Signs
355
+ * with `signer` and submits, returning the execution result including
356
+ * `effects` and `events` (`WithdrawalRequestedEvent`).
357
+ *
358
+ * The method runs three preflight stages before signing:
359
+ *
360
+ * 1. **Address decoding** — `bitcoinAddress` is decoded as bech32 (v0
361
+ * P2WPKH, 20 bytes) or bech32m (v1 P2TR, 32 bytes) via
362
+ * `bitcoinAddressToWitnessProgram`. The HRP must match the client's
363
+ * configured Bitcoin network. Violations throw
364
+ * `InvalidBitcoinAddressError` with a structured `code` so callers
365
+ * can distinguish a typo from a wrong-network mistake.
366
+ * 2. **Pause check** — reads the governance snapshot via `view.all()`
367
+ * and throws `HashiPausedError` if `paused` is `true`. Mirrors the
368
+ * Move-side `hashi::assert_unpaused` in `request_withdrawal`.
369
+ * 3. **Minimum check** — `amountSats` must be ≥
370
+ * `snap.bitcoinWithdrawalMinimum`. Below-minimum throws
371
+ * `AmountBelowMinimumError` with a single violation. Mirrors
372
+ * `EBelowMinimumWithdrawal` in `withdraw::request_withdrawal`.
373
+ *
374
+ * For composable flows (sponsored tx, dry-run, or bundling into a
375
+ * larger PTB), use `tx.requestWithdrawal(options)` instead — it returns
376
+ * the unsigned `Transaction` and leaves signing to the caller.
377
+ */
378
+ async requestWithdrawal({ signer, ...params }) {
379
+ const { program } = bitcoinAddressToWitnessProgram(params.bitcoinAddress, this.#bitcoinNetwork);
380
+ const snap = await this.view.all();
381
+ if (snap.paused) throw new HashiPausedError({ operation: "withdraw" });
382
+ if (params.amountSats < snap.bitcoinWithdrawalMinimum) throw new AmountBelowMinimumError({ violations: [{
383
+ amount: params.amountSats,
384
+ minimum: snap.bitcoinWithdrawalMinimum
385
+ }] });
386
+ const transaction = this.tx.requestWithdrawal({
387
+ amount: params.amountSats,
388
+ bitcoinAddress: program
389
+ });
390
+ return this.#client.core.signAndExecuteTransaction({
391
+ signer,
392
+ transaction,
393
+ include: {
394
+ effects: true,
395
+ events: true
396
+ }
397
+ });
398
+ }
399
+ /**
400
+ * Cancel a pending withdrawal request and return the locked BTC to the
401
+ * signer. Signs with `signer` and submits, returning the execution result.
402
+ *
403
+ * The only client-side precondition is that `requestId` is 0x-prefixed
404
+ * 32-byte hex. All other constraints — ownership (only the original
405
+ * requester can cancel), state window (cancellable only while `Requested`
406
+ * or `Approved`, not after committee commitment), and the on-chain
407
+ * `withdrawal_cancellation_cooldown_ms` — are enforced by the Move side
408
+ * and left to abort at execution. Pre-fetching them would cost an extra
409
+ * round-trip and still race chain state.
410
+ *
411
+ * Unlike `requestWithdrawal`, no pause check is performed: the Move
412
+ * `cancel_withdrawal` function has no `assert_unpaused`, so users can
413
+ * always unwind a pending request even when the system is paused.
414
+ *
415
+ * For composable flows, use `tx.cancelWithdrawal({ requestId, recipient })`.
416
+ */
417
+ async cancelWithdrawal({ signer, requestId }) {
418
+ assertHex32(requestId, "requestId");
419
+ const transaction = this.tx.cancelWithdrawal({
420
+ requestId,
421
+ recipient: signer.toSuiAddress()
422
+ });
423
+ return this.#client.core.signAndExecuteTransaction({
424
+ signer,
425
+ transaction,
426
+ include: {
427
+ effects: true,
428
+ events: true
429
+ }
430
+ });
431
+ }
432
+ #validateDepositParams(params) {
433
+ assertHex32(params.txid, "txid");
434
+ assertHex32(params.recipient, "recipient");
435
+ if (params.utxos.length === 0) throw new InvalidParamsError({ reason: "`utxos` must contain at least one UTXO" });
436
+ const seen = /* @__PURE__ */ new Set();
437
+ for (const { vout } of params.utxos) {
438
+ if (!Number.isInteger(vout) || vout < 0 || vout > U32_MAX) throw new InvalidParamsError({
439
+ reason: "`vout` must be a non-negative u32 integer",
440
+ detail: `got ${JSON.stringify(vout)}`
441
+ });
442
+ if (seen.has(vout)) throw new InvalidParamsError({
443
+ reason: "duplicate `vout` within a single deposit",
444
+ detail: `vout ${vout} appears more than once (each output of a single txid must be unique)`
445
+ });
446
+ seen.add(vout);
447
+ }
448
+ }
449
+ /**
450
+ * Parses the `Hashi.config.config` VecMap contents into a typed snapshot,
451
+ * applying the same floors as the Move accessors so the SDK matches
452
+ * on-chain semantics exactly.
453
+ */
454
+ #parseConfig(contents) {
455
+ const u64 = (key) => {
456
+ const v = entry(contents, key, "U64");
457
+ try {
458
+ return BigInt(v.U64);
459
+ } catch (cause) {
460
+ throw HashiConfigError.malformedPayload(key, "U64", `"${v.U64}" is not a valid integer`, cause);
461
+ }
462
+ };
463
+ const bool = (key) => entry(contents, key, "Bool").Bool;
464
+ const addr = (key) => entry(contents, key, "Address").Address;
465
+ const rawDepositMin = u64("bitcoin_deposit_minimum");
466
+ const rawWithdrawalMin = u64("bitcoin_withdrawal_minimum");
467
+ const bitcoinDepositMinimum = rawDepositMin < DUST_RELAY_MIN_VALUE ? DUST_RELAY_MIN_VALUE : rawDepositMin;
468
+ const bitcoinWithdrawalMinimum = rawWithdrawalMin < DUST_RELAY_MIN_VALUE + 1n ? DUST_RELAY_MIN_VALUE + 1n : rawWithdrawalMin;
469
+ return {
470
+ paused: bool("paused"),
471
+ bitcoinChainId: addr("bitcoin_chain_id"),
472
+ bitcoinDepositMinimum,
473
+ bitcoinWithdrawalMinimum,
474
+ bitcoinConfirmationThreshold: u64("bitcoin_confirmation_threshold"),
475
+ withdrawalCancellationCooldownMs: u64("withdrawal_cancellation_cooldown_ms"),
476
+ depositMinimum: bitcoinDepositMinimum,
477
+ worstCaseNetworkFee: bitcoinWithdrawalMinimum - DUST_RELAY_MIN_VALUE
478
+ };
479
+ }
480
+ /**
481
+ * Fetches the `BitcoinState` dynamic field from the Hashi shared object.
482
+ * Returns the BCS-parsed struct whose nested Bag/Table IDs are used by
483
+ * `findUsedUtxos` and `transactionHistory`.
484
+ *
485
+ * `BitcoinState` is attached via `df::add` on the Move side (it has
486
+ * `store` only, not `key`), so the regular-DF accessor is the right tool
487
+ * — `getDynamicObjectField` would look for a `dynamic_object_field::
488
+ * Wrapper<BitcoinStateKey>` that doesn't exist and abort with "not
489
+ * found". The previous `listDynamicFields` workaround filtered for
490
+ * `$kind === "DynamicObject"` and hit the same dof/df mismatch.
491
+ */
492
+ async #fetchBitcoinState() {
493
+ const { dynamicField } = await this.#client.core.getDynamicField({
494
+ parentId: this.#hashiObjectId,
495
+ name: {
496
+ type: `${this.#packageId}::bitcoin_state::BitcoinStateKey`,
497
+ bcs: BitcoinStateKey.serialize({ dummy_field: false }).toBytes()
498
+ }
499
+ });
500
+ return BitcoinState.parse(new Uint8Array(dynamicField.value.bcs));
501
+ }
502
+ /**
503
+ * Batch-fetches objects in chunks of `GET_OBJECTS_BATCH`, concatenating
504
+ * the results. Each element is either an object or an `Error` (for
505
+ * missing / deleted objects).
506
+ */
507
+ async #batchGetObjects(objectIds, include) {
508
+ const results = [];
509
+ for (let i = 0; i < objectIds.length; i += GET_OBJECTS_BATCH) {
510
+ const batch = objectIds.slice(i, i + GET_OBJECTS_BATCH);
511
+ const { objects } = await this.#client.core.getObjects({
512
+ objectIds: batch,
513
+ include
514
+ });
515
+ results.push(...objects);
516
+ }
517
+ return results;
518
+ }
519
+ };
520
+
521
+ //#endregion
522
+ export { HashiClient, hashi };
@@ -0,0 +1,60 @@
1
+ //#region src/constants.ts
2
+ const NETWORK_HRP = {
3
+ mainnet: "bc",
4
+ testnet: "tb",
5
+ signet: "tb",
6
+ regtest: "bcrt"
7
+ };
8
+ /**
9
+ * BIP-341 Nothing-Up-My-Sleeve (NUMS) internal key.
10
+ * Has no known private key, which forces all taproot spends through the script path.
11
+ */
12
+ const NUMS_KEY = new Uint8Array([
13
+ 80,
14
+ 146,
15
+ 155,
16
+ 116,
17
+ 193,
18
+ 160,
19
+ 73,
20
+ 84,
21
+ 183,
22
+ 139,
23
+ 75,
24
+ 96,
25
+ 53,
26
+ 233,
27
+ 122,
28
+ 94,
29
+ 7,
30
+ 138,
31
+ 90,
32
+ 15,
33
+ 40,
34
+ 236,
35
+ 150,
36
+ 213,
37
+ 71,
38
+ 191,
39
+ 238,
40
+ 154,
41
+ 206,
42
+ 128,
43
+ 58,
44
+ 192
45
+ ]);
46
+ /**
47
+ * The Move side uses this as a floor on `bitcoin_deposit_minimum` and
48
+ * `bitcoin_withdrawal_minimum`; the SDK replicates the same floors so `view.*`
49
+ * matches on-chain semantics. Mirrors `DUST_RELAY_MIN_VALUE` in
50
+ * `hashi::btc_config`.
51
+ */
52
+ const DUST_RELAY_MIN_VALUE = 546n;
53
+ const NETWORK_CONFIG = { devnet: {
54
+ hashiObjectId: "0x61f1acbf8dd79d284cb312853389747a55a158b1b12a68554afcade05dec5f40",
55
+ packageId: "0xc377616c626d73556cae080706acf37e862d24d26c690cc7be5a957f53fadf17",
56
+ bitcoinNetwork: "signet"
57
+ } };
58
+
59
+ //#endregion
60
+ export { DUST_RELAY_MIN_VALUE, NETWORK_CONFIG, NETWORK_HRP, NUMS_KEY };
@@ -0,0 +1,28 @@
1
+ import { MoveStruct } from "../utils/index.mjs";
2
+ import { DepositRequestQueue } from "./deposit_queue.mjs";
3
+ import { WithdrawalRequestQueue } from "./withdrawal_queue.mjs";
4
+ import { UtxoPool } from "./utxo_pool.mjs";
5
+ import { Table } from "./deps/sui/table.mjs";
6
+ import { bcs } from "@mysten/sui/bcs";
7
+
8
+ //#region src/contracts/hashi/bitcoin_state.ts
9
+ /**************************************************************
10
+ * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED *
11
+ **************************************************************/
12
+ const $moduleName = "@local-pkg/hashi::bitcoin_state";
13
+ const BitcoinStateKey = new MoveStruct({
14
+ name: `${$moduleName}::BitcoinStateKey`,
15
+ fields: { dummy_field: bcs.bool() }
16
+ });
17
+ const BitcoinState = new MoveStruct({
18
+ name: `${$moduleName}::BitcoinState`,
19
+ fields: {
20
+ deposit_queue: DepositRequestQueue,
21
+ withdrawal_queue: WithdrawalRequestQueue,
22
+ utxo_pool: UtxoPool,
23
+ user_requests: Table
24
+ }
25
+ });
26
+
27
+ //#endregion
28
+ export { BitcoinState, BitcoinStateKey };
@@ -0,0 +1,40 @@
1
+ import { MoveStruct } from "../utils/index.mjs";
2
+ import { Element } from "./deps/sui/group_ops.mjs";
3
+ import { bcs } from "@mysten/sui/bcs";
4
+
5
+ //#region src/contracts/hashi/committee.ts
6
+ /**************************************************************
7
+ * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED *
8
+ **************************************************************/
9
+ const $moduleName = "@local-pkg/hashi::committee";
10
+ const CommitteeMember = new MoveStruct({
11
+ name: `${$moduleName}::CommitteeMember`,
12
+ fields: {
13
+ validator_address: bcs.Address,
14
+ public_key: Element,
15
+ encryption_public_key: bcs.vector(bcs.u8()),
16
+ weight: bcs.u64()
17
+ }
18
+ });
19
+ const Committee = new MoveStruct({
20
+ name: `${$moduleName}::Committee`,
21
+ fields: {
22
+ epoch: bcs.u64(),
23
+ members: bcs.vector(CommitteeMember),
24
+ 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()
28
+ }
29
+ });
30
+ const CommitteeSignature = new MoveStruct({
31
+ name: `${$moduleName}::CommitteeSignature`,
32
+ fields: {
33
+ epoch: bcs.u64(),
34
+ signature: bcs.vector(bcs.u8()),
35
+ signers_bitmap: bcs.vector(bcs.u8())
36
+ }
37
+ });
38
+
39
+ //#endregion
40
+ export { CommitteeSignature };
@@ -0,0 +1,34 @@
1
+ import { MoveStruct } from "../utils/index.mjs";
2
+ import { Bag } from "./deps/sui/bag.mjs";
3
+ import { Element } from "./deps/sui/group_ops.mjs";
4
+ import { bcs } from "@mysten/sui/bcs";
5
+
6
+ //#region src/contracts/hashi/committee_set.ts
7
+ /**************************************************************
8
+ * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED *
9
+ **************************************************************/
10
+ const $moduleName = "@local-pkg/hashi::committee_set";
11
+ const CommitteeSet = new MoveStruct({
12
+ name: `${$moduleName}::CommitteeSet`,
13
+ fields: {
14
+ members: Bag,
15
+ epoch: bcs.u64(),
16
+ committees: Bag,
17
+ pending_epoch_change: bcs.option(bcs.u64()),
18
+ mpc_public_key: bcs.vector(bcs.u8())
19
+ }
20
+ });
21
+ const MemberInfo = new MoveStruct({
22
+ name: `${$moduleName}::MemberInfo`,
23
+ fields: {
24
+ validator_address: bcs.Address,
25
+ operator_address: bcs.Address,
26
+ next_epoch_public_key: Element,
27
+ endpoint_url: bcs.string(),
28
+ tls_public_key: bcs.vector(bcs.u8()),
29
+ next_epoch_encryption_public_key: bcs.vector(bcs.u8())
30
+ }
31
+ });
32
+
33
+ //#endregion
34
+ export { CommitteeSet };