@mysten-incubation/hashi 0.0.1 → 0.1.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/dist/client.mjs CHANGED
@@ -9,16 +9,54 @@ import { cancelWithdrawal, requestWithdrawal } from "./contracts/hashi/withdraw.
9
9
  import { DUST_RELAY_MIN_VALUE, 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
+ import { getTxConfirmations, lookupAllVouts, lookupVout } from "./btc-rpc.mjs";
12
13
  import { assertHex32, entry, reverseTxidBytes } from "./util.mjs";
13
14
  import { TypeTagSerializer, bcs } from "@mysten/sui/bcs";
14
- import { deriveDynamicFieldID, fromHex, toHex } from "@mysten/sui/utils";
15
+ import { deriveDynamicFieldID, fromHex } from "@mysten/sui/utils";
16
+ import { base58 } from "@scure/base";
15
17
  import { Transaction, coinWithBalance } from "@mysten/sui/transactions";
16
18
 
17
19
  //#region src/client.ts
20
+ /** ObjectBag dynamic field name type (Wrapper<address> for dynamic_object_field lookups). */
21
+ const OBJECT_BAG_ADDRESS_TYPE = "0x0000000000000000000000000000000000000000000000000000000000000002::dynamic_object_field::Wrapper<address>";
18
22
  /** Max value of an unsigned 32-bit integer; vout is a u32 on the Bitcoin side. */
19
23
  const U32_MAX = 4294967295;
20
- /** Max objects per `getObjects` call — Sui transports typically cap at 50. */
21
- const GET_OBJECTS_BATCH = 50;
24
+ /** Max objects per `getObjects` call. */
25
+ const GET_OBJECTS_BATCH = 500;
26
+ const GRAPHQL_URLS = {
27
+ devnet: "https://fullnode.devnet.sui.io:443/graphql",
28
+ testnet: "https://fullnode.testnet.sui.io:443/graphql",
29
+ mainnet: "https://fullnode.mainnet.sui.io:443/graphql",
30
+ localnet: "http://127.0.0.1:9000/graphql"
31
+ };
32
+ function defaultGraphqlUrl(network) {
33
+ return GRAPHQL_URLS[network] ?? GRAPHQL_URLS["mainnet"];
34
+ }
35
+ /**
36
+ * Recognize the per-object errors that mean "the object (or dynamic field)
37
+ * genuinely does not exist" — the only Error shape that `findUsedUtxos` is
38
+ * allowed to treat as a pool miss. Anything else (deleted, displayError,
39
+ * unknown) must propagate so other per-object failures can't be silently
40
+ * downgraded to "not used."
41
+ *
42
+ * Two transports, two shapes:
43
+ * - JSON-RPC returns a typed `ObjectError` whose `.code` is `notExists` or
44
+ * `dynamicFieldNotFound`. `ObjectError` is not re-exported from
45
+ * `@mysten/sui/client`, so the guard duck-types the field.
46
+ * - gRPC stringifies the per-object error into `new Error(message)` with no
47
+ * code (see the `TODO: improve error handling` in `@mysten/sui/grpc/core.ts`).
48
+ * For now the only signal is the message, which the Sui ledger service
49
+ * returns as exactly `Object <id> not found` for missing objects.
50
+ *
51
+ * Transport-level failures don't show up here — they reject the whole
52
+ * `getObjects` promise rather than appearing in the result array.
53
+ */
54
+ const GRPC_NOT_FOUND_MESSAGE_RE = /^Object 0x[0-9a-f]+ not found$/i;
55
+ function isObjectNotFoundError(err) {
56
+ const code = err.code;
57
+ if (code === "notExists" || code === "dynamicFieldNotFound") return true;
58
+ return code === void 0 && GRPC_NOT_FOUND_MESSAGE_RE.test(err.message);
59
+ }
22
60
  function hashi({ name = "hashi", ...options }) {
23
61
  return {
24
62
  name,
@@ -50,11 +88,13 @@ var HashiClient = class {
50
88
  #hashiObjectId;
51
89
  #packageId;
52
90
  #bitcoinNetwork;
53
- constructor({ client, network, hashiObjectId, packageId, bitcoinNetwork }) {
91
+ #btcRpcUrl;
92
+ #graphqlUrl;
93
+ constructor({ client, network, hashiObjectId, packageId, bitcoinNetwork, btcRpcUrl, graphqlUrl }) {
54
94
  this.tx = {
55
95
  deposit: (params) => {
56
96
  const tx = new Transaction();
57
- const internalTxid = reverseTxidBytes(params.txid);
97
+ const internalTxid = `0x${reverseTxidBytes(params.txid)}`;
58
98
  for (const { vout, amountSats } of params.utxos) {
59
99
  const utxoId$1 = tx.add(utxoId({
60
100
  package: this.#packageId,
@@ -175,12 +215,14 @@ var HashiClient = class {
175
215
  const fieldIds = [];
176
216
  for (const u of utxos) {
177
217
  const keyBcs = UtxoId.serialize({
178
- txid: reverseTxidBytes(u.txid),
218
+ txid: `0x${reverseTxidBytes(u.txid)}`,
179
219
  vout: u.vout
180
220
  }).toBytes();
181
221
  fieldIds.push(deriveDynamicFieldID(activePoolId, typeTag, keyBcs), deriveDynamicFieldID(spentPoolId, typeTag, keyBcs));
182
222
  }
183
223
  const objects = await this.#batchGetObjects(fieldIds);
224
+ if (objects.length !== fieldIds.length) throw new HashiFetchError(`findUsedUtxos: getObjects returned ${objects.length} results, expected ${fieldIds.length}`, this.#hashiObjectId);
225
+ for (const result of objects) if (result instanceof Error && !isObjectNotFoundError(result)) throw result;
184
226
  return utxos.map((u, i) => {
185
227
  const inActivePool = !(objects[i * 2] instanceof Error);
186
228
  const inSpentPool = !(objects[i * 2 + 1] instanceof Error);
@@ -192,86 +234,206 @@ var HashiClient = class {
192
234
  };
193
235
  });
194
236
  },
195
- transactionHistory: async (suiAddress) => {
196
- const tableId = (await this.#fetchBitcoinState()).user_requests.id;
197
- let userBagId;
237
+ balance: async (owner) => {
238
+ const btcType = `${this.#packageId}::btc::BTC`;
239
+ const { balance } = await this.#client.core.getBalance({
240
+ owner,
241
+ coinType: btcType
242
+ });
243
+ let coinObjectCount = 0;
244
+ let cursor = null;
245
+ let hasNextPage = true;
246
+ while (hasNextPage) {
247
+ const page = await this.#client.core.listCoins({
248
+ owner,
249
+ coinType: btcType,
250
+ cursor: cursor ?? void 0
251
+ });
252
+ coinObjectCount += page.objects.length;
253
+ cursor = page.cursor;
254
+ hasNextPage = page.hasNextPage;
255
+ }
256
+ return {
257
+ totalBalance: BigInt(balance.balance ?? "0"),
258
+ coinObjectCount
259
+ };
260
+ },
261
+ depositStatus: async (suiTxDigest) => {
262
+ const txResult = await this.#client.core.getTransaction({
263
+ digest: suiTxDigest,
264
+ include: { events: true }
265
+ });
266
+ const txData = txResult.Transaction ?? txResult.FailedTransaction;
267
+ if (!txData?.events) return null;
268
+ const depositEvent = txData.events.find((e) => e.eventType.includes("::deposit::DepositRequestedEvent"));
269
+ if (!depositEvent?.json) return null;
270
+ const parsed = depositEvent.json;
271
+ let status = "unknown";
198
272
  try {
199
- const { dynamicField } = await this.#client.core.getDynamicField({
200
- parentId: tableId,
273
+ await DepositRequest.get({
274
+ client: this.#client,
275
+ objectId: parsed.request_id
276
+ });
277
+ const requestsBagId = (await this.#fetchBitcoinState()).deposit_queue.requests.id;
278
+ status = (await this.#client.core.getDynamicField({
279
+ parentId: requestsBagId,
201
280
  name: {
202
- type: "address",
203
- bcs: bcs.Address.serialize(suiAddress).toBytes()
281
+ type: OBJECT_BAG_ADDRESS_TYPE,
282
+ bcs: bcs.Address.serialize(parsed.request_id).toBytes()
204
283
  }
284
+ }).catch(() => null))?.dynamicField ? "pending" : "confirmed";
285
+ } catch (err) {
286
+ if (err instanceof Error && isObjectNotFoundError(err)) status = "expired";
287
+ else throw err;
288
+ }
289
+ return {
290
+ requestId: parsed.request_id,
291
+ amountSats: BigInt(parsed.amount),
292
+ recipient: parsed.derivation_path,
293
+ btcTxid: reverseTxidBytes(parsed.utxo_id.txid),
294
+ btcVout: parsed.utxo_id.vout,
295
+ timestampMs: BigInt(parsed.timestamp_ms),
296
+ status,
297
+ suiTxDigest
298
+ };
299
+ },
300
+ withdrawalStatus: async (suiTxDigest) => {
301
+ const txResult = await this.#client.core.getTransaction({
302
+ digest: suiTxDigest,
303
+ include: { events: true }
304
+ });
305
+ const txData = txResult.Transaction ?? txResult.FailedTransaction;
306
+ if (!txData?.events) return null;
307
+ const withdrawEvent = txData.events.find((e) => e.eventType.includes("::withdrawal_queue::WithdrawalRequestedEvent"));
308
+ if (!withdrawEvent?.json) return null;
309
+ const parsed = withdrawEvent.json;
310
+ let status = "Requested";
311
+ let btcTxid = null;
312
+ try {
313
+ const reqObj = await WithdrawalRequest.get({
314
+ client: this.#client,
315
+ objectId: parsed.request_id
205
316
  });
206
- userBagId = Bag.parse(new Uint8Array(dynamicField.value.bcs)).id;
207
- } catch {
208
- return [];
317
+ status = reqObj.json.status.$kind;
318
+ const withdrawalTxnId = reqObj.json.withdrawal_txn_id;
319
+ if (withdrawalTxnId && (status === "Processing" || status === "Signed" || status === "Confirmed")) try {
320
+ btcTxid = reverseTxidBytes((await WithdrawalTransaction.get({
321
+ client: this.#client,
322
+ objectId: withdrawalTxnId
323
+ })).json.txid);
324
+ } catch {}
325
+ } catch (err) {
326
+ if (err instanceof Error && isObjectNotFoundError(err)) status = "cancelled";
327
+ else throw err;
209
328
  }
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
329
+ return {
330
+ requestId: parsed.request_id,
331
+ btcAmountSats: BigInt(parsed.btc_amount),
332
+ bitcoinAddress: new Uint8Array(parsed.bitcoin_address),
333
+ sender: parsed.requester_address,
334
+ timestampMs: BigInt(parsed.timestamp_ms),
335
+ status,
336
+ suiTxDigest,
337
+ btcTxid
338
+ };
339
+ },
340
+ depositGasEstimate: async (sender) => {
341
+ const dummyAmount = (await this.view.all()).bitcoinDepositMinimum + 1n;
342
+ const dummyTxid = "0x" + "01".repeat(32);
343
+ const tx = new Transaction();
344
+ const utxoId$1 = tx.add(utxoId({
345
+ package: this.#packageId,
346
+ arguments: {
347
+ txid: dummyTxid,
348
+ vout: 0
349
+ }
350
+ }));
351
+ const utxo$1 = tx.add(utxo({
352
+ package: this.#packageId,
353
+ arguments: {
354
+ utxoId: utxoId$1,
355
+ amount: dummyAmount,
356
+ derivationPath: sender
357
+ }
358
+ }));
359
+ tx.add(this.call.deposit({ utxo: utxo$1 }));
360
+ tx.setSender(sender);
361
+ return { gasEstimateMist: await this.#estimateGas(tx) };
362
+ },
363
+ withdrawalFees: async (sender) => {
364
+ const snap = await this.view.all();
365
+ let gasEstimateMist = 0n;
366
+ if (sender) {
367
+ const dummyAmount = snap.bitcoinWithdrawalMinimum + 1n;
368
+ const btcType = `${this.#packageId}::btc::BTC`;
369
+ const tx = new Transaction();
370
+ const coin = tx.add(coinWithBalance({
371
+ type: btcType,
372
+ balance: dummyAmount,
373
+ useGasCoin: false
374
+ }));
375
+ const [balance] = tx.moveCall({
376
+ package: "0x2",
377
+ module: "coin",
378
+ function: "into_balance",
379
+ typeArguments: [btcType],
380
+ arguments: [coin]
216
381
  });
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 });
382
+ tx.add(this.call.requestWithdrawal({
383
+ btc: balance,
384
+ bitcoinAddress: Array(20).fill(0)
385
+ }));
386
+ tx.setSender(sender);
387
+ gasEstimateMist = await this.#estimateGas(tx);
388
+ }
389
+ return {
390
+ worstCaseNetworkFeeSats: snap.worstCaseNetworkFee,
391
+ withdrawalMinimumSats: snap.bitcoinWithdrawalMinimum,
392
+ gasEstimateMist
393
+ };
394
+ },
395
+ transactionHistory: async (suiAddress) => {
396
+ const btcState = await this.#fetchBitcoinState();
397
+ const confirmedIds = /* @__PURE__ */ new Set();
222
398
  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
- });
399
+ const userBagId = await this.#fetchUserRequestsBagId(btcState.user_requests.id, suiAddress);
400
+ if (userBagId !== null) {
401
+ const requestIds = await this.#listAllDynamicFieldAddressKeys(userBagId);
402
+ if (requestIds.length > 0) {
403
+ const objects = await this.#batchGetObjects(requestIds, { content: true });
404
+ const classified = this.#classifyRequestObjects(objects);
405
+ items.push(...classified.items);
406
+ await this.#populateWithdrawalBtcTxids(items, classified.withdrawalTxnLookups);
407
+ for (const id of requestIds) confirmedIds.add(id);
259
408
  }
260
409
  }
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);
410
+ try {
411
+ const depositEventType = `${this.#packageId}::deposit::DepositRequestedEvent`;
412
+ const pendingIds = (await this.#queryEventRequestIds(suiAddress, depositEventType)).filter((id) => !confirmedIds.has(id));
413
+ if (pendingIds.length > 0) {
414
+ const objects = await this.#batchGetObjects(pendingIds, { content: true });
415
+ const classified = this.#classifyRequestObjects(objects);
416
+ items.push(...classified.items);
270
417
  }
271
- }
418
+ } catch {}
419
+ items.sort((a, b) => Number(b.timestampMs - a.timestampMs));
272
420
  return items;
273
421
  }
274
422
  };
423
+ this.bitcoin = {
424
+ lookupVout: async (txid, depositAddress) => {
425
+ this.#requireBtcRpc();
426
+ return lookupVout(this.#btcRpcUrl, txid, depositAddress);
427
+ },
428
+ lookupAllVouts: async (txid, depositAddress) => {
429
+ this.#requireBtcRpc();
430
+ return lookupAllVouts(this.#btcRpcUrl, txid, depositAddress);
431
+ },
432
+ confirmations: async (txid) => {
433
+ this.#requireBtcRpc();
434
+ return getTxConfirmations(this.#btcRpcUrl, txid);
435
+ }
436
+ };
275
437
  const config = NETWORK_CONFIG[network];
276
438
  const resolvedObjectId = hashiObjectId ?? config?.hashiObjectId;
277
439
  const resolvedPackageId = packageId ?? config?.packageId;
@@ -280,6 +442,8 @@ var HashiClient = class {
280
442
  this.#hashiObjectId = resolvedObjectId;
281
443
  this.#packageId = resolvedPackageId;
282
444
  this.#bitcoinNetwork = bitcoinNetwork ?? config?.bitcoinNetwork ?? "testnet";
445
+ this.#btcRpcUrl = btcRpcUrl;
446
+ this.#graphqlUrl = graphqlUrl ?? defaultGraphqlUrl(network);
283
447
  }
284
448
  /**
285
449
  * Generates a unique Bitcoin P2TR deposit address for a Sui address.
@@ -478,6 +642,52 @@ var HashiClient = class {
478
642
  };
479
643
  }
480
644
  /**
645
+ * Poll deposit status until it reaches a terminal state (confirmed or expired).
646
+ */
647
+ async waitForDeposit(suiTxDigest, options) {
648
+ const intervalMs = options?.intervalMs ?? 15e3;
649
+ const signal = options?.signal;
650
+ while (!signal?.aborted) {
651
+ const info = await this.view.depositStatus(suiTxDigest);
652
+ if (!info) throw new Error(`Deposit not found for digest: ${suiTxDigest}`);
653
+ if (info.status === "confirmed" || info.status === "expired") return info;
654
+ await sleep(intervalMs, signal);
655
+ }
656
+ throw new Error("Polling aborted");
657
+ }
658
+ /**
659
+ * Poll withdrawal status until it reaches a terminal state (confirmed or cancelled).
660
+ */
661
+ async waitForWithdrawal(suiTxDigest, options) {
662
+ const intervalMs = options?.intervalMs ?? 15e3;
663
+ const signal = options?.signal;
664
+ while (!signal?.aborted) {
665
+ const info = await this.view.withdrawalStatus(suiTxDigest);
666
+ if (!info) throw new Error(`Withdrawal not found for digest: ${suiTxDigest}`);
667
+ if (info.status === "Confirmed" || info.status === "cancelled") return info;
668
+ await sleep(intervalMs, signal);
669
+ }
670
+ throw new Error("Polling aborted");
671
+ }
672
+ #requireBtcRpc() {
673
+ if (!this.#btcRpcUrl) throw new Error("btcRpcUrl is required for Bitcoin RPC operations. Pass it in HashiClientOptions.");
674
+ }
675
+ async #estimateGas(tx) {
676
+ try {
677
+ const result = await this.#client.core.simulateTransaction({
678
+ transaction: tx,
679
+ include: { effects: true }
680
+ });
681
+ const simTx = result.Transaction ?? result.FailedTransaction;
682
+ if (simTx?.effects?.gasUsed) {
683
+ const gas = simTx.effects.gasUsed;
684
+ const total = BigInt(gas.computationCost) + BigInt(gas.storageCost) - BigInt(gas.storageRebate);
685
+ return total > 0n ? total * 120n / 100n : 0n;
686
+ }
687
+ } catch {}
688
+ return 0n;
689
+ }
690
+ /**
481
691
  * Fetches the `BitcoinState` dynamic field from the Hashi shared object.
482
692
  * Returns the BCS-parsed struct whose nested Bag/Table IDs are used by
483
693
  * `findUsedUtxos` and `transactionHistory`.
@@ -500,6 +710,41 @@ var HashiClient = class {
500
710
  return BitcoinState.parse(new Uint8Array(dynamicField.value.bcs));
501
711
  }
502
712
  /**
713
+ * Resolve the Bag id holding a user's request IDs from `user_requests`,
714
+ * or `null` if the user has never had a request.
715
+ */
716
+ async #fetchUserRequestsBagId(tableId, suiAddress) {
717
+ try {
718
+ const { dynamicField } = await this.#client.core.getDynamicField({
719
+ parentId: tableId,
720
+ name: {
721
+ type: "address",
722
+ bcs: bcs.Address.serialize(suiAddress).toBytes()
723
+ }
724
+ });
725
+ return Bag.parse(new Uint8Array(dynamicField.value.bcs)).id;
726
+ } catch {
727
+ return null;
728
+ }
729
+ }
730
+ /**
731
+ * Enumerate every `address`-keyed dynamic field on `parentId`, paginating
732
+ * through `listDynamicFields` until exhausted.
733
+ */
734
+ async #listAllDynamicFieldAddressKeys(parentId) {
735
+ const keys = [];
736
+ let cursor = null;
737
+ do {
738
+ const page = await this.#client.core.listDynamicFields({
739
+ parentId,
740
+ cursor: cursor ?? void 0
741
+ });
742
+ for (const df of page.dynamicFields) keys.push(bcs.Address.parse(new Uint8Array(df.name.bcs)));
743
+ cursor = page.hasNextPage ? page.cursor : null;
744
+ } while (cursor);
745
+ return keys;
746
+ }
747
+ /**
503
748
  * Batch-fetches objects in chunks of `GET_OBJECTS_BATCH`, concatenating
504
749
  * the results. Each element is either an object or an `Error` (for
505
750
  * missing / deleted objects).
@@ -516,7 +761,131 @@ var HashiClient = class {
516
761
  }
517
762
  return results;
518
763
  }
764
+ /**
765
+ * Query the Sui GraphQL endpoint for events of a given type emitted by
766
+ * a sender. Returns the `request_id` from each event's JSON payload,
767
+ * paginating through all results.
768
+ */
769
+ async #queryEventRequestIds(sender, eventType) {
770
+ const ids = [];
771
+ let cursor = null;
772
+ let hasMore = true;
773
+ while (hasMore) {
774
+ const query = `{
775
+ events(
776
+ filter: { sender: "${sender}", type: "${eventType}" }
777
+ first: 50${cursor ? `, after: "${cursor}"` : ""}
778
+ ) {
779
+ nodes { contents { json } }
780
+ pageInfo { hasNextPage endCursor }
781
+ }
782
+ }`;
783
+ const res = await fetch(this.#graphqlUrl, {
784
+ method: "POST",
785
+ headers: { "Content-Type": "application/json" },
786
+ body: JSON.stringify({ query })
787
+ });
788
+ if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`);
789
+ const body = await res.json();
790
+ if (body.errors?.length) throw new Error(`GraphQL error: ${body.errors[0].message}`);
791
+ const events = body.data?.events;
792
+ if (!events) break;
793
+ for (const node of events.nodes) ids.push(node.contents.json.request_id);
794
+ hasMore = events.pageInfo.hasNextPage;
795
+ cursor = events.pageInfo.endCursor ?? null;
796
+ }
797
+ return ids;
798
+ }
799
+ /**
800
+ * Classify a batch of request objects into deposit / withdrawal history
801
+ * items. Errors in the batch are skipped (the request object may have
802
+ * been deleted between the bag enumeration and the fetch). Returns the
803
+ * items plus the indices that need a follow-up `WithdrawalTransaction`
804
+ * fetch to populate `btcTxid`.
805
+ */
806
+ #classifyRequestObjects(objects) {
807
+ const items = [];
808
+ const withdrawalTxnLookups = [];
809
+ const depositRequestType = `${this.#packageId}::deposit_queue::DepositRequest`;
810
+ const withdrawalRequestType = `${this.#packageId}::withdrawal_queue::WithdrawalRequest`;
811
+ for (const obj of objects) {
812
+ if (obj instanceof Error) continue;
813
+ if (obj.type === depositRequestType) items.push(parseDepositHistoryItem(obj.content));
814
+ else if (obj.type === withdrawalRequestType) {
815
+ const item = parseWithdrawalHistoryItem(obj.content);
816
+ items.push(item);
817
+ if (item.withdrawalTxnId) withdrawalTxnLookups.push({
818
+ itemIndex: items.length - 1,
819
+ txnId: item.withdrawalTxnId
820
+ });
821
+ }
822
+ }
823
+ return {
824
+ items,
825
+ withdrawalTxnLookups
826
+ };
827
+ }
828
+ /**
829
+ * Batch-fetch `WithdrawalTransaction` objects for the withdrawal items
830
+ * that have a linked txn and overwrite their `btcTxid` in place. Errors
831
+ * in the batch leave `btcTxid` at the initial `null`.
832
+ */
833
+ async #populateWithdrawalBtcTxids(items, lookups) {
834
+ if (lookups.length === 0) return;
835
+ const txnIds = lookups.map((l) => l.txnId);
836
+ const txnObjects = await this.#batchGetObjects(txnIds, { content: true });
837
+ for (let i = 0; i < lookups.length; i++) {
838
+ const txnObj = txnObjects[i];
839
+ if (txnObj instanceof Error) continue;
840
+ const parsed = WithdrawalTransaction.parse(txnObj.content);
841
+ const item = items[lookups[i].itemIndex];
842
+ item.btcTxid = reverseTxidBytes(parsed.txid);
843
+ }
844
+ }
519
845
  };
846
+ function parseDepositHistoryItem(content) {
847
+ const parsed = DepositRequest.parse(content);
848
+ return {
849
+ kind: "deposit",
850
+ requestId: parsed.id,
851
+ sender: parsed.sender,
852
+ timestampMs: BigInt(parsed.timestamp_ms),
853
+ suiTxDigest: base58.encode(new Uint8Array(parsed.sui_tx_digest)),
854
+ amountSats: BigInt(parsed.utxo.amount),
855
+ btcTxid: reverseTxidBytes(parsed.utxo.id.txid),
856
+ btcVout: parsed.utxo.id.vout,
857
+ approved: parsed.approval_cert !== null,
858
+ approvalTimestampMs: parsed.approval_timestamp_ms === null ? null : BigInt(parsed.approval_timestamp_ms)
859
+ };
860
+ }
861
+ function parseWithdrawalHistoryItem(content) {
862
+ const parsed = WithdrawalRequest.parse(content);
863
+ return {
864
+ kind: "withdrawal",
865
+ requestId: parsed.id,
866
+ sender: parsed.sender,
867
+ btcAmountSats: BigInt(parsed.btc_amount),
868
+ bitcoinAddress: new Uint8Array(parsed.bitcoin_address),
869
+ timestampMs: BigInt(parsed.timestamp_ms),
870
+ suiTxDigest: base58.encode(new Uint8Array(parsed.sui_tx_digest)),
871
+ status: parsed.status.$kind,
872
+ withdrawalTxnId: parsed.withdrawal_txn_id ?? null,
873
+ btcTxid: null
874
+ };
875
+ }
876
+ function sleep(ms, signal) {
877
+ return new Promise((resolve, reject) => {
878
+ if (signal?.aborted) {
879
+ reject(/* @__PURE__ */ new Error("Aborted"));
880
+ return;
881
+ }
882
+ const timer = setTimeout(resolve, ms);
883
+ signal?.addEventListener("abort", () => {
884
+ clearTimeout(timer);
885
+ reject(/* @__PURE__ */ new Error("Aborted"));
886
+ }, { once: true });
887
+ });
888
+ }
520
889
 
521
890
  //#endregion
522
891
  export { HashiClient, hashi };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { BitcoinNetwork, CancelWithdrawalParams, DepositHistoryItem, DepositParams, GovernanceConfig, HashiClientOptions, NetworkConfig, SuiNetwork, TransactionHistoryItem, UtxoId, UtxoOutput, UtxoUsageResult, WithdrawalHistoryItem, WithdrawalParams, WithdrawalStatus } from "./types.mjs";
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 } from "./bitcoin.mjs";
5
- export { AmountBelowMinimumError, type AmountViolation, type BitcoinNetwork, type CancelWithdrawalParams, type DepositHistoryItem, type DepositParams, type GovernanceConfig, HashiClient, type HashiClientOptions, HashiConfigError, HashiFetchError, HashiPausedError, type InvalidBitcoinAddressCode, InvalidBitcoinAddressError, InvalidParamsError, type NetworkConfig, type SuiNetwork, type TransactionHistoryItem, type UtxoId, type UtxoOutput, type UtxoUsageResult, type WithdrawalHistoryItem, type WithdrawalParams, type WithdrawalStatus, arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, hashi, taprootScriptPathAddress };
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 };
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 } from "./bitcoin.mjs";
2
+ import { arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, taprootScriptPathAddress, 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 };
5
+ export { AmountBelowMinimumError, HashiClient, HashiConfigError, HashiFetchError, HashiPausedError, InvalidBitcoinAddressError, InvalidParamsError, arkworksToSec1Compressed, bitcoinAddressToWitnessProgram, deriveChildPubkey, generateDepositAddress, hashi, taprootScriptPathAddress, witnessProgramToAddress };