From 5401d426cb4e422decdd35a60879877e660b66c2 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Wed, 15 Jul 2026 12:58:37 +0300 Subject: [PATCH 01/13] Migrate @mysten-incubation/hashi into packages/hashi as @mysten/hashi Copies the Hashi SDK source, tests, and committed codegen bindings from the standalone hashi-ts-sdk repo, reformatted to monorepo prettier style. Rewrites package.json to monorepo conventions (workspace peer deps, standard scripts), retargets codegen at the ../hashi sibling checkout, and wires up the release workflow, oxlint ignore, AGENTS.md, and a changeset. Also fixes latent type errors in tests surfaced by the new test:typecheck step (union narrowing in history lookups, parse-only $kind tag in a BCS enum input); the standalone repo never type-checked its test directory. --- .changeset/hashi-monorepo-migration.md | 6 + .github/workflows/release-hashi.yml | 31 + .oxlintrc.json | 1 + AGENTS.md | 2 + packages/hashi/.prettierignore | 2 + packages/hashi/CHANGELOG.md | 58 + packages/hashi/README.md | 331 +++ packages/hashi/package.json | 68 + packages/hashi/src/bitcoin.ts | 548 ++++ packages/hashi/src/btc-rpc.ts | 65 + packages/hashi/src/client.ts | 1635 ++++++++++++ packages/hashi/src/constants.ts | 56 + .../src/contracts/hashi/abort_reconfig.ts | 77 + .../src/contracts/hashi/bitcoin_state.ts | 43 + packages/hashi/src/contracts/hashi/btc.ts | 21 + .../src/contracts/hashi/cert_submission.ts | 149 ++ .../hashi/src/contracts/hashi/committee.ts | 90 + .../src/contracts/hashi/committee_set.ts | 101 + packages/hashi/src/contracts/hashi/config.ts | 27 + .../hashi/src/contracts/hashi/config_value.ts | 162 ++ packages/hashi/src/contracts/hashi/deposit.ts | 186 ++ .../src/contracts/hashi/deposit_queue.ts | 56 + .../hashi/src/contracts/hashi/deps/sui/bag.ts | 41 + .../src/contracts/hashi/deps/sui/balance.ts | 19 + .../src/contracts/hashi/deps/sui/group_ops.ts | 15 + .../contracts/hashi/deps/sui/linked_table.ts | 27 + .../contracts/hashi/deps/sui/object_bag.ts | 24 + .../src/contracts/hashi/deps/sui/package.ts | 29 + .../src/contracts/hashi/deps/sui/table.ts | 36 + .../src/contracts/hashi/deps/sui/vec_map.ts | 33 + .../src/contracts/hashi/deps/sui/vec_set.ts | 22 + .../src/contracts/hashi/disable_version.ts | 75 + .../src/contracts/hashi/emergency_pause.ts | 75 + .../src/contracts/hashi/enable_version.ts | 76 + packages/hashi/src/contracts/hashi/hashi.ts | 97 + .../hashi/src/contracts/hashi/mpc_signing.ts | 60 + .../hashi/src/contracts/hashi/proposal.ts | 162 ++ .../src/contracts/hashi/proposal_events.ts | 55 + .../hashi/src/contracts/hashi/proposals.ts | 27 + .../hashi/src/contracts/hashi/reconfig.ts | 118 + packages/hashi/src/contracts/hashi/tob.ts | 57 + .../hashi/src/contracts/hashi/treasury.ts | 39 + .../src/contracts/hashi/update_config.ts | 78 + .../src/contracts/hashi/update_guardian.ts | 79 + packages/hashi/src/contracts/hashi/upgrade.ts | 119 + packages/hashi/src/contracts/hashi/utxo.ts | 80 + .../hashi/src/contracts/hashi/utxo_pool.ts | 42 + .../hashi/src/contracts/hashi/validator.ts | 191 ++ .../hashi/src/contracts/hashi/versioning.ts | 30 + .../hashi/src/contracts/hashi/withdraw.ts | 372 +++ .../src/contracts/hashi/withdrawal_queue.ts | 251 ++ packages/hashi/src/contracts/utils/index.ts | 234 ++ packages/hashi/src/errors.ts | 236 ++ packages/hashi/src/guardian.ts | 160 ++ packages/hashi/src/index.ts | 56 + packages/hashi/src/types.ts | 355 +++ packages/hashi/src/util.ts | 87 + packages/hashi/sui-codegen.config.ts | 16 + packages/hashi/test/integration/_env.ts | 323 +++ .../hashi/test/integration/deposit.test.ts | 202 ++ .../generate-deposit-address.test.ts | 78 + packages/hashi/test/integration/view.test.ts | 166 ++ .../integration/withdrawal-lifecycle.test.ts | 215 ++ .../integration/withdrawal-payout.test.ts | 149 ++ packages/hashi/test/tsconfig.json | 11 + packages/hashi/test/unit/bitcoin.test.ts | 550 ++++ packages/hashi/test/unit/btc-rpc.test.ts | 115 + packages/hashi/test/unit/client.test.ts | 2316 +++++++++++++++++ packages/hashi/test/unit/guardian.test.ts | 215 ++ packages/hashi/test/unit/util.test.ts | 128 + packages/hashi/tsconfig.json | 7 + packages/hashi/tsdown.config.ts | 11 + packages/hashi/turbo.json | 8 + packages/hashi/vitest.config.mts | 71 + pnpm-lock.yaml | 34 +- 75 files changed, 11784 insertions(+), 3 deletions(-) create mode 100644 .changeset/hashi-monorepo-migration.md create mode 100644 .github/workflows/release-hashi.yml create mode 100644 packages/hashi/.prettierignore create mode 100644 packages/hashi/CHANGELOG.md create mode 100644 packages/hashi/README.md create mode 100644 packages/hashi/package.json create mode 100644 packages/hashi/src/bitcoin.ts create mode 100644 packages/hashi/src/btc-rpc.ts create mode 100644 packages/hashi/src/client.ts create mode 100644 packages/hashi/src/constants.ts create mode 100644 packages/hashi/src/contracts/hashi/abort_reconfig.ts create mode 100644 packages/hashi/src/contracts/hashi/bitcoin_state.ts create mode 100644 packages/hashi/src/contracts/hashi/btc.ts create mode 100644 packages/hashi/src/contracts/hashi/cert_submission.ts create mode 100644 packages/hashi/src/contracts/hashi/committee.ts create mode 100644 packages/hashi/src/contracts/hashi/committee_set.ts create mode 100644 packages/hashi/src/contracts/hashi/config.ts create mode 100644 packages/hashi/src/contracts/hashi/config_value.ts create mode 100644 packages/hashi/src/contracts/hashi/deposit.ts create mode 100644 packages/hashi/src/contracts/hashi/deposit_queue.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/bag.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/balance.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/group_ops.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/linked_table.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/object_bag.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/package.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/table.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/vec_map.ts create mode 100644 packages/hashi/src/contracts/hashi/deps/sui/vec_set.ts create mode 100644 packages/hashi/src/contracts/hashi/disable_version.ts create mode 100644 packages/hashi/src/contracts/hashi/emergency_pause.ts create mode 100644 packages/hashi/src/contracts/hashi/enable_version.ts create mode 100644 packages/hashi/src/contracts/hashi/hashi.ts create mode 100644 packages/hashi/src/contracts/hashi/mpc_signing.ts create mode 100644 packages/hashi/src/contracts/hashi/proposal.ts create mode 100644 packages/hashi/src/contracts/hashi/proposal_events.ts create mode 100644 packages/hashi/src/contracts/hashi/proposals.ts create mode 100644 packages/hashi/src/contracts/hashi/reconfig.ts create mode 100644 packages/hashi/src/contracts/hashi/tob.ts create mode 100644 packages/hashi/src/contracts/hashi/treasury.ts create mode 100644 packages/hashi/src/contracts/hashi/update_config.ts create mode 100644 packages/hashi/src/contracts/hashi/update_guardian.ts create mode 100644 packages/hashi/src/contracts/hashi/upgrade.ts create mode 100644 packages/hashi/src/contracts/hashi/utxo.ts create mode 100644 packages/hashi/src/contracts/hashi/utxo_pool.ts create mode 100644 packages/hashi/src/contracts/hashi/validator.ts create mode 100644 packages/hashi/src/contracts/hashi/versioning.ts create mode 100644 packages/hashi/src/contracts/hashi/withdraw.ts create mode 100644 packages/hashi/src/contracts/hashi/withdrawal_queue.ts create mode 100644 packages/hashi/src/contracts/utils/index.ts create mode 100644 packages/hashi/src/errors.ts create mode 100644 packages/hashi/src/guardian.ts create mode 100644 packages/hashi/src/index.ts create mode 100644 packages/hashi/src/types.ts create mode 100644 packages/hashi/src/util.ts create mode 100644 packages/hashi/sui-codegen.config.ts create mode 100644 packages/hashi/test/integration/_env.ts create mode 100644 packages/hashi/test/integration/deposit.test.ts create mode 100644 packages/hashi/test/integration/generate-deposit-address.test.ts create mode 100644 packages/hashi/test/integration/view.test.ts create mode 100644 packages/hashi/test/integration/withdrawal-lifecycle.test.ts create mode 100644 packages/hashi/test/integration/withdrawal-payout.test.ts create mode 100644 packages/hashi/test/tsconfig.json create mode 100644 packages/hashi/test/unit/bitcoin.test.ts create mode 100644 packages/hashi/test/unit/btc-rpc.test.ts create mode 100644 packages/hashi/test/unit/client.test.ts create mode 100644 packages/hashi/test/unit/guardian.test.ts create mode 100644 packages/hashi/test/unit/util.test.ts create mode 100644 packages/hashi/tsconfig.json create mode 100644 packages/hashi/tsdown.config.ts create mode 100644 packages/hashi/turbo.json create mode 100644 packages/hashi/vitest.config.mts diff --git a/.changeset/hashi-monorepo-migration.md b/.changeset/hashi-monorepo-migration.md new file mode 100644 index 000000000..cf39d179a --- /dev/null +++ b/.changeset/hashi-monorepo-migration.md @@ -0,0 +1,6 @@ +--- +'@mysten/hashi': minor +--- + +Migrate the Hashi SDK into the ts-sdks monorepo and rename `@mysten-incubation/hashi` to +`@mysten/hashi`. The `@mysten/sui` peer dependency now tracks the monorepo's current release line. diff --git a/.github/workflows/release-hashi.yml b/.github/workflows/release-hashi.yml new file mode 100644 index 000000000..a50b8a251 --- /dev/null +++ b/.github/workflows/release-hashi.yml @@ -0,0 +1,31 @@ +name: Release @mysten/hashi + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry-run mode' + required: false + default: 'false' + ref: + description: 'Git ref or SHA to publish (defaults to main HEAD)' + required: false + default: '' + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +jobs: + release: + if: github.ref == 'refs/heads/main' + permissions: + contents: read + id-token: write # OIDC trusted publishing + uses: ./.github/workflows/_release-package.yml + with: + package: '@mysten/hashi' + dir: 'packages/hashi' + dry_run: ${{ inputs.dry_run || 'false' }} + ref: ${{ inputs.ref }} diff --git a/.oxlintrc.json b/.oxlintrc.json index da7350232..d14b34f42 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -28,6 +28,7 @@ "packages/walrus/src/node-api", "packages/sui/src/grpc/proto", "packages/payment-kit/src/contracts", + "packages/hashi/src/contracts", "generated", "vite-env.d.ts", "vitest.config.*", diff --git a/AGENTS.md b/AGENTS.md index 46659b18e..e6fa24d08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,7 @@ Several packages depend on external repositories and remote schemas. These are u | `../sui-payment-kit` | Payment kit Move contracts | `@mysten/payment-kit` codegen | | `../walrus` | Walrus storage contracts | `@mysten/walrus` codegen | | `../deepbookv3` | DeepBook v3 DEX contracts | `@mysten/deepbook-v3` codegen | +| `../hashi` | Hashi BTC-bridge Move contracts | `@mysten/hashi` codegen | | `../apps/kiosk` | Kiosk Move contracts (optional) | `@mysten/kiosk` codegen | ### Remote Resources (fetched from GitHub) @@ -232,5 +233,6 @@ pnpm --filter @mysten/sui codegen:graphql pnpm --filter @mysten/payment-kit codegen pnpm --filter @mysten/walrus codegen pnpm --filter @mysten/deepbook-v3 codegen +pnpm --filter @mysten/hashi codegen pnpm --filter @mysten/kiosk codegen ``` diff --git a/packages/hashi/.prettierignore b/packages/hashi/.prettierignore new file mode 100644 index 000000000..a3414d0fd --- /dev/null +++ b/packages/hashi/.prettierignore @@ -0,0 +1,2 @@ +dist +CHANGELOG.md diff --git a/packages/hashi/CHANGELOG.md b/packages/hashi/CHANGELOG.md new file mode 100644 index 000000000..fb2d6149d --- /dev/null +++ b/packages/hashi/CHANGELOG.md @@ -0,0 +1,58 @@ +# @mysten/hashi + +## 0.5.0 + +### Minor Changes + +- ff9a398: Wire up Sui testnet: add the testnet Hashi object and package ids to `NETWORK_CONFIG` (BTC signet), so `hashi({ network: "testnet" })` works out of the box + +### Patch Changes + +- ff9a398: Bump hashi submodule to cd2b81f (no contract or binding changes) + +## 0.4.0 + +### Minor Changes + +- 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 + +### Patch Changes + +- 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. + +## 0.3.1 + +### Patch Changes + +- 0af8dfa: Derive Bitcoin deposit addresses with Hashi's delayed MPC recovery taproot leaf. + +## 0.3.0 + +### Minor Changes + +- ced85d2: Derive deposit addresses as 2-of-2 (guardian, MPC-child) taproot to match the on-chain bridge (hashi#609). `generateDepositAddress` (pure helper) now takes a named-args object including `guardianBtcXOnly`; `HashiClient.generateDepositAddress` reads the guardian key from on-chain and fails fast with `HashiConfigError` when the deployment is not guardian-provisioned. `GovernanceConfig` gains `guardianUrl`, `guardianPublicKey`, `guardianBtcPublicKey`. Adds `twoOfTwoTaprootScriptPathAddress` as a public primitive and removes the single-key `taprootScriptPathAddress` helper, which the bridge no longer accepts. + +## 0.2.0 + +### Minor Changes + +- 5f9f592: Surface deposit time delay: add `bitcoinDepositTimeDelayMs` to `GovernanceConfig`, `approvalTimestampMs` and `confirmableAtMs` to `DepositInfo`, and `confirmableAtMs` to `DepositHistoryItem` + +## 0.1.1 + +### Patch Changes + +- 5b3389e: Update README install instructions to use the published npm package +- 72b6efc: Expand README to document the status, balance, history, fee, polling, and Bitcoin RPC APIs + +## 0.1.0 + +### Minor Changes + +- 75fcdca: Fix btcTxid display values to strip the 0x prefix. Add GraphQL-based discovery of pending deposits to transaction history — confirmed requests still read from the on-chain user_requests index; in-flight deposits are discovered via DepositRequestedEvent queries and deduplicated. Bump GET_OBJECTS_BATCH to 500. + +## 0.0.2 + +### Patch Changes + +- 9422708: Add a package-level `README.md` so the npm landing page has a real overview (install, one quickstart snippet, link to the repo README for full docs). Also corrects stale `@mysten/hashi` references in the root README to the actual published name `@mysten/hashi`. diff --git a/packages/hashi/README.md b/packages/hashi/README.md new file mode 100644 index 000000000..dd7a72669 --- /dev/null +++ b/packages/hashi/README.md @@ -0,0 +1,331 @@ +# @mysten/hashi + +[![npm version](https://img.shields.io/npm/v/@mysten/hashi.svg)](https://www.npmjs.com/package/@mysten/hashi) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +TypeScript SDK for the [Hashi](https://github.com/MystenLabs/hashi) protocol. Hashi is a +decentralized Bitcoin collateralization primitive on Sui. Orchestrate native BTC directly from smart +contracts—without centralized balance sheets. + +> [!WARNING] **Not production-ready.** This SDK is pre-1.0 and under active development. The API may +> change without notice and only Sui testnet and devnet are wired up. Do not use it in production +> environments yet. + +End-user actions only: **deposit**, **request withdrawal**, **cancel withdrawal**. +Operator/committee/relayer calls are intentionally not part of this surface — those tools should +import the generated bindings under `src/contracts/hashi/` directly. + +## Install + +```bash +pnpm add @mysten/hashi @mysten/sui +``` + +`@mysten/sui` is a peer dependency. + +## Setup + +The SDK attaches to any Sui client via `$extend`. After extension, every Hashi method lives under +`client.hashi.*`. + +```ts +import { SuiGrpcClient } from '@mysten/sui/grpc'; +import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'; +import { hashi } from '@mysten/hashi'; + +const client = new SuiGrpcClient({ + network: 'testnet', + baseUrl: 'https://fullnode.testnet.sui.io:443', +}).$extend(hashi({ network: 'testnet' })); + +const signer = Ed25519Keypair.fromSecretKey(/* … */); +``` + +> **Network support.** Sui **testnet** and **devnet** are wired up (Bitcoin **signet** by default). +> Prefer testnet — devnet support is temporary and will be deprecated. Mainnet is not yet deployed; +> `hashi({ network: "mainnet" })` will throw until it lands. To target a custom or local deployment, +> pass `hashiObjectId`, `packageId`, and `bitcoinNetwork` explicitly. + +> **Optional client options.** `hashi({ ... })` also accepts `btcRpcUrl` — a Bitcoin Core JSON-RPC +> URL, required for the [`client.hashi.bitcoin.*`](#bitcoin-rpc-optional) lookups — and +> `graphqlUrl`, which overrides the Sui GraphQL endpoint used by +> [`transactionHistory`](#transaction-history) (defaults to +> `https://fullnode.{network}.sui.io:443/graphql`). + +## Quickstart: Deposit BTC → mint hBTC + +1. Derive the unique P2TR Bitcoin deposit address for your Sui address. +2. Send BTC to that address from any wallet. +3. Submit the funding `txid` + `vout` to Hashi for committee confirmation. + +The committee watches the Bitcoin chain. Once the funding tx reaches `bitcoinConfirmationThreshold` +confirmations the committee **approves** the deposit; after an additional +`bitcoinDepositTimeDelayMs` safety window elapses the deposit becomes confirmable and `hBTC` is +minted to the `recipient` address. `view.depositStatus(digest).confirmableAtMs` reports that +earliest mint time. + +```ts +const recipient = signer.toSuiAddress(); + +// 1. Get the deposit address. +const btcAddress = await client.hashi.generateDepositAddress({ + suiAddress: recipient, +}); + +// 2. Send BTC to `btcAddress` from any wallet, then collect the +// funding tx's display-order txid and the vout that paid the +// deposit address. (Display-order = the form mempool.space and +// `bitcoin-cli` show — the SDK reverses internally.) + +// 3. Record the deposit on Sui. +const result = await client.hashi.deposit({ + signer, + txid: '0x<64-hex display-order txid>', + utxos: [{ vout: 0, amountSats: 100_000n }], + recipient, +}); + +if (result.$kind !== 'Transaction') { + throw new Error(`deposit failed: ${JSON.stringify(result.FailedTransaction)}`); +} +// `hBTC` lands in `recipient`'s balance once the committee confirms. +``` + +A single funding tx may pay the deposit address on multiple outputs — pass them all in `utxos` and +they're batched into one atomic Sui PTB. + +## Quickstart: Request withdrawal (burn hBTC → receive BTC) + +Burns `amountSats` of `hBTC` from the signer's balance and enqueues a request for the committee to +send BTC to `bitcoinAddress`. The address is decoded client-side as bech32 (P2WPKH) or bech32m +(P2TR) and must match the client's configured Bitcoin network. + +```ts +const result = await client.hashi.requestWithdrawal({ + signer, + amountSats: 50_000n, + bitcoinAddress: 'tb1q…', // P2WPKH on signet/testnet, or `tb1p…` for P2TR +}); + +if (result.$kind !== 'Transaction') { + throw new Error(`request failed: ${JSON.stringify(result.FailedTransaction)}`); +} + +// Pull the request id out of the WithdrawalRequested — needed if +// you later want to cancel. +const evt = result.Transaction.events?.find((e) => + e.eventType.endsWith('::withdrawal_queue::WithdrawalRequested'), +); +const requestId = (evt as { json?: { request_id?: string } } | undefined)?.json?.request_id; +``` + +## Quickstart: Cancel a pending withdrawal + +Returns the locked `hBTC` to the signer. Only the original requester can cancel, only while the +request is still `Requested` or `Approved` (not after committee commitment), and only after +`withdrawalCancellationCooldownMs` has elapsed since the request. All three are enforced on-chain. + +```ts +await client.hashi.cancelWithdrawal({ signer, requestId }); +``` + +## Tracking a deposit or withdrawal + +`deposit` and `requestWithdrawal` resolve to a transaction execution result whose +`Transaction.digest` identifies the submitted Sui tx. Pass that digest to the `view.*Status` readers +for a one-shot check, or to the `waitFor*` helpers to poll until a terminal state. + +```ts +const digest = result.Transaction?.digest; + +// One-shot status check — returns null if the digest has no Hashi event. +const deposit = await client.hashi.view.depositStatus(digest); +// deposit?.status: "pending" | "confirmed" | "expired" | "unknown" +// deposit?.approvalTimestampMs — when the committee approved (null until approved) +// deposit?.confirmableAtMs — earliest mint time = approval + bitcoinDepositTimeDelayMs +// (null until approved) + +const withdrawal = await client.hashi.view.withdrawalStatus(digest); +// withdrawal?.status: "Requested" | "Approved" | "Processing" +// | "Signed" | "Confirmed" | "cancelled" +``` + +To block until the committee finishes, use the polling helpers. They resolve on a terminal state — +deposits on `confirmed`/`expired`, withdrawals on `Confirmed`/`cancelled`: + +```ts +const info = await client.hashi.waitForDeposit(digest, { + intervalMs: 15_000, // default + signal: AbortSignal.timeout(600_000), // optional cancellation +}); + +const wInfo = await client.hashi.waitForWithdrawal(digest); +// wInfo.btcTxid is populated once the committee commits the Bitcoin tx. +``` + +## Checking hBTC balance + +```ts +const { totalBalance, coinObjectCount } = await client.hashi.view.balance(signer.toSuiAddress()); +// totalBalance — hBTC in satoshis; coinObjectCount — number of coin objects held. +``` + +## Transaction history + +`view.transactionHistory` returns a unified, newest-first list of deposits and withdrawals for a Sui +address. Each item is a discriminated union — switch on `kind`: + +```ts +const history = await client.hashi.view.transactionHistory(signer.toSuiAddress()); + +for (const item of history) { + if (item.kind === 'deposit') { + console.log(item.btcTxid, item.amountSats, item.approved); + } else { + console.log(item.requestId, item.btcAmountSats, item.status); + } +} +``` + +Confirmed requests come from the on-chain index; in-flight deposits are discovered via the Sui +GraphQL endpoint (`graphqlUrl`). If GraphQL is unavailable the call still returns the confirmed set. + +## Detecting already-used UTXOs + +Before submitting a deposit, check whether its outputs were already recorded — re-submitting a +consumed UTXO aborts on-chain. + +```ts +const results = await client.hashi.view.findUsedUtxos([ + { txid: '0x<64-hex display-order txid>', vout: 0 }, +]); +// results[0]: { utxoId, inActivePool, inSpentPool, isUsed } +``` + +## Estimating fees + +```ts +// Withdrawal: worst-case BTC network fee + on-chain minimum. Pass a sender +// to also get a dry-run gas estimate. +const fees = await client.hashi.view.withdrawalFees(signer.toSuiAddress()); +// { worstCaseNetworkFeeSats, withdrawalMinimumSats, gasEstimateMist } + +// Deposit: dry-run gas estimate only. +const { gasEstimateMist } = await client.hashi.view.depositGasEstimate(signer.toSuiAddress()); +``` + +Gas estimation is best-effort — a failed simulation yields `0n` rather than throwing. + +## Reading governance state + +Governance parameters — the pause flag, deposit/withdrawal minimums, confirmation threshold, deposit +time delay, and cancellation cooldown — are read through `client.hashi.view`, the same namespace as +the balance, status, history, and fee readers above. Prefer `view.all()` when you need 2+ values — +single round-trip, internally consistent snapshot. + +```ts +const snap = await client.hashi.view.all(); +// { paused, bitcoinDepositMinimum, bitcoinWithdrawalMinimum, +// bitcoinConfirmationThreshold, bitcoinDepositTimeDelayMs, +// withdrawalCancellationCooldownMs, worstCaseNetworkFee, ... } +``` + +## Errors + +Direct methods throw typed errors before signing whenever a precondition can be checked client-side. +`instanceof` to distinguish: + +| Error | Thrown when | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `InvalidParamsError` | `txid`/`recipient` not 0x-prefixed 32-byte hex, or `utxos` empty/duplicate | +| `InvalidBitcoinAddressError` | `bitcoinAddress` fails bech32(m) decode or HRP mismatches the BTC network | +| `HashiPausedError` | Governance has paused the operation (`deposit` or `withdraw`) | +| `AmountBelowMinimumError` | A UTXO or withdrawal amount is below the on-chain minimum | +| `HashiFetchError` | The Hashi shared object can't be read or has an unexpected shape | +| `HashiConfigError` | A governance config entry is missing or malformed | +| `HashiGuardianError` | The guardian `/info` can't be resolved, reached, or parsed, or the limiter isn't initialized (see `.code`) | + +## Advanced: composable transactions + +The direct methods (`deposit`, `requestWithdrawal`, `cancelWithdrawal`) sign and execute in one +call. For sponsored transactions, dry-runs, or bundling Hashi calls into a larger PTB, use the +`tx.*` builders — they return an unsigned `Transaction` and leave signing to the caller. + +```ts +const tx = client.hashi.tx.deposit({ txid, utxos, recipient }); +// …add more commands to `tx`, then sign and execute via your usual path. +``` + +Move-call thunks are also available under `client.hashi.call.*` for direct composition into +hand-built PTBs. + +## Bitcoin RPC (optional) + +When the client is constructed with a `btcRpcUrl`, the `client.hashi.bitcoin.*` namespace reads the +Bitcoin chain directly — useful for finding which output of a funding tx paid your deposit address, +and for checking confirmations before submitting a deposit. + +```ts +const client = new SuiGrpcClient({ + network: 'testnet', + baseUrl: 'https://fullnode.testnet.sui.io:443', +}).$extend(hashi({ network: 'testnet', btcRpcUrl: 'http://user:pass@127.0.0.1:8332' })); + +// Which output(s) of the funding tx paid the deposit address? +const output = await client.hashi.bitcoin.lookupVout(btcTxid, btcAddress); +const outputs = await client.hashi.bitcoin.lookupAllVouts(btcTxid, btcAddress); +// each result: { vout, amountSats } + +// Confirmation count — 0 while the tx is still in the mempool. +const confirmations = await client.hashi.bitcoin.confirmations(btcTxid); +``` + +Calling any `bitcoin.*` method without `btcRpcUrl` configured throws. + +## Guardian rate limiter (optional) + +Withdrawals are co-signed by the Hashi **guardian**, which throttles signing with a token-bucket +rate limiter. When the client can resolve a guardian URL — from `guardianUrl`, a custom +`guardianInfoProvider`, or the on-chain `guardian_url` config — the `client.hashi.guardian.*` +namespace reads the guardian's public, read-only `/info` endpoint to surface that limiter's +headroom. + +```ts +const client = new SuiGrpcClient({ + network: 'devnet', + baseUrl: 'https://fullnode.devnet.sui.io:443', +}).$extend( + hashi({ network: 'devnet', guardianUrl: 'https://hashi-guardian-devnet.mystenlabs.com' }), +); + +// Guardian identity + limiter. `limiter` is null before the guardian is provisioned. +const info = await client.hashi.guardian.info(); + +// Projected capacity now, bucket fill %, and the refill-to-full ETA. +const status = await client.hashi.guardian.limiterStatus(); +// { availableNowSats, bucketFillPercent, fullAtSecs, state, config } + +// Can the guardian sign a 50,000-sat withdrawal right now? +const check = await client.hashi.guardian.canWithdraw(50_000n); +// { allowed, availableNowSats, estimatedWaitSecs } +``` + +`guardianUrl` overrides the on-chain `guardian_url`; a `guardianInfoProvider` overrides both (useful +for caching or a custom transport). The on-chain `guardian_url` is only published at launch, so a +client constructed beforehand re-reads the chain on each call until it resolves — throwing +`HashiGuardianError` (`code: "not-configured"`) meanwhile — then caches the URL once found. +`limiterStatus()` and `canWithdraw()` throw `HashiGuardianError` (`code: "not-initialized"`) before +the guardian is provisioned — use `guardian.info()`, whose `limiter` is `null`, to detect that state +without a try/catch. + +## Bitcoin address derivation + +Each Sui address maps to a unique P2TR Bitcoin deposit address with two script-path leaves: an +immediate 2-of-2 leaf `multi_a(2, guardian, derive(mpc_master, sui_address))`, and a delayed +MPC-only recovery leaf `and_v(v:older(delay), pk(derive(mpc_master, sui_address)))`. The MPC +child-key derivation replicates +`fastcrypto_tbls::threshold_schnorr::key_derivation::derive_verifying_key`; the guardian's BTC key +is read from the on-chain `guardian_btc_public_key` config, and `generateDepositAddress` throws +`HashiConfigError` until the deployment publishes it. See the +[Hashi address-scheme docs](https://mystenlabs.github.io/hashi/design/address-scheme.html) for the +full design. diff --git a/packages/hashi/package.json b/packages/hashi/package.json new file mode 100644 index 000000000..36aa40fdb --- /dev/null +++ b/packages/hashi/package.json @@ -0,0 +1,68 @@ +{ + "name": "@mysten/hashi", + "version": "0.5.0", + "private": false, + "description": "TypeScript SDK for the Hashi Sui Move smart contracts", + "license": "Apache-2.0", + "author": "Mysten Labs ", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "sideEffects": false, + "files": [ + "CHANGELOG.md", + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/MystenLabs/ts-sdks.git" + }, + "bugs": { + "url": "https://github.com/MystenLabs/ts-sdks/issues/new" + }, + "keywords": [ + "sui", + "hashi", + "bitcoin", + "btc", + "sdk", + "mysten" + ], + "scripts": { + "clean": "rm -rf tsconfig.tsbuildinfo ./dist", + "build": "rm -rf dist && tsc --noEmit && tsdown", + "codegen": "sui-ts-codegen generate && pnpm lint:fix", + "prettier:check": "prettier -c --ignore-unknown .", + "prettier:fix": "prettier -w --ignore-unknown .", + "oxlint:check": "oxlint .", + "oxlint:fix": "oxlint --fix", + "lint": "pnpm run oxlint:check && pnpm run prettier:check", + "lint:fix": "pnpm run oxlint:fix && pnpm run prettier:fix", + "test": "pnpm test:typecheck && pnpm test:unit", + "test:typecheck": "tsc -p ./test", + "test:unit": "vitest run --project=unit", + "test:integration": "vitest run --project=integration", + "vitest": "vitest" + }, + "dependencies": { + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@scure/base": "^2.2.0" + }, + "devDependencies": { + "@mysten/codegen": "workspace:^", + "@types/node": "^25.9.3", + "typescript": "^6.0.3", + "vitest": "^4.1.8" + }, + "peerDependencies": { + "@mysten/sui": "workspace:^" + } +} diff --git a/packages/hashi/src/bitcoin.ts b/packages/hashi/src/bitcoin.ts new file mode 100644 index 000000000..72df03ec3 --- /dev/null +++ b/packages/hashi/src/bitcoin.ts @@ -0,0 +1,548 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Bitcoin address derivation for Hashi deposit addresses. + * + * Hashi bridges Bitcoin and Sui by assigning each Sui address a unique Bitcoin + * deposit address. When BTC is sent to that address, the Hashi MPC committee + * and the Hashi guardian co-sign the withdrawal that spends it, and the bridge + * mints equivalent tokens on Sui. + * + * The deposit address is a Pay-to-Taproot (P2TR / BIP-341) script-path address + * with two leaves: an immediate `multi_a(2, guardian_btc_pubkey, + * derive(mpc_master, sui_address))` spend, and a delayed MPC-only recovery + * spend after Hashi's BIP-68 relative timelock. + * + * The full derivation pipeline is: + * + * 1. **Fetch** the MPC master key from on-chain (`CommitteeSet.mpc_public_key`) + * and the guardian's BTC public key from the on-chain config + * (`guardian_btc_public_key`). The MPC bytes use the arkworks compressed + * format and must first be converted to SEC1 via + * {@link arkworksToSec1Compressed} — done automatically by the client's + * `view.mpcPublicKey()`. The guardian key is already in BIP-340 x-only form. + * + * 2. **Derive** a child MPC key: `child = masterKey + HKDF-SHA3-256(x ‖ suiAddr) × G` + * (see {@link deriveChildPubkey}). This replicates the Rust function + * `fastcrypto_tbls::threshold_schnorr::key_derivation::derive_verifying_key`. + * + * 3. **Build** the taproot address: + * `tr(NUMS, {multi_a(2, guardian, child), and_v(v:older(delay), pk(child))})` + * where NUMS is a Nothing-Up-My-Sleeve point with no known private key, + * forcing all spends through the script path (see + * {@link twoOfTwoTaprootScriptPathAddress}). + * + * The end-to-end helper {@link generateDepositAddress} combines steps 2–3. + * + * Mirrors `taproot_address` in `crates/hashi-types/src/bitcoin/taproot.rs`. + * Cross-language test vectors live in both this file's unit tests and the + * matching Rust unit test `cross_lang_2of2_test_vectors`. + * + * @see https://mystenlabs.github.io/hashi/design/address-scheme.html + */ + +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { sha3_256 } from '@noble/hashes/sha3.js'; +import { hkdf } from '@noble/hashes/hkdf.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { concatBytes } from '@noble/hashes/utils.js'; +import { bech32, bech32m } from '@scure/base'; +import { NETWORK_HRP, NUMS_KEY } from './constants.js'; +import { InvalidBitcoinAddressError } from './errors.js'; + +import type { BitcoinNetwork } from './types.js'; + +const Point = secp256k1.Point; +const CURVE_ORDER = Point.CURVE().n; + +const NUMS_POINT = Point.fromBytes(concatBytes(new Uint8Array([0x02]), NUMS_KEY)); +const HASHI_MPC_RECOVERY_DELAY_SECONDS = 60 * 24 * 60 * 60; +const HASHI_MPC_RECOVERY_DELAY_SEQUENCE = + (1 << 22) | Math.ceil(HASHI_MPC_RECOVERY_DELAY_SECONDS / 512); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** BIP-340 tagged hash: SHA256(SHA256(tag) ‖ SHA256(tag) ‖ msg) */ +function taggedHash(tag: string, ...msgs: Uint8Array[]): Uint8Array { + const tagHash = sha256(new TextEncoder().encode(tag)); + return sha256(concatBytes(tagHash, tagHash, ...msgs)); +} + +/** Interpret a byte array as a big-endian unsigned integer. */ +function bytesToNumberBE(bytes: Uint8Array): bigint { + let n = 0n; + for (const byte of bytes) { + n = (n << 8n) | BigInt(byte); + } + return n; +} + +/** CompactSize for scripts small enough to fit in one byte. */ +function compactSize(len: number): Uint8Array { + if (len >= 253) { + throw new Error(`Unsupported script length ${len}`); + } + return new Uint8Array([len]); +} + +function scriptNum(n: number): Uint8Array { + if (n === 0) return new Uint8Array(); + + const bytes: number[] = []; + let value = n; + while (value > 0) { + bytes.push(value & 0xff); + value >>= 8; + } + if ((bytes[bytes.length - 1] & 0x80) !== 0) { + bytes.push(0); + } + return new Uint8Array(bytes); +} + +function pushBytes(bytes: Uint8Array): Uint8Array { + if (bytes.length >= 0x4c) { + throw new Error(`Unsupported push length ${bytes.length}`); + } + return concatBytes(new Uint8Array([bytes.length]), bytes); +} + +function lexicographicCompare(a: Uint8Array, b: Uint8Array): number { + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return a.length - b.length; +} + +function tapLeafHash(script: Uint8Array): Uint8Array { + return taggedHash('TapLeaf', new Uint8Array([0xc0]), compactSize(script.length), script); +} + +function tapBranchHash(a: Uint8Array, b: Uint8Array): Uint8Array { + return lexicographicCompare(a, b) <= 0 + ? taggedHash('TapBranch', a, b) + : taggedHash('TapBranch', b, a); +} + +// --------------------------------------------------------------------------- +// Format conversion +// --------------------------------------------------------------------------- + +/** + * Converts a 33-byte arkworks-compressed secp256k1 point to 33-byte SEC1 compressed format. + * + * The on-chain `CommitteeSet.mpc_public_key` is serialised with `ark-serialize` + * (via `bcs::to_bytes` in the Rust node), which uses a different compressed + * layout than the SEC1/X9.62 standard that `@noble/curves` expects: + * + * | Property | ark-serialize | SEC1 (noble) | + * |----------------|----------------------------|--------------------------| + * | Byte order | **little-endian** x | **big-endian** x | + * | Y-parity | flag in **last** byte | prefix **first** byte | + * | Parity meaning | "negative" (y > (p-1)/2) | even / odd (y mod 2) | + * + * Because the parity conventions differ, we cannot simply remap the flag bit — + * we must lift the x-coordinate onto the curve to recover y, then check its + * parity in both systems. + * + * @param ark - 33-byte arkworks-compressed point + * (bytes [0..32] = x in little-endian, byte [32] = flags with bit 7 = y_is_negative) + * @returns 33-byte SEC1 compressed point (prefix 0x02 | 0x03, then x in big-endian) + */ +export function arkworksToSec1Compressed(ark: Uint8Array): Uint8Array { + if (ark.length !== 33) { + throw new Error(`Expected 33-byte arkworks-compressed key, got ${ark.length}`); + } + + const flags = ark[32]; + const yIsNegative = (flags >> 7) & 1; // bit 7: y > (p-1)/2 in arkworks + + // x-coordinate: first 32 bytes in LE → reverse to BE. + const xBe = new Uint8Array(ark.slice(0, 32)).reverse(); + + // Lift x onto the curve with a trial SEC1 prefix (0x02 = even y). + const trial = new Uint8Array(33); + trial[0] = 0x02; + trial.set(xBe, 1); + + const trialPoint = Point.fromBytes(trial); + const y = trialPoint.toAffine().y; + + // arkworks "negative" = y > (p-1)/2. Determine whether the trial y satisfies that. + const p = Point.CURVE().p; + const trialIsNeg = y > (p - 1n) / 2n; + + // If the trial parity doesn't match the arkworks flag, flip the prefix. + const prefix = (yIsNegative === 1) !== trialIsNeg ? 0x03 : 0x02; + + const sec1 = new Uint8Array(33); + sec1[0] = prefix; + sec1.set(xBe, 1); + return sec1; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Derives a child x-only public key from the MPC master key and a Sui address. + * + * This is the core key-derivation step that gives each Sui address its own + * unique Bitcoin public key. The MPC committee can sign for this child key + * (using threshold Schnorr with additive tweaking), which is what authorises + * a withdrawal transaction on the Bitcoin side. + * + * Replicates the Rust function + * `fastcrypto_tbls::threshold_schnorr::key_derivation::derive_verifying_key`: + * + * ```text + * tweak = HKDF-SHA3-256(ikm = parent_x ‖ sui_address, len = 64) mod n + * child = parent + tweak × G + * ``` + * + * The tweak is derived deterministically from the master key's x-coordinate + * concatenated with the depositor's Sui address, using HKDF with SHA3-256 as + * the underlying hash. The 64-byte output is reduced mod n (the secp256k1 + * group order) to produce a scalar, which is then used as an additive tweak + * on the master public key. + * + * The returned value is the 32-byte x-only form of the child key (the + * x-coordinate only, without a parity prefix). This is the format expected + * by BIP-340 Schnorr signatures and BIP-341 taproot constructions. + * + * @param mpcKeyCompressed - 33-byte SEC1 compressed secp256k1 public key + * (the MPC master key, after arkworks-to-SEC1 conversion) + * @param suiAddress - 32-byte Sui address used as the derivation path + * @returns 32-byte x-only public key of the derived child + */ +export function deriveChildPubkey( + mpcKeyCompressed: Uint8Array, + suiAddress: Uint8Array, +): Uint8Array { + if (mpcKeyCompressed.length !== 33) { + throw new Error(`Expected 33-byte compressed MPC key, got ${mpcKeyCompressed.length}`); + } + if (suiAddress.length !== 32) { + throw new Error(`Expected 32-byte Sui address, got ${suiAddress.length}`); + } + + // Parse the compressed key, preserving y-parity from the prefix byte. + const parentPoint = Point.fromBytes(mpcKeyCompressed); + + // x-coordinate is bytes [1..33] of the compressed representation. + const xBytes = mpcKeyCompressed.slice(1); + + // HKDF-SHA3-256(ikm = x ‖ address, salt = ∅, info = ∅, len = 64) + const ikm = concatBytes(xBytes, suiAddress); + const tweakBytes = hkdf(sha3_256, ikm, undefined, undefined, 64); + + // Reduce the 64-byte big-endian integer mod the secp256k1 group order. + const tweakScalar = bytesToNumberBE(tweakBytes) % CURVE_ORDER; + + // child = parent + tweak × G + const childPoint = parentPoint.add(Point.BASE.multiply(tweakScalar)); + + // Return the x-coordinate (32 bytes). The x value is the same regardless + // of whether the child point has even or odd y. + return childPoint.toBytes(true).slice(1); +} + +/** + * Builds Hashi's P2TR script-path-only deposit address: + * `tr(NUMS, {multi_a(2, guardian, derived_mpc), and_v(v:older(delay), pk(derived_mpc))})`. + * + * The leaf script is a BIP-342 `multi_a` 2-of-2 — both Schnorr signatures must + * be present to spend, ordered so that the witness stack is + * `[derived_mpc_sig, guardian_sig, leaf_script, control_block]` (LIFO). This + * is the exact immediate-spend script the bridge's withdrawal path constructs + * in `crates/hashi-types/src/bitcoin/taproot.rs`'s `compute_taproot_descriptor`. + * + * The leaf script is exactly 70 bytes: + * + * ```text + * 0x20 ‖ guardian (32) // OP_PUSHBYTES_32 + * 0xAC // OP_CHECKSIG + * 0x20 ‖ derived_mpc(32) // OP_PUSHBYTES_32 + * 0xBA // OP_CHECKSIGADD + * 0x52 // OP_2 (push small int 2) + * 0x9C // OP_NUMEQUAL + * ``` + * + * Note `0x52` (OP_2) — not `0x01 0x02` — because `rust-miniscript`'s `multi_a` + * codegen routes through `Builder::push_int(2)` which emits the small-num + * opcode. A literal pushdata would change the leaf hash and the address. + * + * The recovery leaf script is a BIP-342 `and_v(v:older(delay), pk(derived_mpc))`: + * after Hashi's 60-day BIP-68 relative timelock, the MPC child key alone can + * spend the output if the guardian key is unavailable. + * + * The taproot output key is computed per BIP-341: + * + * ```text + * leaf1 = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(two_of_two_script) ‖ two_of_two_script) + * leaf2 = tagged_hash("TapLeaf", 0xC0 ‖ compact_size(recovery_script) ‖ recovery_script) + * root = tagged_hash("TapBranch", min(leaf1, leaf2) ‖ max(leaf1, leaf2)) + * tweak = tagged_hash("TapTweak", NUMS ‖ root) + * outputKey = NUMS + tweak × G + * ``` + * + * **Argument ordering is load-bearing.** Guardian first, derived-MPC second. + * Swapping them produces a different (but real-looking) `tb1p…` whose + * withdrawals the bridge cannot spend. + * + * @param guardianBtcXOnly - 32-byte BIP-340 x-only guardian BTC public key + * (from the on-chain `guardian_btc_public_key` config) + * @param derivedMpcXOnly - 32-byte x-only public key for the MPC-derived child + * (output of {@link deriveChildPubkey}) + * @param network - Bitcoin network for the bech32m human-readable prefix + * @returns bech32m-encoded P2TR address (e.g. `bc1p…`, `tb1p…`, `bcrt1p…`) + */ +export function twoOfTwoTaprootScriptPathAddress( + guardianBtcXOnly: Uint8Array, + derivedMpcXOnly: Uint8Array, + network: BitcoinNetwork, +): string { + if (guardianBtcXOnly.length !== 32) { + throw new Error(`Expected 32-byte x-only guardian pubkey, got ${guardianBtcXOnly.length}`); + } + if (derivedMpcXOnly.length !== 32) { + throw new Error(`Expected 32-byte x-only derived MPC pubkey, got ${derivedMpcXOnly.length}`); + } + + // Tapscript: + // OP_PUSHBYTES_32 OP_CHECKSIG + // OP_PUSHBYTES_32 OP_CHECKSIGADD + // OP_2 OP_NUMEQUAL + const twoOfTwoScript = new Uint8Array(70); + twoOfTwoScript[0] = 0x20; // OP_PUSHBYTES_32 + twoOfTwoScript.set(guardianBtcXOnly, 1); + twoOfTwoScript[33] = 0xac; // OP_CHECKSIG + twoOfTwoScript[34] = 0x20; // OP_PUSHBYTES_32 + twoOfTwoScript.set(derivedMpcXOnly, 35); + twoOfTwoScript[67] = 0xba; // OP_CHECKSIGADD + twoOfTwoScript[68] = 0x52; // OP_2 + twoOfTwoScript[69] = 0x9c; // OP_NUMEQUAL + + // Tapscript for `and_v(v:older(delay), pk(derived_mpc))`: + // OP_CHECKSEQUENCEVERIFY OP_VERIFY OP_CHECKSIG + const recoveryScript = concatBytes( + pushBytes(scriptNum(HASHI_MPC_RECOVERY_DELAY_SEQUENCE)), + new Uint8Array([0xb2, 0x69, 0x20]), // OP_CHECKSEQUENCEVERIFY OP_VERIFY OP_PUSHBYTES_32 + derivedMpcXOnly, + new Uint8Array([0xac]), // OP_CHECKSIG + ); + + const merkleRoot = tapBranchHash(tapLeafHash(twoOfTwoScript), tapLeafHash(recoveryScript)); + + // Tweak (BIP-341): tagged_hash("TapTweak", internal_key ‖ merkle_root). + const tweak = taggedHash('TapTweak', NUMS_KEY, merkleRoot); + const tweakScalar = bytesToNumberBE(tweak) % CURVE_ORDER; + + // Output key = NUMS + tweak × G + const outputPoint = NUMS_POINT.add(Point.BASE.multiply(tweakScalar)); + const outputKey = outputPoint.toBytes(true).slice(1); // 32-byte x-only + + const words = [1, ...bech32m.toWords(outputKey)]; + return bech32m.encode(NETWORK_HRP[network], words); +} + +/** + * Named-args input bundle for {@link generateDepositAddress}. Using named args + * avoids the foot-gun of two 32-byte `Uint8Array`s in adjacent positions — + * swapping `guardianBtcXOnly` and `suiAddress` would silently produce a valid + * but wrong address. + */ +export interface DepositAddressInputs { + /** + * 33-byte SEC1-compressed secp256k1 MPC master key (post-arkworks + * conversion). See {@link arkworksToSec1Compressed}. + */ + readonly mpcMasterCompressed: Uint8Array; + /** + * 32-byte BIP-340 x-only guardian BTC public key (from the on-chain + * `guardian_btc_public_key` config). + */ + readonly guardianBtcXOnly: Uint8Array; + /** 32-byte Sui address used as the derivation path. */ + readonly suiAddress: Uint8Array; + /** Bitcoin network (determines the bech32m address prefix). */ + readonly network: BitcoinNetwork; +} + +/** + * Generates a Bitcoin P2TR deposit address for a Sui address. + * + * Main entry point for the address derivation pipeline. Combines + * {@link deriveChildPubkey} and {@link twoOfTwoTaprootScriptPathAddress} into + * a single call. The produced address matches the Rust node's + * `hashi_types::bitcoin::taproot::taproot_address` byte-for-byte. + * + * The address scheme is: + * ```text + * tr(NUMS, {multi_a(2, guardian, derive(mpc_master, sui_address)), + * and_v(v:older(delay), pk(derive(mpc_master, sui_address)))}) + * ``` + * + * @returns bech32m-encoded P2TR deposit address (e.g. `tb1p…` for signet) + */ +export function generateDepositAddress({ + mpcMasterCompressed, + guardianBtcXOnly, + suiAddress, + network, +}: DepositAddressInputs): string { + const childXOnly = deriveChildPubkey(mpcMasterCompressed, suiAddress); + return twoOfTwoTaprootScriptPathAddress(guardianBtcXOnly, childXOnly, network); +} + +// --------------------------------------------------------------------------- +// Withdrawal address decoding +// --------------------------------------------------------------------------- + +/** + * Decodes a bech32/bech32m SegWit Bitcoin address into a witness program. + * + * Hashi withdrawals send BTC to a witness-program output, so the SDK only + * accepts the two address types the MPC committee currently supports: + * + * - **P2WPKH** — witness version 0, 20-byte program (`bc1q…`, `tb1q…`) + * - **P2TR** — witness version 1, 32-byte program (`bc1p…`, `tb1p…`) + * + * Legacy base58 addresses (`1…`, `3…`) aren't bech32 at all and surface as + * `"malformed"`. Version-0 32-byte P2WSH is rejected (no committee support). + * + * Per BIP-350, v0 must use a bech32 checksum and v1+ must use bech32m. This + * function enforces that rule strictly — a v0 address encoded as bech32m + * (or vice versa) fails with `"bad-checksum"`. + * + * @param address - User-supplied Bitcoin address string + * @param network - Expected Bitcoin network; the HRP must match + * @returns `{ version, program }` — witness version + raw program bytes + * @throws {@link InvalidBitcoinAddressError} with a structured `code` on any failure + */ +export function bitcoinAddressToWitnessProgram( + address: string, + network: BitcoinNetwork, +): { version: number; program: Uint8Array } { + const expectedHrp = NETWORK_HRP[network]; + + // Try both checksum variants and record which one validated. We defer the + // BIP-350 version ↔ variant enforcement until after we know the version, + // so we can emit a targeted `"bad-checksum"` instead of a generic parse + // failure when the user encoded with the wrong variant. + let decoded: { prefix: string; words: number[] } | undefined; + let variant: 'bech32' | 'bech32m' | undefined; + try { + decoded = bech32.decode(address as `${string}1${string}`); + variant = 'bech32'; + } catch { + // fall through to bech32m + } + if (!decoded) { + try { + decoded = bech32m.decode(address as `${string}1${string}`); + variant = 'bech32m'; + } catch (cause) { + throw new InvalidBitcoinAddressError( + { + address, + code: 'malformed', + message: `Bitcoin address "${address}" is not valid bech32 or bech32m.`, + }, + { cause }, + ); + } + } + + if (decoded.words.length === 0) { + throw new InvalidBitcoinAddressError({ + address, + code: 'malformed', + message: `Bitcoin address "${address}" has no data payload.`, + }); + } + + const version = decoded.words[0]; + + // BIP-350: witness v0 → bech32, v1+ → bech32m. Cross-variant encodings + // are malformed per spec even if the bits decode cleanly. + const expectedVariant = version === 0 ? 'bech32' : 'bech32m'; + if (variant !== expectedVariant) { + throw new InvalidBitcoinAddressError({ + address, + code: 'bad-checksum', + message: + `Bitcoin address "${address}" has witness version ${version} but a ` + + `${variant} checksum; BIP-350 requires ${expectedVariant} for this version.`, + }); + } + + if (decoded.prefix !== expectedHrp) { + throw new InvalidBitcoinAddressError({ + address, + code: 'wrong-network', + message: + `Bitcoin address "${address}" uses HRP "${decoded.prefix}" but the client ` + + `is configured for ${network} (expected "${expectedHrp}").`, + }); + } + + if (version !== 0 && version !== 1) { + throw new InvalidBitcoinAddressError({ + address, + code: 'unsupported-version', + message: + `Bitcoin address "${address}" has witness version ${version}; ` + + `Hashi supports only v0 (P2WPKH) and v1 (P2TR).`, + }); + } + + const program = bech32.fromWords(decoded.words.slice(1)); + + const expectedLen = version === 0 ? 20 : 32; + if (program.length !== expectedLen) { + throw new InvalidBitcoinAddressError({ + address, + code: 'bad-program-length', + message: + `Bitcoin address "${address}" has a ${program.length}-byte witness program; ` + + `v${version} (${version === 0 ? 'P2WPKH' : 'P2TR'}) requires ${expectedLen} bytes.`, + }); + } + + return { version, program }; +} + +/** + * Encodes a witness program back into a bech32/bech32m Bitcoin address. + * + * Inverse of {@link bitcoinAddressToWitnessProgram}. Useful for displaying + * the Bitcoin address associated with a withdrawal request whose on-chain + * state stores only the raw witness program bytes. + * + * @param program - Raw witness program bytes (20 for P2WPKH, 32 for P2TR) + * @param network - Bitcoin network for the HRP + * @returns Encoded bech32 (v0) or bech32m (v1+) address + */ +export function witnessProgramToAddress(program: Uint8Array, network: BitcoinNetwork): string { + const hrp = NETWORK_HRP[network]; + + if (program.length === 20) { + const words = [0, ...bech32.toWords(program)]; + return bech32.encode(hrp, words); + } + + if (program.length === 32) { + const words = [1, ...bech32m.toWords(program)]; + return bech32m.encode(hrp, words); + } + + throw new Error( + `Unsupported witness program length ${program.length}; expected 20 (P2WPKH) or 32 (P2TR).`, + ); +} diff --git a/packages/hashi/src/btc-rpc.ts b/packages/hashi/src/btc-rpc.ts new file mode 100644 index 000000000..ef6025909 --- /dev/null +++ b/packages/hashi/src/btc-rpc.ts @@ -0,0 +1,65 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { UtxoLookupResult } from './types.js'; + +async function rpcCall( + url: string, + method: string, + params: unknown[], + id: string, +): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '1.0', id, method, params }), + }); + const data = (await res.json()) as { error?: { message: string }; result?: unknown }; + if (data.error) throw new Error(data.error.message); + return data.result; +} + +export async function lookupVout( + btcRpcUrl: string, + txid: string, + depositAddress: string, +): Promise { + const tx = (await rpcCall(btcRpcUrl, 'getrawtransaction', [txid, true], 'hashi-lookup-vout')) as { + vout: Array<{ n: number; value: number; scriptPubKey?: { address?: string } }>; + }; + for (const output of tx.vout) { + if (output.scriptPubKey?.address === depositAddress) { + return { vout: output.n, amountSats: BigInt(Math.round(output.value * 1e8)) }; + } + } + return null; +} + +export async function lookupAllVouts( + btcRpcUrl: string, + txid: string, + depositAddress: string, +): Promise { + const tx = (await rpcCall(btcRpcUrl, 'getrawtransaction', [txid, true], 'hashi-lookup-all')) as { + vout: Array<{ n: number; value: number; scriptPubKey?: { address?: string } }>; + }; + const matches: UtxoLookupResult[] = []; + for (const output of tx.vout) { + if (output.scriptPubKey?.address === depositAddress) { + matches.push({ vout: output.n, amountSats: BigInt(Math.round(output.value * 1e8)) }); + } + } + return matches; +} + +export async function getTxConfirmations(btcRpcUrl: string, txid: string): Promise { + const tx = (await rpcCall( + btcRpcUrl, + 'getrawtransaction', + [txid, true], + 'hashi-confirmations', + )) as { + confirmations?: number; + }; + return Number(tx?.confirmations ?? 0); +} diff --git a/packages/hashi/src/client.ts b/packages/hashi/src/client.ts new file mode 100644 index 000000000..6871ab3f2 --- /dev/null +++ b/packages/hashi/src/client.ts @@ -0,0 +1,1635 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; +import type { Signer } from '@mysten/sui/cryptography'; +import { bcs, TypeTagSerializer } from '@mysten/sui/bcs'; +import { fromHex, deriveDynamicFieldID, normalizeSuiAddress } from '@mysten/sui/utils'; +import { base58 } from '@scure/base'; +import { Transaction, coinWithBalance } from '@mysten/sui/transactions'; +import { Hashi } from './contracts/hashi/hashi.js'; +import { BitcoinState, BitcoinStateKey } from './contracts/hashi/bitcoin_state.js'; +import { DepositRequest } from './contracts/hashi/deposit_queue.js'; +import { Bag } from './contracts/hashi/deps/sui/bag.js'; +import { WithdrawalRequest, WithdrawalTransaction } from './contracts/hashi/withdrawal_queue.js'; +import { UtxoId as UtxoIdBcs } from './contracts/hashi/utxo.js'; +import * as depositModule from './contracts/hashi/deposit.js'; +import * as withdrawModule from './contracts/hashi/withdraw.js'; +import * as utxoModule from './contracts/hashi/utxo.js'; +import type { RawTransactionArgument } from './contracts/utils/index.js'; +import { + generateDepositAddress as generateDepositAddressRaw, + arkworksToSec1Compressed, + bitcoinAddressToWitnessProgram, +} from './bitcoin.js'; +import { + DUST_RELAY_MIN_VALUE, + GUARDIAN_BTC_PUBLIC_KEY_LEN, + GUARDIAN_PUBLIC_KEY_LEN, + NETWORK_CONFIG, +} from './constants.js'; +import type { AmountViolation } from './errors.js'; +import { + AmountBelowMinimumError, + HashiConfigError, + HashiFetchError, + HashiGuardianError, + HashiPausedError, + InvalidParamsError, +} from './errors.js'; +import type { + BitcoinNetwork, + CancelWithdrawalParams, + DepositFees, + DepositHistoryItem, + DepositInfo, + DepositParams, + DepositStatus, + GovernanceConfig, + GuardianInfoProvider, + GuardianLimiterRaw, + GuardianLimiterSnapshot, + GuardianWithdrawCheck, + HashiClientOptions, + HbtcBalance, + RawGuardianInfo, + SuiNetwork, + TransactionHistoryItem, + UtxoId, + UtxoLookupResult, + UtxoUsageResult, + WaitOptions, + WithdrawalFees, + WithdrawalHistoryItem, + WithdrawalInfo, + WithdrawalParams, + WithdrawalStatus, +} from './types.js'; +import { lookupVout, lookupAllVouts, getTxConfirmations } from './btc-rpc.js'; +import { projectCapacity, estimateWaitSecs, fetchGuardianInfo } from './guardian.js'; +import { assertHex32, configBytes, entry, reverseTxidBytes, type ConfigEntry } from './util.js'; + +/** ObjectBag dynamic field name type (Wrapper
for dynamic_object_field lookups). */ +const OBJECT_BAG_ADDRESS_TYPE = + '0x0000000000000000000000000000000000000000000000000000000000000002::dynamic_object_field::Wrapper
'; + +/** Max value of an unsigned 32-bit integer; vout is a u32 on the Bitcoin side. */ +const U32_MAX = 0xffffffff; + +/** Max objects per `getObjects` call. */ +const GET_OBJECTS_BATCH = 500; + +const GRAPHQL_URLS: Record = { + devnet: 'https://fullnode.devnet.sui.io:443/graphql', + testnet: 'https://fullnode.testnet.sui.io:443/graphql', + mainnet: 'https://fullnode.mainnet.sui.io:443/graphql', + localnet: 'http://127.0.0.1:9000/graphql', +}; + +function defaultGraphqlUrl(network: string): string { + return GRAPHQL_URLS[network] ?? GRAPHQL_URLS['mainnet']!; +} + +/** + * Recognize the per-object errors that mean "the object (or dynamic field) + * genuinely does not exist" — the only Error shape that `findUsedUtxos` is + * allowed to treat as a pool miss. Anything else (deleted, displayError, + * unknown) must propagate so other per-object failures can't be silently + * downgraded to "not used." + * + * Two transports, two shapes: + * - JSON-RPC returns a typed `ObjectError` whose `.code` is `notExists` or + * `dynamicFieldNotFound`. `ObjectError` is not re-exported from + * `@mysten/sui/client`, so the guard duck-types the field. + * - gRPC stringifies the per-object error into `new Error(message)` with no + * code (see the `TODO: improve error handling` in `@mysten/sui/grpc/core.ts`). + * For now the only signal is the message, which the Sui ledger service + * returns as exactly `Object not found` for missing objects. + * + * Transport-level failures don't show up here — they reject the whole + * `getObjects` promise rather than appearing in the result array. + */ +const GRPC_NOT_FOUND_MESSAGE_RE = /^Object 0x[0-9a-f]+ not found$/i; + +function isObjectNotFoundError(err: Error): boolean { + const code = (err as Error & { code?: unknown }).code; + if (code === 'notExists' || code === 'dynamicFieldNotFound') return true; + return code === undefined && GRPC_NOT_FOUND_MESSAGE_RE.test(err.message); +} + +export function hashi({ + name = 'hashi' as Name, + ...options +}: HashiClientOptions) { + return { + name, + register: (client: ClientWithCoreApi) => { + return new HashiClient({ client, ...options }); + }, + }; +} + +/** + * User-facing SDK client for the Hashi protocol. Constructed via the + * `hashi({...})` factory and attached to any Sui client via `$extend`: + * + * ```ts + * const client = new SuiGrpcClient({ ... }).$extend(hashi({ network: "devnet" })); + * const result = await client.hashi.deposit({ signer, ... }); + * ``` + * + * **Direct methods (`deposit`, `withdraw`) sign and execute transactions** on + * behalf of the caller — pass a `Signer` and receive the execution result. + * For composable flows (bundling into a larger PTB, sponsored transactions, + * dry-run/simulation), use the `tx.*` builders instead; they return unsigned + * `Transaction` objects and leave signing to the caller. + */ +export class HashiClient { + #client: ClientWithCoreApi; + #hashiObjectId: string; + #packageId: string; + #bitcoinNetwork: BitcoinNetwork; + #btcRpcUrl: string | undefined; + #graphqlUrl: string; + #guardianUrl: string | undefined; + #guardianInfoProvider: GuardianInfoProvider | undefined; + // A URL resolved from the on-chain `guardian_url`, cached once found. Stays + // `undefined` while `guardian_url` is still absent (pre-launch), so we keep + // re-reading the chain until launch (`finish_publish`) publishes it. + #resolvedGuardianUrl: string | undefined; + + constructor({ + client, + network, + hashiObjectId, + packageId, + bitcoinNetwork, + btcRpcUrl, + graphqlUrl, + guardianUrl, + guardianInfoProvider, + }: { + client: ClientWithCoreApi; + network: SuiNetwork; + hashiObjectId?: string; + packageId?: string; + bitcoinNetwork?: BitcoinNetwork; + btcRpcUrl?: string; + graphqlUrl?: string; + guardianUrl?: string; + guardianInfoProvider?: GuardianInfoProvider; + }) { + const config = NETWORK_CONFIG[network]; + const resolvedObjectId = hashiObjectId ?? config?.hashiObjectId; + const resolvedPackageId = packageId ?? config?.packageId; + if (!resolvedObjectId || !resolvedPackageId) { + throw new Error( + `Hashi is not yet supported on Sui ${network}. Provide a custom hashiObjectId and packageId.`, + ); + } + this.#client = client; + this.#hashiObjectId = resolvedObjectId; + this.#packageId = resolvedPackageId; + this.#bitcoinNetwork = bitcoinNetwork ?? config?.bitcoinNetwork ?? 'testnet'; + this.#btcRpcUrl = btcRpcUrl; + this.#graphqlUrl = graphqlUrl ?? defaultGraphqlUrl(network); + this.#guardianUrl = guardianUrl; + this.#guardianInfoProvider = guardianInfoProvider; + } + + /** + * Generates a unique Bitcoin P2TR deposit address for a Sui address. + * + * Fetches the MPC committee public key and the guardian's BTC public key + * from on-chain, derives an MPC child key against the Sui address, and + * builds the Hashi taproot script tree: an immediate 2-of-2 leaf + * (`multi_a(2, guardian, derived_mpc)`) plus a delayed MPC-only recovery + * leaf. The address matches the bridge's on-chain + * `validate_deposit_request_derivation_path` check byte-for-byte. + * + * The MPC key (`committee_set.mpc_public_key`) and the guardian key + * (`guardian_btc_public_key` config) come from a single fetch of the + * Hashi object, and only those two fields are parsed — an unrelated + * malformed config entry can't block deposit-address generation. + * + * Throws `HashiConfigError` if the deployment isn't guardian-provisioned + * yet (no `guardian_btc_public_key` on chain). The SDK refuses to fall + * back to a single-key address because the bridge validator rejects it. + * + * @example + * ```ts + * const btcAddress = await client.hashi.generateDepositAddress({ + * suiAddress: signer.toSuiAddress(), + * }); + * ``` + */ + async generateDepositAddress({ + suiAddress, + bitcoinNetwork = this.#bitcoinNetwork, + }: { + /** The Sui address to generate a deposit address for (hex string with 0x prefix). */ + suiAddress: string; + /** Override the default Bitcoin network for this call. */ + bitcoinNetwork?: BitcoinNetwork; + }): Promise { + const { json, contents } = await this.#fetchHashiObject(); + return generateDepositAddressRaw({ + mpcMasterCompressed: parseMpcPublicKey(json.committee_set.mpc_public_key), + guardianBtcXOnly: configBytes( + contents, + 'guardian_btc_public_key', + GUARDIAN_BTC_PUBLIC_KEY_LEN, + ), + suiAddress: fromHex(normalizeSuiAddress(suiAddress)), + network: bitcoinNetwork, + }); + } + + /** + * Submit one or more Bitcoin deposits for committee confirmation, batched + * into a single Sui PTB. Signs with `signer` and submits, returning the + * execution result (`$kind: "Transaction" | "FailedTransaction"`). The + * result includes `effects` and `events` so callers can confirm + * `DepositRequested` without an extra round-trip. + * + * The method runs three preflight stages before signing: + * + * 1. **Structural validation** — `txid` and `recipient` must be + * 0x-prefixed 32-byte hex; `utxos` must be non-empty; every `vout` + * must be a non-negative u32 and unique within the call. Violations + * throw `InvalidParamsError` without any chain read. + * 2. **Pause check** — reads the governance snapshot via `view.all()` + * and throws `HashiPausedError` if `paused` is `true`. Mirrors the + * Move-side `hashi::assert_unpaused`. + * 3. **Minimum check** — every UTXO must have `amountSats ≥ + * snap.bitcoinDepositMinimum`. All offenders are collected into one + * `AmountBelowMinimumError`, so callers can fix the batch in one + * round-trip. Mirrors `EBelowMinimumDeposit` in `deposit::deposit`. + * + * Both chain-reading checks (2, 3) read from the same `view.all()` + * snapshot, so validation is internally consistent. Chain state can still + * drift between the snapshot and execution — the Move side re-asserts + * both invariants, so a genuine race simply aborts the tx. + * + * For composable flows (sponsored tx, dry-run, or bundling into a larger + * PTB), use `tx.deposit(params)` instead — it returns the unsigned + * `Transaction` and leaves signing to the caller. + */ + async deposit({ + signer, + ...params + }: DepositParams & { + /** Signs and pays for the resulting transaction. The signer's address becomes the tx sender. */ + signer: Signer; + }) { + this.#validateDepositParams(params); + + const snap = await this.view.all(); + if (snap.paused) { + throw new HashiPausedError({ operation: 'deposit' }); + } + + const violations: AmountViolation[] = []; + for (const { vout, amountSats } of params.utxos) { + if (amountSats < snap.bitcoinDepositMinimum) { + violations.push({ + amount: amountSats, + minimum: snap.bitcoinDepositMinimum, + vout, + }); + } + } + if (violations.length > 0) { + throw new AmountBelowMinimumError({ violations }); + } + + const transaction = this.tx.deposit(params); + return this.#client.core.signAndExecuteTransaction({ + signer, + transaction, + include: { effects: true, events: true }, + }); + } + /** + * Submit a BTC withdrawal request for committee processing. Burns `hBTC` + * from the signer's balance and enqueues a request for the committee to + * send `amountSats` to `bitcoinAddress` on the Bitcoin network. Signs + * with `signer` and submits, returning the execution result including + * `effects` and `events` (`WithdrawalRequested`). + * + * The method runs three preflight stages before signing: + * + * 1. **Address decoding** — `bitcoinAddress` is decoded as bech32 (v0 + * P2WPKH, 20 bytes) or bech32m (v1 P2TR, 32 bytes) via + * `bitcoinAddressToWitnessProgram`. The HRP must match the client's + * configured Bitcoin network. Violations throw + * `InvalidBitcoinAddressError` with a structured `code` so callers + * can distinguish a typo from a wrong-network mistake. + * 2. **Pause check** — reads the governance snapshot via `view.all()` + * and throws `HashiPausedError` if `paused` is `true`. Mirrors the + * Move-side `hashi::assert_unpaused` in `request_withdrawal`. + * 3. **Minimum check** — `amountSats` must be ≥ + * `snap.bitcoinWithdrawalMinimum`. Below-minimum throws + * `AmountBelowMinimumError` with a single violation. Mirrors + * `EBelowMinimumWithdrawal` in `withdraw::request_withdrawal`. + * + * For composable flows (sponsored tx, dry-run, or bundling into a + * larger PTB), use `tx.requestWithdrawal(options)` instead — it returns + * the unsigned `Transaction` and leaves signing to the caller. + */ + async requestWithdrawal({ + signer, + ...params + }: WithdrawalParams & { + /** Signs and pays for the resulting transaction. The signer's address becomes the tx sender. */ + signer: Signer; + }) { + const { program } = bitcoinAddressToWitnessProgram(params.bitcoinAddress, this.#bitcoinNetwork); + + const snap = await this.view.all(); + if (snap.paused) { + throw new HashiPausedError({ operation: 'withdraw' }); + } + if (params.amountSats < snap.bitcoinWithdrawalMinimum) { + throw new AmountBelowMinimumError({ + violations: [ + { + amount: params.amountSats, + minimum: snap.bitcoinWithdrawalMinimum, + }, + ], + }); + } + + const transaction = this.tx.requestWithdrawal({ + amount: params.amountSats, + bitcoinAddress: program, + }); + return this.#client.core.signAndExecuteTransaction({ + signer, + transaction, + include: { effects: true, events: true }, + }); + } + + /** + * Cancel a pending withdrawal request and return the locked BTC to the + * signer. Signs with `signer` and submits, returning the execution result. + * + * The only client-side precondition is that `requestId` is 0x-prefixed + * 32-byte hex. All other constraints — ownership (only the original + * requester can cancel), state window (cancellable only while `Requested` + * or `Approved`, not after committee commitment), and the on-chain + * `withdrawal_cancellation_cooldown_ms` — are enforced by the Move side + * and left to abort at execution. Pre-fetching them would cost an extra + * round-trip and still race chain state. + * + * Unlike `requestWithdrawal`, no pause check is performed: the Move + * `cancel_withdrawal` function has no `assert_unpaused`, so users can + * always unwind a pending request even when the system is paused. + * + * For composable flows, use `tx.cancelWithdrawal({ requestId, recipient })`. + */ + async cancelWithdrawal({ + signer, + requestId, + }: CancelWithdrawalParams & { + /** Signs and pays for the resulting transaction. The signer's address becomes the tx sender and the recipient of the returned BTC. */ + signer: Signer; + }) { + assertHex32(requestId, 'requestId'); + + const transaction = this.tx.cancelWithdrawal({ + requestId, + recipient: signer.toSuiAddress(), + }); + return this.#client.core.signAndExecuteTransaction({ + signer, + transaction, + include: { effects: true, events: true }, + }); + } + + #validateDepositParams(params: DepositParams): void { + assertHex32(params.txid, 'txid'); + assertHex32(params.recipient, 'recipient'); + if (params.utxos.length === 0) { + throw new InvalidParamsError({ + reason: '`utxos` must contain at least one UTXO', + }); + } + const seen = new Set(); + for (const { vout } of params.utxos) { + if (!Number.isInteger(vout) || vout < 0 || vout > U32_MAX) { + throw new InvalidParamsError({ + reason: '`vout` must be a non-negative u32 integer', + detail: `got ${JSON.stringify(vout)}`, + }); + } + if (seen.has(vout)) { + throw new InvalidParamsError({ + reason: 'duplicate `vout` within a single deposit', + detail: `vout ${vout} appears more than once (each output of a single txid must be unique)`, + }); + } + seen.add(vout); + } + } + + // User-facing transaction builders — compose `call.*` thunks into a full + // PTB and return the unsigned `Transaction`. Execution (sign + dry-run + + // submit) is the direct-method layer's concern and happens elsewhere. + tx = { + /** + * Build a transaction that submits one or more Bitcoin deposits for + * committee confirmation, batched into a single Sui PTB. + * + * A single Bitcoin funding tx can pay the same deposit address on + * multiple outputs (e.g. change + donation, or a coinjoin). Rather + * than forcing the user to submit one Sui tx per output, this method + * accepts every qualifying output and emits a dedicated Move-call + * triple per UTXO: + * + * utxo::utxo_id(txid, vout_i) → UtxoId + * utxo::utxo(utxoId, amount_i, derivationPath = recipient) → Utxo + * deposit::deposit(hashi, utxo) + * + * The triples are emitted in `params.utxos` order, so N UTXOs yield + * exactly `3 * N` PTB commands. + * + * Because all triples live in one PTB, execution is atomic: either + * every deposit is recorded, or none are (any abort — wrong minimum, + * replayed UTXO, paused system — reverts the whole transaction). + * + * All UTXOs share the `txid` because `DepositParams` has a single + * top-level `txid` field, and are credited to the same `recipient`. + * + * The `txid` is provided in **display byte order** (the form + * mempool.space and `bitcoin-cli` show); this method reverses it + * to internal byte order before recording on-chain via + * `reverseTxidBytes`. The committee verifier reads `Utxo.txid` + * as a `bitcoin::Txid`, which expects internal byte order — so + * recording display-order bytes leaves the committee searching + * for a phantom (byte-reversed) tx and the deposit never confirms. + * + * @example + * ```ts + * const tx = client.hashi.tx.deposit({ + * txid: `0x${btcTxid}`, + * utxos: [ + * { vout: 0, amountSats: 100_000n }, + * { vout: 2, amountSats: 50_000n }, + * ], + * recipient: signer.toSuiAddress(), + * }); + * await client.signAndExecuteTransaction({ signer, transaction: tx }); + * ``` + */ + deposit: (params: DepositParams): Transaction => { + const tx = new Transaction(); + const internalTxid = `0x${reverseTxidBytes(params.txid)}`; + for (const { vout, amountSats } of params.utxos) { + const utxoId = tx.add( + utxoModule.utxoId({ + package: this.#packageId, + arguments: { txid: internalTxid, vout }, + }), + ); + const utxo = tx.add( + utxoModule.utxo({ + package: this.#packageId, + arguments: { + utxoId, + amount: amountSats, + derivationPath: params.recipient, + }, + }), + ); + tx.add(this.call.deposit({ utxo })); + } + return tx; + }, + + /** + * Build a transaction that submits a BTC withdrawal request. Sources the + * BTC via `coinWithBalance`, unwraps it into a `Balance`, and passes + * it to `withdraw::request_withdrawal` along with the target Bitcoin + * output address. + */ + requestWithdrawal: (options: { + /** Amount in sats to withdraw. Must be ≥ the on-chain withdrawal minimum. */ + amount: bigint; + /** + * Target Bitcoin address as raw witness program bytes — 20 bytes for + * P2WPKH, 32 bytes for P2TR. Callers decode their own bech32(m) + * strings for now; a string-input overload may land in a follow-up. + */ + bitcoinAddress: Uint8Array; + }): Transaction => { + const tx = new Transaction(); + const btcType = `${this.#packageId}::btc::BTC`; + const coin = tx.add( + coinWithBalance({ + type: btcType, + balance: options.amount, + useGasCoin: false, + }), + ); + const [balance] = tx.moveCall({ + package: '0x2', + module: 'coin', + function: 'into_balance', + typeArguments: [btcType], + arguments: [coin], + }); + tx.add( + this.call.requestWithdrawal({ + btc: balance, + bitcoinAddress: Array.from(options.bitcoinAddress), + }), + ); + return tx; + }, + + /** + * Build a transaction that cancels a pending withdrawal request and + * returns the locked BTC to the user. Consumes the `Balance` + * hot-potato returned by `withdraw::cancel_withdrawal` by wrapping it + * into a `Coin` and transferring to `recipient`. + */ + cancelWithdrawal: (options: { + /** The withdrawal request ID to cancel. */ + requestId: string; + /** + * Sui address that will receive the returned `Coin`. Required + * because the unsigned `Transaction` does not know its sender at + * build time — the caller must pass their own address explicitly. + */ + recipient: string; + }): Transaction => { + const tx = new Transaction(); + const balance = tx.add(this.call.cancelWithdrawal({ requestId: options.requestId })); + const [coin] = tx.moveCall({ + package: '0x2', + module: 'coin', + function: 'from_balance', + typeArguments: [`${this.#packageId}::btc::BTC`], + arguments: [balance], + }); + tx.transferObjects([coin], options.recipient); + return tx; + }, + }; + + // Move call helpers — thin wrappers over generated bindings that auto-inject + // the Hashi shared object and the resolved package id. Each returns a thunk + // suitable for `tx.add(...)`. Only user-facing Hashi calls are exposed here; + // operator/committee calls are intentionally not part of this surface. + call = { + deposit: (options: { utxo: RawTransactionArgument }) => + depositModule.deposit({ + package: this.#packageId, + arguments: { hashi: this.#hashiObjectId, utxo: options.utxo }, + }), + requestWithdrawal: (options: { + btc: RawTransactionArgument; + bitcoinAddress: RawTransactionArgument; + }) => + withdrawModule.requestWithdrawal({ + package: this.#packageId, + arguments: { + hashi: this.#hashiObjectId, + btc: options.btc, + bitcoinAddress: options.bitcoinAddress, + }, + }), + /** + * Cancel a pending withdrawal request. Returns a `Balance` hot potato + * that must be consumed in the same PTB (e.g. wrapped into a Coin and + * transferred back to the sender). + */ + cancelWithdrawal: (options: { requestId: RawTransactionArgument }) => + withdrawModule.cancelWithdrawal({ + package: this.#packageId, + arguments: { hashi: this.#hashiObjectId, requestId: options.requestId }, + }), + }; + + /** + * Parses the `Hashi.config.config` VecMap contents into a typed snapshot, + * applying the same floors as the Move accessors so the SDK matches + * on-chain semantics exactly. + */ + #parseConfig(contents: readonly ConfigEntry[]): GovernanceConfig { + const u64 = (key: string): bigint => { + const v = entry(contents, key, 'U64'); + try { + return BigInt(v.U64); + } catch (cause) { + throw HashiConfigError.malformedPayload( + key, + 'U64', + `"${v.U64}" is not a valid integer`, + cause, + ); + } + }; + const bool = (key: string): boolean => entry(contents, key, 'Bool').Bool; + const addr = (key: string): string => entry(contents, key, 'Address').Address; + // Guardian keys are optional today — pre-feature deployments don't + // have them at all. We surface `null` for missing entries; downstream + // callers (e.g. `generateDepositAddress`) hard-fail when they need a + // value but find `null`. + const optionalString = (key: string): string | null => { + try { + return entry(contents, key, 'String').String; + } catch (e) { + if (e instanceof HashiConfigError && e.actualVariant === undefined) { + return null; + } + throw e; + } + }; + const optionalBytes = (key: string, expectedLen: number): Uint8Array | null => { + try { + return configBytes(contents, key, expectedLen); + } catch (e) { + // A genuinely-absent key (no `actualVariant`) is optional; a + // wrong variant or bad length is a real malformation — rethrow. + if (e instanceof HashiConfigError && e.actualVariant === undefined) { + return null; + } + throw e; + } + }; + + const rawDepositMin = u64('bitcoin_deposit_minimum'); + const rawWithdrawalMin = u64('bitcoin_withdrawal_minimum'); + const bitcoinDepositMinimum = + rawDepositMin < DUST_RELAY_MIN_VALUE ? DUST_RELAY_MIN_VALUE : rawDepositMin; + const bitcoinWithdrawalMinimum = + rawWithdrawalMin < DUST_RELAY_MIN_VALUE + 1n ? DUST_RELAY_MIN_VALUE + 1n : rawWithdrawalMin; + + return { + paused: bool('paused'), + bitcoinChainId: addr('bitcoin_chain_id'), + bitcoinDepositMinimum, + bitcoinWithdrawalMinimum, + bitcoinConfirmationThreshold: u64('bitcoin_confirmation_threshold'), + withdrawalCancellationCooldownMs: u64('withdrawal_cancellation_cooldown_ms'), + bitcoinDepositTimeDelayMs: u64('bitcoin_deposit_time_delay_ms'), + depositMinimum: bitcoinDepositMinimum, + worstCaseNetworkFee: bitcoinWithdrawalMinimum - DUST_RELAY_MIN_VALUE, + guardianUrl: optionalString('guardian_url'), + guardianPublicKey: optionalBytes('guardian_public_key', GUARDIAN_PUBLIC_KEY_LEN), + guardianBtcPublicKey: optionalBytes('guardian_btc_public_key', GUARDIAN_BTC_PUBLIC_KEY_LEN), + }; + } + + view = { + /** + * Fetches the MPC committee's threshold public key from on-chain. + * + * This is the 33-byte compressed secp256k1 key stored in `CommitteeSet.mpc_public_key`. + * It is set after the committee completes DKG and is updated at epoch boundaries. + * + * @returns 33-byte compressed secp256k1 public key + * @throws If the MPC key is not yet available (DKG not completed) + */ + mpcPublicKey: async (): Promise => { + const result = await Hashi.get({ + client: this.#client, + objectId: this.#hashiObjectId, + }); + return parseMpcPublicKey(result.json.committee_set.mpc_public_key); + }, + + /** + * Fetches all governance values in a single round-trip and returns a + * consistent snapshot. Prefer this over individual methods when you + * need 2+ values — it avoids redundant `Hashi.get` calls and gives + * you all fields from the same on-chain state. + */ + all: async (): Promise => { + const { contents } = await this.#fetchHashiObject(); + return this.#parseConfig(contents); + }, + + paused: async (): Promise => (await this.view.all()).paused, + + /** Floored to `DUST_RELAY_MIN_VALUE` if the on-chain value is lower. */ + bitcoinDepositMinimum: async (): Promise => + (await this.view.all()).bitcoinDepositMinimum, + + /** Floored to `DUST_RELAY_MIN_VALUE + 1` so `worstCaseNetworkFee` is always ≥ 1. */ + bitcoinWithdrawalMinimum: async (): Promise => + (await this.view.all()).bitcoinWithdrawalMinimum, + + bitcoinConfirmationThreshold: async (): Promise => + (await this.view.all()).bitcoinConfirmationThreshold, + + withdrawalCancellationCooldownMs: async (): Promise => + (await this.view.all()).withdrawalCancellationCooldownMs, + + bitcoinChainId: async (): Promise => (await this.view.all()).bitcoinChainId, + + /** Alias of `bitcoinDepositMinimum`. */ + depositMinimum: async (): Promise => (await this.view.all()).depositMinimum, + + /** + * Worst-case Bitcoin miner fee (sats) deducted from a withdrawal. + * Derived as `bitcoinWithdrawalMinimum - DUST_RELAY_MIN_VALUE`; always ≥ 1. + */ + worstCaseNetworkFee: async (): Promise => (await this.view.all()).worstCaseNetworkFee, + + /** + * Check whether one or more Bitcoin UTXOs already exist in the + * on-chain `UtxoPool` — either as active (confirmed deposit, not yet + * consumed) or spent (consumed by a withdrawal). Callers use this to + * detect already-used outputs before submitting a deposit. + * + * `txid` in each `UtxoId` is **display byte order**; the method + * reverses to internal byte order before encoding the on-chain key. + * + * Throws on any RPC failure that isn't a clean "object does not + * exist" — a transient transport error must not be downgraded to + * `isUsed: false`, because that could lead a caller to re-spend an + * already-used UTXO. + */ + findUsedUtxos: async (utxos: readonly UtxoId[]): Promise => { + if (utxos.length === 0) return []; + + const btcState = await this.#fetchBitcoinState(); + const activePoolId = btcState.utxo_pool.utxo_records.id; + const spentPoolId = btcState.utxo_pool.spent_utxos.id; + + const typeTag = TypeTagSerializer.parseFromStr(`${this.#packageId}::utxo::UtxoId`); + + // Derive dynamic field IDs for every UTXO in both pools. + const fieldIds: string[] = []; + for (const u of utxos) { + const keyBcs = UtxoIdBcs.serialize({ + txid: `0x${reverseTxidBytes(u.txid)}`, + vout: u.vout, + }).toBytes(); + fieldIds.push( + deriveDynamicFieldID(activePoolId, typeTag, keyBcs), + deriveDynamicFieldID(spentPoolId, typeTag, keyBcs), + ); + } + + // Batch-fetch — existence test only, no content needed. + const objects = await this.#batchGetObjects(fieldIds); + + if (objects.length !== fieldIds.length) { + throw new HashiFetchError( + `findUsedUtxos: getObjects returned ${objects.length} results, expected ${fieldIds.length}`, + this.#hashiObjectId, + ); + } + + // Only "object not found" errors mean the UTXO is absent from a + // pool. Anything else (transient RPC failure, unexpected code, + // opaque gRPC Error) must propagate — otherwise callers could + // silently treat a still-used UTXO as free and re-spend it. + for (const result of objects) { + if (result instanceof Error && !isObjectNotFoundError(result)) { + throw result; + } + } + + return utxos.map((u, i) => { + const inActivePool = !(objects[i * 2] instanceof Error); + const inSpentPool = !(objects[i * 2 + 1] instanceof Error); + return { + utxoId: u, + inActivePool, + inSpentPool, + isUsed: inActivePool || inSpentPool, + }; + }); + }, + + /** + * Get the hBTC balance for a Sui address. + * + * @returns Total balance in satoshis and the number of coin objects held. + */ + balance: async (owner: string): Promise => { + const btcType = `${this.#packageId}::btc::BTC`; + const { balance } = await this.#client.core.getBalance({ + owner, + coinType: btcType, + }); + + let coinObjectCount = 0; + let cursor: string | null = null; + let hasNextPage = true; + while (hasNextPage) { + const page = await this.#client.core.listCoins({ + owner, + coinType: btcType, + cursor: cursor ?? undefined, + }); + coinObjectCount += page.objects.length; + cursor = page.cursor; + hasNextPage = page.hasNextPage; + } + + return { + totalBalance: BigInt(balance.balance ?? '0'), + coinObjectCount, + }; + }, + + /** + * Get the status and details of a deposit by its Sui transaction digest. + * + * Fetches the `DepositRequested` event from the transaction, extracts the + * request ID, then probes on-chain state to determine whether the deposit + * is pending (still in `requests` ObjectBag), confirmed (object exists + * but not in requests), or expired (object destroyed). + */ + depositStatus: async (suiTxDigest: string): Promise => { + const txResult = await this.#client.core.getTransaction({ + digest: suiTxDigest, + include: { events: true }, + }); + + const txData = txResult.Transaction ?? txResult.FailedTransaction; + if (!txData?.events) return null; + + const depositEvent = txData.events.find((e: { eventType: string }) => + e.eventType.includes('::deposit::DepositRequested'), + ); + if (!depositEvent?.json) return null; + + const parsed = depositEvent.json as { + request_id: string; + utxo_id: { txid: string; vout: number }; + amount: string; + derivation_path: string | null; + timestamp_ms: string; + }; + + let status: DepositStatus = 'unknown'; + let approvalTimestampMs: bigint | null = null; + let confirmableAtMs: bigint | null = null; + try { + const reqObj = await DepositRequest.get({ + client: this.#client, + objectId: parsed.request_id, + }); + + if (reqObj.json.approved_timestamp_ms != null) { + approvalTimestampMs = BigInt(reqObj.json.approved_timestamp_ms); + } + + const [btcState, config] = await Promise.all([ + this.#fetchBitcoinState(), + this.view.all().catch(() => null), + ]); + + if (approvalTimestampMs !== null && config) { + confirmableAtMs = approvalTimestampMs + config.bitcoinDepositTimeDelayMs; + } + + const requestsBagId = btcState.deposit_queue.requests.id; + + const reqResult = await this.#client.core + .getDynamicField({ + parentId: requestsBagId, + name: { + type: OBJECT_BAG_ADDRESS_TYPE, + bcs: bcs.Address.serialize(parsed.request_id).toBytes(), + }, + }) + .catch(() => null); + + status = reqResult?.dynamicField ? 'pending' : 'confirmed'; + } catch (err) { + if (err instanceof Error && isObjectNotFoundError(err)) { + status = 'expired'; + } else { + throw err; + } + } + + return { + requestId: parsed.request_id, + amountSats: BigInt(parsed.amount), + recipient: parsed.derivation_path, + btcTxid: reverseTxidBytes(parsed.utxo_id.txid), + btcVout: parsed.utxo_id.vout, + timestampMs: BigInt(parsed.timestamp_ms), + approvalTimestampMs, + confirmableAtMs, + status, + suiTxDigest, + }; + }, + + /** + * Get the status and details of a withdrawal by its Sui transaction digest. + * + * Fetches the `WithdrawalRequested` event from the transaction, extracts the + * request ID, then reads the `WithdrawalRequest` object to determine the + * current lifecycle state. If a `WithdrawalTransaction` is linked, its + * Bitcoin txid is populated. + */ + withdrawalStatus: async (suiTxDigest: string): Promise => { + const txResult = await this.#client.core.getTransaction({ + digest: suiTxDigest, + include: { events: true }, + }); + + const txData = txResult.Transaction ?? txResult.FailedTransaction; + if (!txData?.events) return null; + + const withdrawEvent = txData.events.find((e: { eventType: string }) => + e.eventType.includes('::withdrawal_queue::WithdrawalRequested'), + ); + if (!withdrawEvent?.json) return null; + + const parsed = withdrawEvent.json as { + request_id: string; + btc_amount: string; + bitcoin_address: number[]; + timestamp_ms: string; + requester_address: string; + }; + + let status: WithdrawalStatus | 'cancelled' = 'Requested'; + let btcTxid: string | null = null; + + try { + const reqObj = await WithdrawalRequest.get({ + client: this.#client, + objectId: parsed.request_id, + }); + status = reqObj.json.status.$kind as WithdrawalStatus; + + const withdrawalTxnId = reqObj.json.withdrawal_txn_id; + if ( + withdrawalTxnId && + (status === 'Processing' || status === 'Signed' || status === 'Confirmed') + ) { + try { + const txnObj = await WithdrawalTransaction.get({ + client: this.#client, + objectId: withdrawalTxnId, + }); + btcTxid = reverseTxidBytes(txnObj.json.txid); + } catch { + // best effort + } + } + } catch (err) { + if (err instanceof Error && isObjectNotFoundError(err)) { + status = 'cancelled'; + } else { + throw err; + } + } + + return { + requestId: parsed.request_id, + btcAmountSats: BigInt(parsed.btc_amount), + bitcoinAddress: new Uint8Array(parsed.bitcoin_address), + sender: parsed.requester_address, + timestampMs: BigInt(parsed.timestamp_ms), + status, + suiTxDigest, + btcTxid, + }; + }, + + /** + * Estimate the gas cost for a deposit transaction via dry-run. + * Returns `0n` if simulation fails (best-effort). + */ + depositGasEstimate: async (sender: string): Promise => { + const snap = await this.view.all(); + const dummyAmount = snap.bitcoinDepositMinimum + 1n; + const dummyTxid = '0x' + '01'.repeat(32); + const tx = new Transaction(); + const utxoId = tx.add( + utxoModule.utxoId({ + package: this.#packageId, + arguments: { txid: dummyTxid, vout: 0 }, + }), + ); + const utxo = tx.add( + utxoModule.utxo({ + package: this.#packageId, + arguments: { utxoId, amount: dummyAmount, derivationPath: sender }, + }), + ); + tx.add(this.call.deposit({ utxo })); + tx.setSender(sender); + return { gasEstimateMist: await this.#estimateGas(tx) }; + }, + + /** + * Fetch current withdrawal fees, minimums, and gas estimates. + * + * @param sender - If provided, estimates gas cost via dry-run. + */ + withdrawalFees: async (sender?: string): Promise => { + const snap = await this.view.all(); + + let gasEstimateMist = 0n; + if (sender) { + const dummyAmount = snap.bitcoinWithdrawalMinimum + 1n; + const btcType = `${this.#packageId}::btc::BTC`; + const tx = new Transaction(); + const coin = tx.add( + coinWithBalance({ type: btcType, balance: dummyAmount, useGasCoin: false }), + ); + const [balance] = tx.moveCall({ + package: '0x2', + module: 'coin', + function: 'into_balance', + typeArguments: [btcType], + arguments: [coin], + }); + tx.add( + this.call.requestWithdrawal({ + btc: balance, + bitcoinAddress: Array(20).fill(0), + }), + ); + tx.setSender(sender); + gasEstimateMist = await this.#estimateGas(tx); + } + + return { + worstCaseNetworkFeeSats: snap.worstCaseNetworkFee, + withdrawalMinimumSats: snap.bitcoinWithdrawalMinimum, + gasEstimateMist, + }; + }, + + /** + * Fetch the unified transaction history (deposits + withdrawals) for + * a Sui address. Confirmed requests come from the on-chain + * `user_requests` index; in-flight deposits are discovered via + * GraphQL `DepositRequested` event queries (indexed by sender). + */ + transactionHistory: async (suiAddress: string): Promise => { + const [btcState, timeDelayMs] = await Promise.all([ + this.#fetchBitcoinState(), + this.view.all().then( + (c) => c.bitcoinDepositTimeDelayMs, + () => null, + ), + ]); + + // 1. Confirmed requests from user_requests on-chain index. + const confirmedIds = new Set(); + const items: TransactionHistoryItem[] = []; + + const userBagId = await this.#fetchUserRequestsBagId(btcState.user_requests.id, suiAddress); + if (userBagId !== null) { + const requestIds = await this.#listAllDynamicFieldAddressKeys(userBagId); + if (requestIds.length > 0) { + const objects = await this.#batchGetObjects(requestIds, { content: true }); + const classified = this.#classifyRequestObjects(objects, timeDelayMs); + items.push(...classified.items); + await this.#populateWithdrawalBtcTxids(items, classified.withdrawalTxnLookups); + for (const id of requestIds) confirmedIds.add(id); + } + } + + // 2. In-flight deposits via GraphQL events (not yet in user_requests). + // Best-effort: if GraphQL is unavailable (e.g. localnet) we + // still return the confirmed set from step 1. + try { + const depositEventType = `${this.#packageId}::deposit::DepositRequested`; + const allDepositIds = await this.#queryEventRequestIds(suiAddress, depositEventType); + const pendingIds = allDepositIds.filter((id) => !confirmedIds.has(id)); + + if (pendingIds.length > 0) { + const objects = await this.#batchGetObjects(pendingIds, { content: true }); + const classified = this.#classifyRequestObjects(objects, timeDelayMs); + items.push(...classified.items); + } + } catch { + // GraphQL endpoint may not be available (localnet, custom deployments). + } + + items.sort((a, b) => Number(b.timestampMs - a.timestampMs)); + return items; + }, + }; + + // ------------------------------------------------------------------ + // Polling helpers + // ------------------------------------------------------------------ + + /** + * Poll deposit status until it reaches a terminal state (confirmed or expired). + */ + async waitForDeposit(suiTxDigest: string, options?: WaitOptions): Promise { + const intervalMs = options?.intervalMs ?? 15_000; + const signal = options?.signal; + while (!signal?.aborted) { + const info = await this.view.depositStatus(suiTxDigest); + if (!info) throw new Error(`Deposit not found for digest: ${suiTxDigest}`); + if (info.status === 'confirmed' || info.status === 'expired') return info; + await sleep(intervalMs, signal); + } + throw new Error('Polling aborted'); + } + + /** + * Poll withdrawal status until it reaches a terminal state (confirmed or cancelled). + */ + async waitForWithdrawal(suiTxDigest: string, options?: WaitOptions): Promise { + const intervalMs = options?.intervalMs ?? 15_000; + const signal = options?.signal; + while (!signal?.aborted) { + const info = await this.view.withdrawalStatus(suiTxDigest); + if (!info) throw new Error(`Withdrawal not found for digest: ${suiTxDigest}`); + if (info.status === 'Confirmed' || info.status === 'cancelled') return info; + await sleep(intervalMs, signal); + } + throw new Error('Polling aborted'); + } + + // ------------------------------------------------------------------ + // Bitcoin RPC (optional — requires btcRpcUrl) + // ------------------------------------------------------------------ + + bitcoin = { + /** + * Look up the first UTXO output in a Bitcoin transaction that pays + * to the given deposit address. + * + * Requires `btcRpcUrl` to be configured. + */ + lookupVout: async (txid: string, depositAddress: string): Promise => { + this.#requireBtcRpc(); + return lookupVout(this.#btcRpcUrl!, txid, depositAddress); + }, + + /** + * Look up ALL outputs in a Bitcoin transaction that pay to the given + * deposit address. + * + * Requires `btcRpcUrl` to be configured. + */ + lookupAllVouts: async (txid: string, depositAddress: string): Promise => { + this.#requireBtcRpc(); + return lookupAllVouts(this.#btcRpcUrl!, txid, depositAddress); + }, + + /** + * Returns the current confirmation count for a Bitcoin transaction. + * Returns 0 if the transaction is in the mempool but not yet mined. + * + * Requires `btcRpcUrl` to be configured. + */ + confirmations: async (txid: string): Promise => { + this.#requireBtcRpc(); + return getTxConfirmations(this.#btcRpcUrl!, txid); + }, + }; + + #requireBtcRpc(): void { + if (!this.#btcRpcUrl) { + throw new Error( + 'btcRpcUrl is required for Bitcoin RPC operations. ' + 'Pass it in HashiClientOptions.', + ); + } + } + + // ------------------------------------------------------------------ + // Guardian rate limiter (optional — requires guardianUrl, + // guardianInfoProvider, or an on-chain `guardian_url` config value) + // ------------------------------------------------------------------ + + guardian = { + /** + * Fetch the guardian's curated `/info` (identity + limiter). `limiter` + * is `null` when the guardian is not yet provisioned/activated; this + * method never throws for that state, so it can detect an uninitialized + * guardian without a try/catch. + */ + info: async (): Promise => { + const provider = await this.#resolveGuardianProvider(); + return provider(); + }, + + /** + * Fetch the limiter and compute derived fields: capacity projected to + * now, bucket fill percentage, and the refill-to-full ETA. Throws + * `HashiGuardianError` (`code: "not-initialized"`) if the guardian has + * no limiter yet. + */ + limiterStatus: async (): Promise => { + const { state, config } = this.#requireLimiter(await this.guardian.info()); + const nowSecs = BigInt(Math.floor(Date.now() / 1000)); + const availableNowSats = projectCapacity(config, state, nowSecs); + const max = config.maxBucketCapacitySats; + const bucketFillPercent = max > 0n ? (Number(availableNowSats) / Number(max)) * 100 : 0; + let fullAtSecs: bigint | null = null; + if (availableNowSats < max && config.refillRateSatsPerSec > 0n) { + const deficit = max - availableNowSats; + const secsToFull = + (deficit + config.refillRateSatsPerSec - 1n) / config.refillRateSatsPerSec; + fullAtSecs = nowSecs + secsToFull; + } + return { state, config, availableNowSats, bucketFillPercent, fullAtSecs }; + }, + + /** + * Check whether the guardian can sign a withdrawal of `amountSats` right + * now, with an estimated wait if not. Throws `HashiGuardianError` + * (`code: "not-initialized"`) if the guardian has no limiter yet. + */ + canWithdraw: async (amountSats: bigint): Promise => { + const { state, config } = this.#requireLimiter(await this.guardian.info()); + const nowSecs = BigInt(Math.floor(Date.now() / 1000)); + const availableNowSats = projectCapacity(config, state, nowSecs); + const estimatedWaitSecs = estimateWaitSecs(config, state, amountSats, nowSecs); + return { allowed: availableNowSats >= amountSats, availableNowSats, estimatedWaitSecs }; + }, + }; + + #requireLimiter(info: RawGuardianInfo): GuardianLimiterRaw { + if (info.limiter === null) { + throw new HashiGuardianError({ + message: 'Guardian rate limiter is not initialized (limiter is null).', + code: 'not-initialized', + }); + } + return info.limiter; + } + + async #resolveGuardianProvider(): Promise { + if (this.#guardianInfoProvider) return this.#guardianInfoProvider; + const url = this.#guardianUrl || (await this.#resolveOnChainGuardianUrl()); + if (!url) { + throw new HashiGuardianError({ + message: + 'Guardian URL is not configured. Pass `guardianUrl` or `guardianInfoProvider` ' + + 'to hashi({...}), or set `guardian_url` in the on-chain config.', + code: 'not-configured', + }); + } + return () => fetchGuardianInfo(url); + } + + /** + * Resolve the guardian origin from the on-chain `guardian_url`, caching only + * a found URL. `guardian_url` is absent until launch (`finish_publish` + * publishes it), so a missing value is left uncached and each call re-reads + * the chain — a client created before launch starts working once the URL is + * published, rather than caching the absence forever (mirrors the node's + * lazy `guardian_client()` resolution). Returns `undefined` while unresolved. + */ + async #resolveOnChainGuardianUrl(): Promise { + if (this.#resolvedGuardianUrl) return this.#resolvedGuardianUrl; + let onChain: string | null; + try { + onChain = (await this.view.all()).guardianUrl; + } catch (cause) { + // A transient chain-read failure must not permanently disable + // guardian resolution for this client; surface it, cache nothing. + throw new HashiGuardianError( + { + message: + 'Could not read the on-chain guardian_url config. Pass ' + + '`guardianUrl` or `guardianInfoProvider` to bypass the on-chain read.', + code: 'not-configured', + }, + { cause }, + ); + } + return (this.#resolvedGuardianUrl = onChain || undefined); + } + + async #estimateGas(tx: Transaction): Promise { + try { + const result = await this.#client.core.simulateTransaction({ + transaction: tx, + include: { effects: true }, + }); + const simTx = result.Transaction ?? result.FailedTransaction; + if (simTx?.effects?.gasUsed) { + const gas = simTx.effects.gasUsed; + const total = + BigInt(gas.computationCost) + BigInt(gas.storageCost) - BigInt(gas.storageRebate); + return total > 0n ? (total * 120n) / 100n : 0n; + } + } catch { + // simulation may fail — gas estimate is best-effort + } + return 0n; + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + /** + * Fetches the Hashi shared object once and returns its decoded `json` + * alongside the validated governance-config `contents` array. Wraps + * transport failures and unexpected shapes in `HashiFetchError`. Shared by + * `view.all()` and `generateDepositAddress` so a single round-trip serves + * both the committee key (from `json`) and the config reads (from + * `contents`). + */ + async #fetchHashiObject(): Promise<{ + json: Awaited>['json']; + contents: readonly ConfigEntry[]; + }> { + let result; + try { + result = await Hashi.get({ + client: this.#client, + objectId: this.#hashiObjectId, + }); + } catch (cause) { + throw new HashiFetchError( + `Failed to fetch Hashi shared object ${this.#hashiObjectId}.`, + this.#hashiObjectId, + { cause }, + ); + } + const contents = result.json?.config?.config?.contents; + if (!Array.isArray(contents)) { + throw new HashiFetchError( + `Hashi object ${this.#hashiObjectId} returned an unexpected shape: config.config.contents is not an array.`, + this.#hashiObjectId, + ); + } + return { json: result.json, contents }; + } + + /** + * Fetches the `BitcoinState` dynamic field from the Hashi shared object. + * Returns the BCS-parsed struct whose nested Bag/Table IDs are used by + * `findUsedUtxos` and `transactionHistory`. + * + * `BitcoinState` is attached via `df::add` on the Move side (it has + * `store` only, not `key`), so the regular-DF accessor is the right tool + * — `getDynamicObjectField` would look for a `dynamic_object_field:: + * Wrapper` that doesn't exist and abort with "not + * found". The previous `listDynamicFields` workaround filtered for + * `$kind === "DynamicObject"` and hit the same dof/df mismatch. + */ + async #fetchBitcoinState() { + const { dynamicField } = await this.#client.core.getDynamicField({ + parentId: this.#hashiObjectId, + name: { + type: `${this.#packageId}::bitcoin_state::BitcoinStateKey`, + bcs: BitcoinStateKey.serialize({ dummy_field: false }).toBytes(), + }, + }); + return BitcoinState.parse(new Uint8Array(dynamicField.value.bcs)); + } + + /** + * Resolve the Bag id holding a user's request IDs from `user_requests`, + * or `null` if the user has never had a request. + */ + async #fetchUserRequestsBagId(tableId: string, suiAddress: string): Promise { + try { + const { dynamicField } = await this.#client.core.getDynamicField({ + parentId: tableId, + name: { + type: 'address', + bcs: bcs.Address.serialize(suiAddress).toBytes(), + }, + }); + return Bag.parse(new Uint8Array(dynamicField.value.bcs)).id; + } catch { + return null; + } + } + + /** + * Enumerate every `address`-keyed dynamic field on `parentId`, paginating + * through `listDynamicFields` until exhausted. + */ + async #listAllDynamicFieldAddressKeys(parentId: string): Promise { + const keys: string[] = []; + let cursor: string | null = null; + do { + const page = await this.#client.core.listDynamicFields({ + parentId, + cursor: cursor ?? undefined, + }); + for (const df of page.dynamicFields) { + keys.push(bcs.Address.parse(new Uint8Array(df.name.bcs))); + } + cursor = page.hasNextPage ? page.cursor : null; + } while (cursor); + return keys; + } + + /** + * Batch-fetches objects in chunks of `GET_OBJECTS_BATCH`, concatenating + * the results. Each element is either an object or an `Error` (for + * missing / deleted objects). + */ + async #batchGetObjects(objectIds: string[], include?: { content?: boolean }) { + const results: (SuiClientTypes.Object<{ content: true }> | Error)[] = []; + for (let i = 0; i < objectIds.length; i += GET_OBJECTS_BATCH) { + const batch = objectIds.slice(i, i + GET_OBJECTS_BATCH); + const { objects } = await this.#client.core.getObjects({ + objectIds: batch, + include: include as { content: true }, + }); + results.push(...objects); + } + return results; + } + + /** + * Query the Sui GraphQL endpoint for events of a given type emitted by + * a sender. Returns the `request_id` from each event's JSON payload, + * paginating through all results. + */ + async #queryEventRequestIds(sender: string, eventType: string): Promise { + const ids: string[] = []; + let cursor: string | null = null; + let hasMore = true; + + while (hasMore) { + const afterClause = cursor ? `, after: "${cursor}"` : ''; + const query = `{ + events( + filter: { sender: "${sender}", type: "${eventType}" } + first: 50${afterClause} + ) { + nodes { contents { json } } + pageInfo { hasNextPage endCursor } + } + }`; + + const res = await fetch(this.#graphqlUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`); + + const body = (await res.json()) as { + data?: { + events?: { + nodes: { contents: { json: { request_id: string } } }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; + }; + errors?: { message: string }[]; + }; + if (body.errors?.length) { + throw new Error(`GraphQL error: ${body.errors[0].message}`); + } + const events = body.data?.events; + if (!events) break; + + for (const node of events.nodes) { + ids.push(node.contents.json.request_id); + } + hasMore = events.pageInfo.hasNextPage; + cursor = events.pageInfo.endCursor ?? null; + } + + return ids; + } + + /** + * Classify a batch of request objects into deposit / withdrawal history + * items. Errors in the batch are skipped (the request object may have + * been deleted between the bag enumeration and the fetch). Returns the + * items plus the indices that need a follow-up `WithdrawalTransaction` + * fetch to populate `btcTxid`. + */ + #classifyRequestObjects( + objects: readonly (SuiClientTypes.Object<{ content: true }> | Error)[], + timeDelayMs: bigint | null, + ): { + items: TransactionHistoryItem[]; + withdrawalTxnLookups: { itemIndex: number; txnId: string }[]; + } { + const items: TransactionHistoryItem[] = []; + const withdrawalTxnLookups: { itemIndex: number; txnId: string }[] = []; + const depositRequestType = `${this.#packageId}::deposit_queue::DepositRequest`; + const withdrawalRequestType = `${this.#packageId}::withdrawal_queue::WithdrawalRequest`; + + for (const obj of objects) { + if (obj instanceof Error) continue; + if (obj.type === depositRequestType) { + items.push(parseDepositHistoryItem(obj.content, timeDelayMs)); + } else if (obj.type === withdrawalRequestType) { + const item = parseWithdrawalHistoryItem(obj.content); + items.push(item); + if (item.withdrawalTxnId) { + withdrawalTxnLookups.push({ + itemIndex: items.length - 1, + txnId: item.withdrawalTxnId, + }); + } + } + } + + return { items, withdrawalTxnLookups }; + } + + /** + * Batch-fetch `WithdrawalTransaction` objects for the withdrawal items + * that have a linked txn and overwrite their `btcTxid` in place. Errors + * in the batch leave `btcTxid` at the initial `null`. + */ + async #populateWithdrawalBtcTxids( + items: TransactionHistoryItem[], + lookups: readonly { itemIndex: number; txnId: string }[], + ): Promise { + if (lookups.length === 0) return; + const txnIds = lookups.map((l) => l.txnId); + const txnObjects = await this.#batchGetObjects(txnIds, { content: true }); + for (let i = 0; i < lookups.length; i++) { + const txnObj = txnObjects[i]; + if (txnObj instanceof Error) continue; + const parsed = WithdrawalTransaction.parse(txnObj.content); + const item = items[lookups[i].itemIndex] as WithdrawalHistoryItem; + (item as { btcTxid: string | null }).btcTxid = reverseTxidBytes(parsed.txid); + } + } +} + +/** + * Convert the on-chain `committee_set.mpc_public_key` (arkworks-compressed) + * into a 33-byte SEC1 key. Throws `HashiConfigError` if DKG hasn't populated + * it yet (empty vector). + */ +function parseMpcPublicKey(raw: ArrayLike): Uint8Array { + const mpcKey = new Uint8Array(raw); + if (mpcKey.length === 0) { + throw HashiConfigError.missing('committee_set.mpc_public_key', 'Bytes'); + } + return arkworksToSec1Compressed(mpcKey); +} + +function parseDepositHistoryItem( + content: Uint8Array, + timeDelayMs: bigint | null, +): DepositHistoryItem { + const parsed = DepositRequest.parse(content); + const approvalTimestampMs = + parsed.approved_timestamp_ms === null ? null : BigInt(parsed.approved_timestamp_ms); + return { + kind: 'deposit', + requestId: parsed.id, + sender: parsed.sender, + timestampMs: BigInt(parsed.created_timestamp_ms), + suiTxDigest: base58.encode(new Uint8Array(parsed.sui_tx_digest)), + amountSats: BigInt(parsed.utxo.amount), + btcTxid: reverseTxidBytes(parsed.utxo.id.txid), + btcVout: parsed.utxo.id.vout, + approved: parsed.approval_cert !== null, + approvalTimestampMs, + confirmableAtMs: + approvalTimestampMs !== null && timeDelayMs !== null + ? approvalTimestampMs + timeDelayMs + : null, + }; +} + +function parseWithdrawalHistoryItem(content: Uint8Array): WithdrawalHistoryItem { + const parsed = WithdrawalRequest.parse(content); + return { + kind: 'withdrawal', + requestId: parsed.id, + sender: parsed.sender, + btcAmountSats: BigInt(parsed.btc_amount), + bitcoinAddress: new Uint8Array(parsed.bitcoin_address), + timestampMs: BigInt(parsed.created_timestamp_ms), + suiTxDigest: base58.encode(new Uint8Array(parsed.sui_tx_digest)), + status: parsed.status.$kind as WithdrawalStatus, + withdrawalTxnId: parsed.withdrawal_txn_id ?? null, + btcTxid: null, + }; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Aborted')); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new Error('Aborted')); + }, + { once: true }, + ); + }); +} diff --git a/packages/hashi/src/constants.ts b/packages/hashi/src/constants.ts new file mode 100644 index 000000000..9304a2f10 --- /dev/null +++ b/packages/hashi/src/constants.ts @@ -0,0 +1,56 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { BitcoinNetwork, NetworkConfig, SuiNetwork } from './types.js'; + +export const NETWORK_HRP: Record = { + mainnet: 'bc', + testnet: 'tb', + signet: 'tb', + regtest: 'bcrt', +}; + +/** + * BIP-341 Nothing-Up-My-Sleeve (NUMS) internal key. + * Has no known private key, which forces all taproot spends through the script path. + */ +// prettier-ignore +export const NUMS_KEY = new Uint8Array([ + 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, + 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, + 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, + 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, +]); + +/** + * The Move side uses this as a floor on `bitcoin_deposit_minimum` and + * `bitcoin_withdrawal_minimum`; the SDK replicates the same floors so `view.*` + * matches on-chain semantics. Mirrors `DUST_RELAY_MIN_VALUE` in + * `hashi::btc_config`. + */ +export const DUST_RELAY_MIN_VALUE = 546n; + +/** + * Length of the Guardian's Ed25519 attestation public key, in bytes. Matches + * `GUARDIAN_PUBLIC_KEY_LEN` in `hashi::config`. + */ +export const GUARDIAN_PUBLIC_KEY_LEN = 32; + +/** + * Length of the Guardian's BIP-340 x-only BTC public key, in bytes. Matches + * `GUARDIAN_BTC_PUBLIC_KEY_LEN` in `hashi::config`. + */ +export const GUARDIAN_BTC_PUBLIC_KEY_LEN = 32; + +export const NETWORK_CONFIG: Partial> = { + devnet: { + hashiObjectId: '0x84081242ebb05eac5e09ab2a930a60b1357d3d8bc6f927380979f72de991ccca', + packageId: '0xa877d4d97b6a8bae1da982a84980c502c5ad2ead4b24e6c8e50c57cd6ddc3771', + bitcoinNetwork: 'signet', + }, + testnet: { + hashiObjectId: '0x22c0ce66ce09df2dc88a31bd320d4177b766518b9b88010368cfbdcd724528f8', + packageId: '0xfcea10cadbb553c4874201584abf68771592678952efd957b2e82c010c7f4360', + bitcoinNetwork: 'signet', + }, +}; diff --git a/packages/hashi/src/contracts/hashi/abort_reconfig.ts b/packages/hashi/src/contracts/hashi/abort_reconfig.ts new file mode 100644 index 000000000..68ab52556 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/abort_reconfig.ts @@ -0,0 +1,77 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Governance proposal for aborting a pending Hashi reconfiguration. + * + * This is intentionally governed by the current committee. If the pending next + * committee cannot complete DKG/key rotation or cannot produce the `end_reconfig` + * certificate, the last committed committee is the only committee with stable + * on-chain voting power. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::abort_reconfig'; +export const AbortReconfig = new MoveStruct({ + name: `${$moduleName}::AbortReconfig`, + fields: { + epoch: bcs.u64(), + }, +}); +export interface ProposeArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + epoch: RawTransactionArgument; + metadata: RawTransactionArgument; +} +export interface ProposeOptions { + package?: string; + arguments: + | ProposeArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + epoch: RawTransactionArgument, + metadata: RawTransactionArgument, + ]; +} +export function propose(options: ProposeOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'u64', null, '0x2::clock::Clock'] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'validatorAddress', 'epoch', 'metadata']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'abort_reconfig', + function: 'propose', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ExecuteArguments { + hashi: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface ExecuteOptions { + package?: string; + arguments: + | ExecuteArguments + | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; +} +export function execute(options: ExecuteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'abort_reconfig', + function: 'execute', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/bitcoin_state.ts b/packages/hashi/src/contracts/hashi/bitcoin_state.ts new file mode 100644 index 000000000..6885f1463 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/bitcoin_state.ts @@ -0,0 +1,43 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Per-chain Bitcoin state, attached to the `Hashi` shared object as a dynamic + * field keyed by `BitcoinStateKey`. Bundles the deposit queue, withdrawal queue, + * and UTXO pool behind package-only accessors, and maintains a per-user index of + * request IDs so clients can discover all deposits and withdrawals belonging to an + * address. + */ + +import { MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import * as deposit_queue from './deposit_queue.js'; +import * as withdrawal_queue from './withdrawal_queue.js'; +import * as utxo_pool from './utxo_pool.js'; +import * as table from './deps/sui/table.js'; +const $moduleName = '@local-pkg/hashi::bitcoin_state'; +export const BitcoinStateKey = new MoveStruct({ + name: `${$moduleName}::BitcoinStateKey`, + fields: { + dummy_field: bcs.bool(), + }, +}); +export const BitcoinState = new MoveStruct({ + name: `${$moduleName}::BitcoinState`, + fields: { + /** + * Extension point: dynamic fields can be attached here so new BTC-side state can + * be added after the struct layout freezes at mainnet. + */ + id: bcs.Address, + deposit_queue: deposit_queue.DepositRequestQueue, + withdrawal_queue: withdrawal_queue.WithdrawalRequestQueue, + utxo_pool: utxo_pool.UtxoPool, + /** + * Per-user index: user address -> Bag of request IDs (deposits and withdrawals). + * Allows clients to discover all requests for a given address. + */ + user_requests: table.Table, + }, +}); diff --git a/packages/hashi/src/contracts/hashi/btc.ts b/packages/hashi/src/contracts/hashi/btc.ts new file mode 100644 index 000000000..96196c5ca --- /dev/null +++ b/packages/hashi/src/contracts/hashi/btc.ts @@ -0,0 +1,21 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * The hBTC coin type — the Sui-side claim on BTC secured by the bridge. `create` + * registers the currency (8 decimals, symbol hBTC) with the Sui coin registry + * during system initialization and returns the treasury and metadata caps, which + * `hashi::treasury` takes into custody so deposits can mint and withdrawals can + * burn hBTC against Bitcoin UTXOs. + */ + +import { MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +const $moduleName = '@local-pkg/hashi::btc'; +export const BTC = new MoveStruct({ + name: `${$moduleName}::BTC`, + fields: { + id: bcs.Address, + }, +}); diff --git a/packages/hashi/src/contracts/hashi/cert_submission.ts b/packages/hashi/src/contracts/hashi/cert_submission.ts new file mode 100644 index 000000000..62d2ff49a --- /dev/null +++ b/packages/hashi/src/contracts/hashi/cert_submission.ts @@ -0,0 +1,149 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Entry points for submitting TOB dealer certificates. Committee members (or their + * delegated operators) post certified dealer-messages hashes for the DKG, + * key-rotation, and nonce-generation MPC ceremonies into per-(epoch, batch, + * protocol) buckets stored on `Hashi`, and garbage-collect buckets once they are + * old enough. + */ + +import { type Transaction } from '@mysten/sui/transactions'; +import { normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +export interface SubmitDkgCertArguments { + hashi: RawTransactionArgument; + epoch: RawTransactionArgument; + dealer: RawTransactionArgument; + messagesHash: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface SubmitDkgCertOptions { + package?: string; + arguments: + | SubmitDkgCertArguments + | [ + hashi: RawTransactionArgument, + epoch: RawTransactionArgument, + dealer: RawTransactionArgument, + messagesHash: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +export function submitDkgCert(options: SubmitDkgCertOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'u64', 'address', 'vector', null] satisfies (string | null)[]; + const parameterNames = ['hashi', 'epoch', 'dealer', 'messagesHash', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'cert_submission', + function: 'submit_dkg_cert', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface SubmitRotationCertArguments { + hashi: RawTransactionArgument; + epoch: RawTransactionArgument; + dealer: RawTransactionArgument; + messagesHash: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface SubmitRotationCertOptions { + package?: string; + arguments: + | SubmitRotationCertArguments + | [ + hashi: RawTransactionArgument, + epoch: RawTransactionArgument, + dealer: RawTransactionArgument, + messagesHash: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +export function submitRotationCert(options: SubmitRotationCertOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'u64', 'address', 'vector', null] satisfies (string | null)[]; + const parameterNames = ['hashi', 'epoch', 'dealer', 'messagesHash', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'cert_submission', + function: 'submit_rotation_cert', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface SubmitNonceCertArguments { + hashi: RawTransactionArgument; + epoch: RawTransactionArgument; + batchIndex: RawTransactionArgument; + dealer: RawTransactionArgument; + messagesHash: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface SubmitNonceCertOptions { + package?: string; + arguments: + | SubmitNonceCertArguments + | [ + hashi: RawTransactionArgument, + epoch: RawTransactionArgument, + batchIndex: RawTransactionArgument, + dealer: RawTransactionArgument, + messagesHash: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +export function submitNonceCert(options: SubmitNonceCertOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'u64', 'u32', 'address', 'vector', null] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'epoch', 'batchIndex', 'dealer', 'messagesHash', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'cert_submission', + function: 'submit_nonce_cert', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface DestroyAllCertsArguments { + hashi: RawTransactionArgument; + epoch: RawTransactionArgument; + batchIndex: RawTransactionArgument; + protocolType: RawTransactionArgument; +} +export interface DestroyAllCertsOptions { + package?: string; + arguments: + | DestroyAllCertsArguments + | [ + hashi: RawTransactionArgument, + epoch: RawTransactionArgument, + batchIndex: RawTransactionArgument, + protocolType: RawTransactionArgument, + ]; +} +/** + * Garbage collection: deliberately NOT gated on pause/reconfig — cert buckets old + * enough to destroy (see `tob::destroy_all`) carry no live state, and GC must stay + * callable during an emergency pause. + */ +export function destroyAllCerts(options: DestroyAllCertsOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'u64', '0x1::option::Option', null] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'epoch', 'batchIndex', 'protocolType']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'cert_submission', + function: 'destroy_all_certs', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/committee.ts b/packages/hashi/src/contracts/hashi/committee.ts new file mode 100644 index 000000000..953e86893 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/committee.ts @@ -0,0 +1,90 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * BLS signing committees and certificate verification. A `Committee` pins an + * epoch's members (validator addresses, BLS public keys, encryption keys, voting + * weights) together with the MPC parameters snapshotted at reconfig time. + * `verify_certificate` checks an aggregate BLS12-381 min-pk signature against a + * signers bitmap, enforces the stake threshold, and wraps the payload in a + * `CertifiedMessage` as proof of committee approval. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs, type BcsType } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +import * as group_ops from './deps/sui/group_ops.js'; +import * as config from './config.js'; +const $moduleName = '@local-pkg/hashi::committee'; +export const CommitteeMember = new MoveStruct({ + name: `${$moduleName}::CommitteeMember`, + fields: { + validator_address: bcs.Address, + public_key: group_ops.Element, + encryption_public_key: bcs.vector(bcs.u8()), + weight: bcs.u64(), + }, +}); +export const Committee = new MoveStruct({ + name: `${$moduleName}::Committee`, + fields: { + /** The epoch in which the committee is active. */ + epoch: bcs.u64(), + /** A vector of committee members */ + members: bcs.vector(CommitteeMember), + /** Total voting weight of the committee. */ + total_weight: bcs.u64(), + /** + * The config pinned for this epoch (the MPC parameters: threshold, + * weight-reduction delta, max-faulty bound, nonce-generation protocol), + * snapshotted from the governed config at reconfig time. + */ + config: config.Config, + }, +}); +export const CommitteeSignature = new MoveStruct({ + name: `${$moduleName}::CommitteeSignature`, + fields: { + epoch: bcs.u64(), + signature: bcs.vector(bcs.u8()), + signers_bitmap: bcs.vector(bcs.u8()), + }, +}); +export function CertifiedMessage>(...typeParameters: [T]) { + return new MoveStruct({ + name: `${$moduleName}::CertifiedMessage<${typeParameters[0].name as T['name']}>`, + fields: { + message: typeParameters[0], + signature: CommitteeSignature, + stake_support: bcs.u64(), + }, + }); +} +export interface NewCommitteeSignatureArguments { + epoch: RawTransactionArgument; + signature: RawTransactionArgument; + signersBitmap: RawTransactionArgument; +} +export interface NewCommitteeSignatureOptions { + package?: string; + arguments: + | NewCommitteeSignatureArguments + | [ + epoch: RawTransactionArgument, + signature: RawTransactionArgument, + signersBitmap: RawTransactionArgument, + ]; +} +export function newCommitteeSignature(options: NewCommitteeSignatureOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['u64', 'vector', 'vector'] satisfies (string | null)[]; + const parameterNames = ['epoch', 'signature', 'signersBitmap']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'committee', + function: 'new_committee_signature', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/committee_set.ts b/packages/hashi/src/contracts/hashi/committee_set.ts new file mode 100644 index 000000000..cdaa0daea --- /dev/null +++ b/packages/hashi/src/contracts/hashi/committee_set.ts @@ -0,0 +1,101 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Registry of Hashi committee state: registered member metadata (`MemberInfo`), + * per-epoch `Committee`s, the current epoch, the MPC threshold public key, and the + * pending epoch change while a reconfiguration is in flight. Members register (and + * rotate keys/metadata) here between epochs; `start_reconfig` builds the next + * committee from Sui's active validator set, and `end_reconfig` activates it — + * storing the outgoing committee's handoff certificate for non-initial reconfigs. + */ + +import { MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import * as committee from './committee.js'; +import * as bag from './deps/sui/bag.js'; +import * as bag_1 from './deps/sui/bag.js'; +import * as committee_1 from './committee.js'; +import * as group_ops from './deps/sui/group_ops.js'; +import * as config from './config.js'; +const $moduleName = '@local-pkg/hashi::committee_set'; +export const PendingEpochChange = new MoveStruct({ + name: `${$moduleName}::PendingEpochChange`, + fields: { + epoch: bcs.u64(), + committee_handoff_cert: bcs.option(committee.CommitteeSignature), + }, +}); +export const CommitteeSet = new MoveStruct({ + name: `${$moduleName}::CommitteeSet`, + fields: { + members: bag.Bag, + /** The current epoch. */ + epoch: bcs.u64(), + committees: bag_1.Bag, + pending_epoch_change: bcs.option(PendingEpochChange), + /** The MPC committee's threshold public key. */ + mpc_public_key: bcs.vector(bcs.u8()), + }, +}); +export const CommitteeHandoffKey = new MoveStruct({ + name: `${$moduleName}::CommitteeHandoffKey`, + fields: { + epoch: bcs.u64(), + }, +}); +export const CommitteeHandoff = new MoveStruct({ + name: `${$moduleName}::CommitteeHandoff`, + fields: { + next_epoch: bcs.u64(), + cert: committee_1.CommitteeSignature, + }, +}); +export const MemberInfo = new MoveStruct({ + name: `${$moduleName}::MemberInfo`, + fields: { + /** Sui Validator Address of this node */ + validator_address: bcs.Address, + /** Sui Address of an operations account */ + operator_address: bcs.Address, + /** + * bls12381 public key to be used in the next epoch. + * + * The public key for this node which is active in the current epoch can be found + * in the `Committee` struct. + * + * This public key can be rotated but will only take effect at the beginning of the + * next epoch. + */ + next_epoch_public_key: group_ops.Element, + /** + * The HTTPS network address where the instance of the `hashi` service for this + * validator can be reached. + * + * This HTTPS address can be rotated and any such updates will take effect + * immediately. + */ + endpoint_url: bcs.string(), + /** + * ed25519 public key used to verify TLS self-signed x509 certs + * + * This public key can be rotated and any such updates will take effect + * immediately. + */ + tls_public_key: bcs.vector(bcs.u8()), + /** + * A 32-byte ristretto255 Ristretto encryption public key (ristretto255 + * RistrettoPoint) for MPC ECIES, to be used in the next epoch. + * + * This public key can be rotated but will only take effect at the beginning of the + * next epoch. + */ + next_epoch_encryption_public_key: bcs.vector(bcs.u8()), + /** + * Open-ended per-member extension slot. Empty today; lets future upgrades attach + * new member data (e.g. per-protocol keys) without a MemberInfoV2 migration. + */ + extra_fields: config.Config, + }, +}); diff --git a/packages/hashi/src/contracts/hashi/config.ts b/packages/hashi/src/contracts/hashi/config.ts new file mode 100644 index 000000000..795fd89b8 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/config.ts @@ -0,0 +1,27 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * A general-purpose, typed key-value configuration store: a map from string keys + * to `config_value::Value`s, with domain-specific accessors layered on top (pause + * state, guardian, emergency thresholds). Chain-specific configuration (e.g. BTC + * fee parameters) lives in separate modules that use get/upsert. + * + * `Config` has `copy, drop, store` and carries no policy of its own (no + * versioning, no upgrade authority — those live in `versioning`), so it can be + * embedded by value wherever a bag of settings is needed: it backs the package's + * global config and is also pinned per-epoch onto a `Committee`. + */ + +import { MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import * as vec_map from './deps/sui/vec_map.js'; +import * as config_value from './config_value.js'; +const $moduleName = '@local-pkg/hashi::config'; +export const Config = new MoveStruct({ + name: `${$moduleName}::Config`, + fields: { + config: vec_map.VecMap(bcs.string(), config_value.Value), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/config_value.ts b/packages/hashi/src/contracts/hashi/config_value.ts new file mode 100644 index 000000000..8b48960d7 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/config_value.ts @@ -0,0 +1,162 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Typed value wrapper for config entries: the `Value` enum carries one of the + * primitive types a configuration entry can hold, with a public constructor per + * variant and package-level helpers for variant checks (`is_*`) and extraction + * (`as_*`, aborting on a type mismatch). Storing typed values — rather than raw + * bytes — lets `config::is_valid_config_update` reject governance updates that + * would change an entry's type. + */ + +import { MoveEnum, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::config_value'; +export const Value = new MoveEnum({ + name: `${$moduleName}::Value`, + fields: { + U64: bcs.u64(), + U128: bcs.u128(), + U256: bcs.u256(), + Address: bcs.Address, + String: bcs.string(), + Bool: bcs.bool(), + Bytes: bcs.vector(bcs.u8()), + }, +}); +export interface NewU64Arguments { + value: RawTransactionArgument; +} +export interface NewU64Options { + package?: string; + arguments: NewU64Arguments | [value: RawTransactionArgument]; +} +export function newU64(options: NewU64Options) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['u64'] satisfies (string | null)[]; + const parameterNames = ['value']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'config_value', + function: 'new_u64', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface NewAddressArguments { + value: RawTransactionArgument; +} +export interface NewAddressOptions { + package?: string; + arguments: NewAddressArguments | [value: RawTransactionArgument]; +} +export function newAddress(options: NewAddressOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['address'] satisfies (string | null)[]; + const parameterNames = ['value']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'config_value', + function: 'new_address', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface NewStringArguments { + value: RawTransactionArgument; +} +export interface NewStringOptions { + package?: string; + arguments: NewStringArguments | [value: RawTransactionArgument]; +} +export function newString(options: NewStringOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['0x1::string::String'] satisfies (string | null)[]; + const parameterNames = ['value']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'config_value', + function: 'new_string', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface NewBoolArguments { + value: RawTransactionArgument; +} +export interface NewBoolOptions { + package?: string; + arguments: NewBoolArguments | [value: RawTransactionArgument]; +} +export function newBool(options: NewBoolOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['bool'] satisfies (string | null)[]; + const parameterNames = ['value']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'config_value', + function: 'new_bool', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface NewBytesArguments { + value: RawTransactionArgument; +} +export interface NewBytesOptions { + package?: string; + arguments: NewBytesArguments | [value: RawTransactionArgument]; +} +export function newBytes(options: NewBytesOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['vector'] satisfies (string | null)[]; + const parameterNames = ['value']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'config_value', + function: 'new_bytes', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface NewU128Arguments { + value: RawTransactionArgument; +} +export interface NewU128Options { + package?: string; + arguments: NewU128Arguments | [value: RawTransactionArgument]; +} +export function newU128(options: NewU128Options) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['u128'] satisfies (string | null)[]; + const parameterNames = ['value']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'config_value', + function: 'new_u128', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface NewU256Arguments { + value: RawTransactionArgument; +} +export interface NewU256Options { + package?: string; + arguments: NewU256Arguments | [value: RawTransactionArgument]; +} +export function newU256(options: NewU256Options) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['u256'] satisfies (string | null)[]; + const parameterNames = ['value']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'config_value', + function: 'new_u256', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/deposit.ts b/packages/hashi/src/contracts/hashi/deposit.ts new file mode 100644 index 000000000..85ad8a594 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deposit.ts @@ -0,0 +1,186 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * User-facing Bitcoin deposit flow. A depositor registers the UTXO they sent to + * the bridge's address, the committee approves it with a certificate over + * `(request_id, utxo)`, and — after a configurable time delay in which a faulty + * approval can be caught and the service paused — the deposit is confirmed: hBTC + * is minted to the recipient encoded in the UTXO's derivation path and the UTXO + * joins the active pool. Requests that are never confirmed can be + * garbage-collected once they expire. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +import * as utxo from './utxo.js'; +import * as utxo_1 from './utxo.js'; +import * as utxo_2 from './utxo.js'; +import * as committee from './committee.js'; +import * as utxo_3 from './utxo.js'; +const $moduleName = '@local-pkg/hashi::deposit'; +export const DepositConfirmationMessage = new MoveStruct({ + name: `${$moduleName}::DepositConfirmationMessage`, + fields: { + request_id: bcs.Address, + utxo: utxo.Utxo, + }, +}); +export const DepositRequested = new MoveStruct({ + name: `${$moduleName}::DepositRequested`, + fields: { + request_id: bcs.Address, + utxo_id: utxo_1.UtxoId, + amount: bcs.u64(), + derivation_path: bcs.option(bcs.Address), + timestamp_ms: bcs.u64(), + requester_address: bcs.Address, + sui_tx_digest: bcs.vector(bcs.u8()), + }, +}); +export const DepositApproved = new MoveStruct({ + name: `${$moduleName}::DepositApproved`, + fields: { + request_id: bcs.Address, + utxo: utxo_2.Utxo, + cert: committee.CommitteeSignature, + approval_timestamp_ms: bcs.u64(), + }, +}); +export const DepositConfirmed = new MoveStruct({ + name: `${$moduleName}::DepositConfirmed`, + fields: { + request_id: bcs.Address, + utxo: utxo_3.Utxo, + }, +}); +export const ExpiredDepositDeleted = new MoveStruct({ + name: `${$moduleName}::ExpiredDepositDeleted`, + fields: { + request_id: bcs.Address, + }, +}); +export interface DepositArguments { + hashi: RawTransactionArgument; + utxo: RawTransactionArgument; +} +export interface DepositOptions { + package?: string; + arguments: + | DepositArguments + | [hashi: RawTransactionArgument, utxo: RawTransactionArgument]; +} +export function deposit(options: DepositOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, null, '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'utxo']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'deposit', + function: 'deposit', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ApproveDepositArguments { + hashi: RawTransactionArgument; + requestId: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface ApproveDepositOptions { + package?: string; + arguments: + | ApproveDepositArguments + | [ + hashi: RawTransactionArgument, + requestId: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +/** + * First phase of deposit confirmation. Records a committee certificate over + * `(request_id, utxo)` on the request, alongside the approval timestamp, and + * re-inserts the request into the queue. + * + * The approval is not yet final — `confirm_deposit` must be called after the + * configured `bitcoin_deposit_time_delay_ms` has elapsed. The delay gives + * operators a window to detect a faulty or fraudulent committee signature and + * pause the service before funds are minted; while paused, `confirm_deposit` is + * rejected, leaving the approval parked. If the committee rotates during the + * window, the deposit will also need to be re-approved by the new epoch's + * committee. + */ +export function approveDeposit(options: ApproveDepositOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', null, '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'requestId', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'deposit', + function: 'approve_deposit', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ConfirmDepositArguments { + hashi: RawTransactionArgument; + requestId: RawTransactionArgument; +} +export interface ConfirmDepositOptions { + package?: string; + arguments: + | ConfirmDepositArguments + | [hashi: RawTransactionArgument, requestId: RawTransactionArgument]; +} +/** + * Second phase of deposit confirmation. Re-verifies the stored committee + * certificate against the current committee, enforces the time-delay since + * approval, then mints BTC to the recipient (if any) and moves the UTXO into the + * active pool. + * + * Re-verifying against the current committee means an approval from a rotated-out + * committee will not confirm — it must be re-approved by the current committee. + * Aborts if the request was never approved (no stored cert), the cert no longer + * verifies (committee rotated), or the time-delay window has not yet elapsed. + */ +export function confirmDeposit(options: ConfirmDepositOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'requestId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'deposit', + function: 'confirm_deposit', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface DeleteExpiredDepositArguments { + hashi: RawTransactionArgument; + requestId: RawTransactionArgument; +} +export interface DeleteExpiredDepositOptions { + package?: string; + arguments: + | DeleteExpiredDepositArguments + | [hashi: RawTransactionArgument, requestId: RawTransactionArgument]; +} +/** + * Garbage collection: deliberately NOT gated on pause/reconfig — expiry refunds + * nothing (deposits mint on confirmation) and GC must stay callable during an + * emergency pause. + */ +export function deleteExpiredDeposit(options: DeleteExpiredDepositOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'requestId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'deposit', + function: 'delete_expired_deposit', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/deposit_queue.ts b/packages/hashi/src/contracts/hashi/deposit_queue.ts new file mode 100644 index 000000000..b42f722b4 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deposit_queue.ts @@ -0,0 +1,56 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Storage and bookkeeping for Bitcoin deposit requests. Active requests sit in an + * ObjectBag awaiting committee approval and confirmation; confirmed requests move + * to a processed bag, and requests that were never confirmed can be deleted once + * they pass the maximum age. The state transitions themselves (certificate + * verification, minting, time-delay enforcement) are driven by `hashi::deposit`. + */ + +import { MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import * as object_bag from './deps/sui/object_bag.js'; +import * as object_bag_1 from './deps/sui/object_bag.js'; +import * as utxo from './utxo.js'; +import * as committee from './committee.js'; +const $moduleName = '@local-pkg/hashi::deposit_queue'; +export const DepositRequestQueue = new MoveStruct({ + name: `${$moduleName}::DepositRequestQueue`, + fields: { + /** + * Active deposits awaiting confirmation. ObjectBag so DepositRequest UIDs are + * directly accessible via getObject. + */ + requests: object_bag.ObjectBag, + /** Completed deposits (confirmed or expired). */ + processed: object_bag_1.ObjectBag, + }, +}); +export const DepositRequest = new MoveStruct({ + name: `${$moduleName}::DepositRequest`, + fields: { + id: bcs.Address, + sender: bcs.Address, + created_timestamp_ms: bcs.u64(), + sui_tx_digest: bcs.vector(bcs.u8()), + utxo: utxo.Utxo, + /** + * Committee certificate recorded at approval time. `None` until `approve_deposit` + * has been called. + */ + approval_cert: bcs.option(committee.CommitteeSignature), + /** + * Clock timestamp at the moment of approval. `None` until `approve_deposit` has + * been called. + */ + approved_timestamp_ms: bcs.option(bcs.u64()), + /** + * Clock timestamp at the moment of confirmation. `None` until `confirm_deposit` + * has been called. + */ + confirmed_timestamp_ms: bcs.option(bcs.u64()), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/deps/sui/bag.ts b/packages/hashi/src/contracts/hashi/deps/sui/bag.ts new file mode 100644 index 000000000..f8966dc9e --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/bag.ts @@ -0,0 +1,41 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * A bag is a heterogeneous map-like collection. The collection is similar to + * `sui::table` in that its keys and values are not stored within the `Bag` value, + * but instead are stored using Sui's object system. The `Bag` struct acts only as + * a handle into the object system to retrieve those keys and values. Note that + * this means that `Bag` values with exactly the same key-value mapping will not be + * equal, with `==`, at runtime. For example + * + * ``` + * let bag1 = bag::new(); + * let bag2 = bag::new(); + * bag::add(&mut bag1, 0, false); + * bag::add(&mut bag1, 1, true); + * bag::add(&mut bag2, 0, false); + * bag::add(&mut bag2, 1, true); + * // bag1 does not equal bag2, despite having the same entries + * assert!(&bag1 != &bag2); + * ``` + * + * At it's core, `sui::bag` is a wrapper around `UID` that allows for access to + * `sui::dynamic_field` while preventing accidentally stranding field values. A + * `UID` can be deleted, even if it has dynamic fields associated with it, but a + * bag, on the other hand, must be empty to be destroyed. + */ + +import { MoveStruct } from '../../../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +const $moduleName = '0x2::bag'; +export const Bag = new MoveStruct({ + name: `${$moduleName}::Bag`, + fields: { + /** the ID of this bag */ + id: bcs.Address, + /** the number of key-value pairs in the bag */ + size: bcs.u64(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/deps/sui/balance.ts b/packages/hashi/src/contracts/hashi/deps/sui/balance.ts new file mode 100644 index 000000000..e0018f41a --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/balance.ts @@ -0,0 +1,19 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * A storable handler for Balances in general. Is used in the `Coin` module to + * allow balance operations and can be used to implement custom coins with `Supply` + * and `Balance`s. + */ + +import { MoveStruct } from '../../../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +const $moduleName = '0x2::balance'; +export const Balance = new MoveStruct({ + name: `${$moduleName}::Balance`, + fields: { + value: bcs.u64(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/deps/sui/group_ops.ts b/packages/hashi/src/contracts/hashi/deps/sui/group_ops.ts new file mode 100644 index 000000000..3c2c1f8d7 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/group_ops.ts @@ -0,0 +1,15 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** Generic Move and native functions for group operations. */ + +import { MoveStruct } from '../../../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +const $moduleName = '0x2::group_ops'; +export const Element = new MoveStruct({ + name: `${$moduleName}::Element`, + fields: { + bytes: bcs.vector(bcs.u8()), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/deps/sui/linked_table.ts b/packages/hashi/src/contracts/hashi/deps/sui/linked_table.ts new file mode 100644 index 000000000..7c39a37b8 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/linked_table.ts @@ -0,0 +1,27 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Similar to `sui::table` but the values are linked together, allowing for ordered + * insertion and removal + */ + +import { type BcsType, bcs } from '@mysten/sui/bcs'; +import { MoveStruct } from '../../../utils/index.js'; +const $moduleName = '0x2::linked_table'; +export function LinkedTable>(...typeParameters: [K]) { + return new MoveStruct({ + name: `${$moduleName}::LinkedTable<${typeParameters[0].name as K['name']}, phantom V>`, + fields: { + /** the ID of this table */ + id: bcs.Address, + /** the number of key-value pairs in the table */ + size: bcs.u64(), + /** the front of the table, i.e. the key of the first entry */ + head: bcs.option(typeParameters[0]), + /** the back of the table, i.e. the key of the last entry */ + tail: bcs.option(typeParameters[0]), + }, + }); +} diff --git a/packages/hashi/src/contracts/hashi/deps/sui/object_bag.ts b/packages/hashi/src/contracts/hashi/deps/sui/object_bag.ts new file mode 100644 index 000000000..8e7d56db1 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/object_bag.ts @@ -0,0 +1,24 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Similar to `sui::bag`, an `ObjectBag` is a heterogeneous map-like collection. + * But unlike `sui::bag`, the values bound to these dynamic fields _must_ be + * objects themselves. This allows for the objects to still exist in storage, which + * may be important for external tools. The difference is otherwise not observable + * from within Move. + */ + +import { MoveStruct } from '../../../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +const $moduleName = '0x2::object_bag'; +export const ObjectBag = new MoveStruct({ + name: `${$moduleName}::ObjectBag`, + fields: { + /** the ID of this bag */ + id: bcs.Address, + /** the number of key-value pairs in the bag */ + size: bcs.u64(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/deps/sui/package.ts b/packages/hashi/src/contracts/hashi/deps/sui/package.ts new file mode 100644 index 000000000..7c0bec8bd --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/package.ts @@ -0,0 +1,29 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Functions for operating on Move packages from within Move: + * + * - Creating proof-of-publish objects from one-time witnesses + * - Administering package upgrades through upgrade policies. + */ + +import { MoveStruct } from '../../../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +const $moduleName = '0x2::package'; +export const UpgradeCap = new MoveStruct({ + name: `${$moduleName}::UpgradeCap`, + fields: { + id: bcs.Address, + /** (Mutable) ID of the package that can be upgraded. */ + package: bcs.Address, + /** + * (Mutable) The number of upgrades that have been applied successively to the + * original package. Initially 0. + */ + version: bcs.u64(), + /** What kind of upgrades are allowed. */ + policy: bcs.u8(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/deps/sui/table.ts b/packages/hashi/src/contracts/hashi/deps/sui/table.ts new file mode 100644 index 000000000..41e18b1d1 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/table.ts @@ -0,0 +1,36 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * A table is a map-like collection. But unlike a traditional collection, it's keys + * and values are not stored within the `Table` value, but instead are stored using + * Sui's object system. The `Table` struct acts only as a handle into the object + * system to retrieve those keys and values. Note that this means that `Table` + * values with exactly the same key-value mapping will not be equal, with `==`, at + * runtime. For example + * + * ``` + * let table1 = table::new(); + * let table2 = table::new(); + * table::add(&mut table1, 0, false); + * table::add(&mut table1, 1, true); + * table::add(&mut table2, 0, false); + * table::add(&mut table2, 1, true); + * // table1 does not equal table2, despite having the same entries + * assert!(&table1 != &table2); + * ``` + */ + +import { MoveStruct } from '../../../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +const $moduleName = '0x2::table'; +export const Table = new MoveStruct({ + name: `${$moduleName}::Table`, + fields: { + /** the ID of this table */ + id: bcs.Address, + /** the number of key-value pairs in the table */ + size: bcs.u64(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/deps/sui/vec_map.ts b/packages/hashi/src/contracts/hashi/deps/sui/vec_map.ts new file mode 100644 index 000000000..3d90a6772 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/vec_map.ts @@ -0,0 +1,33 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ +import { type BcsType, bcs } from '@mysten/sui/bcs'; +import { MoveStruct } from '../../../utils/index.js'; +const $moduleName = '0x2::vec_map'; +/** An entry in the map */ +export function Entry, V extends BcsType>(...typeParameters: [K, V]) { + return new MoveStruct({ + name: `${$moduleName}::Entry<${typeParameters[0].name as K['name']}, ${typeParameters[1].name as V['name']}>`, + fields: { + key: typeParameters[0], + value: typeParameters[1], + }, + }); +} +/** + * A map data structure backed by a vector. The map is guaranteed not to contain + * duplicate keys, but entries are _not_ sorted by key--entries are included in + * insertion order. All operations are O(N) in the size of the map--the intention + * of this data structure is only to provide the convenience of programming against + * a map API. Large maps should use handwritten parent/child relationships instead. + * Maps that need sorted iteration rather than insertion order iteration should + * also be handwritten. + */ +export function VecMap, V extends BcsType>(...typeParameters: [K, V]) { + return new MoveStruct({ + name: `${$moduleName}::VecMap<${typeParameters[0].name as K['name']}, ${typeParameters[1].name as V['name']}>`, + fields: { + contents: bcs.vector(Entry(typeParameters[0], typeParameters[1])), + }, + }); +} diff --git a/packages/hashi/src/contracts/hashi/deps/sui/vec_set.ts b/packages/hashi/src/contracts/hashi/deps/sui/vec_set.ts new file mode 100644 index 000000000..f73afa257 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/deps/sui/vec_set.ts @@ -0,0 +1,22 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ +import { type BcsType, bcs } from '@mysten/sui/bcs'; +import { MoveStruct } from '../../../utils/index.js'; +const $moduleName = '0x2::vec_set'; +/** + * A set data structure backed by a vector. The set is guaranteed not to contain + * duplicate keys. All operations are O(N) in the size of the set + * + * - the intention of this data structure is only to provide the convenience of + * programming against a set API. Sets that need sorted iteration rather than + * insertion order iteration should be handwritten. + */ +export function VecSet>(...typeParameters: [K]) { + return new MoveStruct({ + name: `${$moduleName}::VecSet<${typeParameters[0].name as K['name']}>`, + fields: { + contents: bcs.vector(typeParameters[0]), + }, + }); +} diff --git a/packages/hashi/src/contracts/hashi/disable_version.ts b/packages/hashi/src/contracts/hashi/disable_version.ts new file mode 100644 index 000000000..6b7b5bb59 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/disable_version.ts @@ -0,0 +1,75 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Governance proposal for disabling a package version. Once quorum is reached, + * `execute` marks the proposed version as disabled in `versioning`, so every entry + * point guarded by `assert_version_enabled` stops serving calls made through that + * version — the recovery lever if an upgraded package turns out to be broken. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::disable_version'; +export const DisableVersion = new MoveStruct({ + name: `${$moduleName}::DisableVersion`, + fields: { + version: bcs.u64(), + }, +}); +export interface ProposeArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + version: RawTransactionArgument; + metadata: RawTransactionArgument; +} +export interface ProposeOptions { + package?: string; + arguments: + | ProposeArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + version: RawTransactionArgument, + metadata: RawTransactionArgument, + ]; +} +export function propose(options: ProposeOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'u64', null, '0x2::clock::Clock'] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'validatorAddress', 'version', 'metadata']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'disable_version', + function: 'propose', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ExecuteArguments { + hashi: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface ExecuteOptions { + package?: string; + arguments: + | ExecuteArguments + | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; +} +export function execute(options: ExecuteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'disable_version', + function: 'execute', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/emergency_pause.ts b/packages/hashi/src/contracts/hashi/emergency_pause.ts new file mode 100644 index 000000000..cede33d3c --- /dev/null +++ b/packages/hashi/src/contracts/hashi/emergency_pause.ts @@ -0,0 +1,75 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Emergency pause/unpause governance module. + * + * A single proposal type that can either pause or unpause the bridge. Pausing uses + * a low quorum for fast response; unpausing requires supermajority. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::emergency_pause'; +export const EmergencyPause = new MoveStruct({ + name: `${$moduleName}::EmergencyPause`, + fields: { + pause: bcs.bool(), + }, +}); +export interface ProposeArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + pause: RawTransactionArgument; + metadata: RawTransactionArgument; +} +export interface ProposeOptions { + package?: string; + arguments: + | ProposeArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + pause: RawTransactionArgument, + metadata: RawTransactionArgument, + ]; +} +export function propose(options: ProposeOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'bool', null, '0x2::clock::Clock'] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'validatorAddress', 'pause', 'metadata']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'emergency_pause', + function: 'propose', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ExecuteArguments { + hashi: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface ExecuteOptions { + package?: string; + arguments: + | ExecuteArguments + | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; +} +export function execute(options: ExecuteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'emergency_pause', + function: 'execute', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/enable_version.ts b/packages/hashi/src/contracts/hashi/enable_version.ts new file mode 100644 index 000000000..58623f19c --- /dev/null +++ b/packages/hashi/src/contracts/hashi/enable_version.ts @@ -0,0 +1,76 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Governance proposal for enabling a package version. Once quorum is reached, + * `execute` marks the proposed version as enabled in `versioning`, re-admitting + * calls made through that version at entry points guarded by + * `assert_version_enabled` — the counterpart to `disable_version` for + * re-activating a previously disabled version. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::enable_version'; +export const EnableVersion = new MoveStruct({ + name: `${$moduleName}::EnableVersion`, + fields: { + version: bcs.u64(), + }, +}); +export interface ProposeArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + version: RawTransactionArgument; + metadata: RawTransactionArgument; +} +export interface ProposeOptions { + package?: string; + arguments: + | ProposeArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + version: RawTransactionArgument, + metadata: RawTransactionArgument, + ]; +} +export function propose(options: ProposeOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'u64', null, '0x2::clock::Clock'] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'validatorAddress', 'version', 'metadata']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'enable_version', + function: 'propose', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ExecuteArguments { + hashi: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface ExecuteOptions { + package?: string; + arguments: + | ExecuteArguments + | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; +} +export function execute(options: ExecuteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'enable_version', + function: 'execute', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/hashi.ts b/packages/hashi/src/contracts/hashi/hashi.ts new file mode 100644 index 000000000..58bccfd83 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/hashi.ts @@ -0,0 +1,97 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * The root shared object of the bridge. `Hashi` aggregates every subsystem — + * committee set, config, versioning, treasury, governance proposals, and TOB + * certificate storage — and hangs per-chain state (e.g. `BitcoinState`) off its + * `UID` as dynamic fields. It also provides the package-wide guards (pause, + * reconfig, committee-signature verification) that entry functions in other + * modules call through, and the one-time `finish_publish` launch switch that hands + * the package `UpgradeCap` into on-chain custody. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +import * as committee_set from './committee_set.js'; +import * as config from './config.js'; +import * as versioning from './versioning.js'; +import * as treasury from './treasury.js'; +import * as proposals from './proposals.js'; +import * as bag from './deps/sui/bag.js'; +const $moduleName = '@local-pkg/hashi::hashi'; +export const Hashi = new MoveStruct({ + name: `${$moduleName}::Hashi`, + fields: { + id: bcs.Address, + committee_set: committee_set.CommitteeSet, + config: config.Config, + versioning: versioning.Versioning, + treasury: treasury.Treasury, + proposals: proposals.Proposals, + /** TOB certificates by (epoch, batch_index) -> EpochCertsV1 */ + tob: bag.Bag, + /** + * Number of presignatures consumed in the current epoch. Used by recovering nodes + * to derive `(batch_index, index_in_batch)`. + */ + num_consumed_presigs: bcs.u64(), + }, +}); +export interface FinishPublishArguments { + self: RawTransactionArgument; + upgradeCap: RawTransactionArgument; + bitcoinChainId: RawTransactionArgument; + guardianUrl: RawTransactionArgument; + guardianBtcPublicKey: RawTransactionArgument; + bitcoinConfirmationThreshold: RawTransactionArgument; + bitcoinDepositTimeDelayMs: RawTransactionArgument; + coinRegistry: RawTransactionArgument; +} +export interface FinishPublishOptions { + package?: string; + arguments: + | FinishPublishArguments + | [ + self: RawTransactionArgument, + upgradeCap: RawTransactionArgument, + bitcoinChainId: RawTransactionArgument, + guardianUrl: RawTransactionArgument, + guardianBtcPublicKey: RawTransactionArgument, + bitcoinConfirmationThreshold: RawTransactionArgument, + bitcoinDepositTimeDelayMs: RawTransactionArgument, + coinRegistry: RawTransactionArgument, + ]; +} +export function finishPublish(options: FinishPublishOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [ + null, + null, + 'address', + '0x1::string::String', + 'vector', + '0x1::option::Option', + '0x1::option::Option', + null, + ] satisfies (string | null)[]; + const parameterNames = [ + 'self', + 'upgradeCap', + 'bitcoinChainId', + 'guardianUrl', + 'guardianBtcPublicKey', + 'bitcoinConfirmationThreshold', + 'bitcoinDepositTimeDelayMs', + 'coinRegistry', + ]; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'hashi', + function: 'finish_publish', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/mpc_signing.ts b/packages/hashi/src/contracts/hashi/mpc_signing.ts new file mode 100644 index 000000000..deafd2e1a --- /dev/null +++ b/packages/hashi/src/contracts/hashi/mpc_signing.ts @@ -0,0 +1,60 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Durable, out-of-order accumulator for a withdrawal's per-input threshold Schnorr + * signatures. This is the MPC-protocol side of incremental signing: the dangerous + * presignature / nonce bookkeeping lives here, behind a module boundary, and is + * embedded (field-private) inside the BTC `WithdrawalTransaction` rather than + * stored as a separate object. + * + * Each input occupies one slot that is either: + * + * - `Pending(presig_index)` — awaiting its signature; carries the presignature + * index it will consume (valid within `epoch`), or + * - `Signed(bytes)` — the completed per-input MPC signature. + * + * Signatures are filled in any order (`record`), survive leader timeouts / + * rotation / restart because they live on chain, and survive committee + * reconfiguration: on an epoch change only the still-`Pending` slots are + * reassigned fresh presignatures (`reallocate`); `Signed` slots are final and + * epoch-independent (the committee group key is stable across rotation). + * + * NONCE SAFETY (a violation leaks the group secret share): + * + * - every `Pending` index is unique within (batch, epoch) — `new` / `reallocate` + * assign distinct offsets from a freshly allocated block; + * - indices are globally disjoint within an epoch — the allocator is monotonic + * (see `hashi::allocate_presigs`); + * - a stale-epoch index is never used after a reconfig — `reallocate` overwrites + * EVERY `Pending` slot before any signing happens in the new epoch, and the + * caller must `reallocate` whenever `epoch` is stale; + * - a `Signed` slot holds no index, so there is nothing stale to reuse. + */ + +import { MoveEnum, MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +const $moduleName = '@local-pkg/hashi::mpc_signing'; +/** Per-input signing slot. */ +export const MpcSig = new MoveEnum({ + name: `${$moduleName}::MpcSig`, + fields: { + /** + * Awaiting signature; holds the presignature index this input will consume, valid + * within the owning batch's `epoch`. + */ + Pending: bcs.u64(), + /** Completed per-input MPC Schnorr signature bytes. */ + Signed: bcs.vector(bcs.u8()), + }, +}); +export const SigningBatch = new MoveStruct({ + name: `${$moduleName}::SigningBatch`, + fields: { + /** One slot per input; same length/order as the withdrawal's inputs. */ + signatures: bcs.vector(MpcSig), + /** Epoch the `Pending` presignature indices belong to. */ + epoch: bcs.u64(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/proposal.ts b/packages/hashi/src/contracts/hashi/proposal.ts new file mode 100644 index 000000000..e8242beea --- /dev/null +++ b/packages/hashi/src/contracts/hashi/proposal.ts @@ -0,0 +1,162 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Generic quorum-voting machinery shared by every governance action. A + * `Proposal` wraps a typed payload `T` (defined by the proposal-type modules + * under `types/`) together with the votes it has gathered; committee members vote + * by weight, and once the proposal's quorum threshold is reached it can be + * executed exactly once, releasing the payload to the executing module and + * archiving the proposal. Proposals expire after seven days, after which + * unexecuted ones may be deleted. + */ + +import { type BcsType, bcs } from '@mysten/sui/bcs'; +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { type Transaction } from '@mysten/sui/transactions'; +import * as vec_map from './deps/sui/vec_map.js'; +const $moduleName = '@local-pkg/hashi::proposal'; +export function Proposal>(...typeParameters: [T]) { + return new MoveStruct({ + name: `${$moduleName}::Proposal<${typeParameters[0].name as T['name']}>`, + fields: { + id: bcs.Address, + creator: bcs.Address, + votes: bcs.vector(bcs.Address), + quorum_threshold_bps: bcs.u64(), + created_timestamp_ms: bcs.u64(), + /** Clock timestamp at execution. `None` until the proposal executes. */ + executed_timestamp_ms: bcs.option(bcs.u64()), + metadata: vec_map.VecMap(bcs.string(), bcs.string()), + data: typeParameters[0], + }, + }); +} +export const ProposalCreated = new MoveStruct({ + name: `${$moduleName}::ProposalCreated`, + fields: { + proposal_id: bcs.Address, + timestamp_ms: bcs.u64(), + }, +}); +export const VoteCast = new MoveStruct({ + name: `${$moduleName}::VoteCast`, + fields: { + proposal_id: bcs.Address, + voter: bcs.Address, + }, +}); +export const VoteRemoved = new MoveStruct({ + name: `${$moduleName}::VoteRemoved`, + fields: { + proposal_id: bcs.Address, + voter: bcs.Address, + }, +}); +export const ProposalDeleted = new MoveStruct({ + name: `${$moduleName}::ProposalDeleted`, + fields: { + proposal_id: bcs.Address, + }, +}); +export function ProposalExecuted>(...typeParameters: [T]) { + return new MoveStruct({ + name: `${$moduleName}::ProposalExecuted<${typeParameters[0].name as T['name']}>`, + fields: { + proposal_id: bcs.Address, + data: typeParameters[0], + }, + }); +} +export const QuorumReached = new MoveStruct({ + name: `${$moduleName}::QuorumReached`, + fields: { + proposal_id: bcs.Address, + }, +}); +export interface VoteArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface VoteOptions { + package?: string; + arguments: + | VoteArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + proposalId: RawTransactionArgument, + ]; + typeArguments: [string]; +} +export function vote(options: VoteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', '0x2::object::ID', '0x2::clock::Clock'] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'validatorAddress', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'proposal', + function: 'vote', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface RemoveVoteArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface RemoveVoteOptions { + package?: string; + arguments: + | RemoveVoteArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + proposalId: RawTransactionArgument, + ]; + typeArguments: [string]; +} +export function removeVote(options: RemoveVoteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', '0x2::object::ID'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'validatorAddress', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'proposal', + function: 'remove_vote', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface DeleteExpiredArguments { + hashi: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface DeleteExpiredOptions { + package?: string; + arguments: + | DeleteExpiredArguments + | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; + typeArguments: [string]; +} +export function deleteExpired(options: DeleteExpiredOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'proposal', + function: 'delete_expired', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} diff --git a/packages/hashi/src/contracts/hashi/proposal_events.ts b/packages/hashi/src/contracts/hashi/proposal_events.ts new file mode 100644 index 000000000..23b4b3ee9 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/proposal_events.ts @@ -0,0 +1,55 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ +import { MoveStruct } from '../utils/index.js'; +import { bcs, type BcsType } from '@mysten/sui/bcs'; +const $moduleName = '@local-pkg/hashi::proposal_events'; +export const ProposalCreatedEvent = new MoveStruct({ + name: `${$moduleName}::ProposalCreatedEvent`, + fields: { + proposal_id: bcs.Address, + timestamp_ms: bcs.u64(), + }, +}); +export const VoteCastEvent = new MoveStruct({ + name: `${$moduleName}::VoteCastEvent`, + fields: { + proposal_id: bcs.Address, + voter: bcs.Address, + }, +}); +export const VoteRemovedEvent = new MoveStruct({ + name: `${$moduleName}::VoteRemovedEvent`, + fields: { + proposal_id: bcs.Address, + voter: bcs.Address, + }, +}); +export const ProposalDeletedEvent = new MoveStruct({ + name: `${$moduleName}::ProposalDeletedEvent`, + fields: { + proposal_id: bcs.Address, + }, +}); +export function ProposalExecutedEvent>(...typeParameters: [T]) { + return new MoveStruct({ + name: `${$moduleName}::ProposalExecutedEvent<${typeParameters[0].name as T['name']}>`, + fields: { + proposal_id: bcs.Address, + data: typeParameters[0], + }, + }); +} +export const QuorumReachedEvent = new MoveStruct({ + name: `${$moduleName}::QuorumReachedEvent`, + fields: { + proposal_id: bcs.Address, + }, +}); +export const PackageUpgradedEvent = new MoveStruct({ + name: `${$moduleName}::PackageUpgradedEvent`, + fields: { + package: bcs.Address, + version: bcs.u64(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/proposals.ts b/packages/hashi/src/contracts/hashi/proposals.ts new file mode 100644 index 000000000..4243145b4 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/proposals.ts @@ -0,0 +1,27 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Container for the package's governance proposals, hung off `Hashi`. Active and + * executed proposals live in two separate object bags so that each set can be + * enumerated directly, and executed proposals are archived indefinitely to keep + * historical governance actions inspectable. + */ + +import { MoveStruct } from '../utils/index.js'; +import * as object_bag from './deps/sui/object_bag.js'; +import * as object_bag_1 from './deps/sui/object_bag.js'; +const $moduleName = '@local-pkg/hashi::proposals'; +export const Proposals = new MoveStruct({ + name: `${$moduleName}::Proposals`, + fields: { + /** Proposals that have been created but not yet executed. */ + active: object_bag.ObjectBag, + /** + * Proposals that have executed successfully. Kept indefinitely so historical + * governance actions remain inspectable. + */ + executed: object_bag_1.ObjectBag, + }, +}); diff --git a/packages/hashi/src/contracts/hashi/reconfig.ts b/packages/hashi/src/contracts/hashi/reconfig.ts new file mode 100644 index 000000000..1ebb16944 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/reconfig.ts @@ -0,0 +1,118 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Committee reconfiguration entry points. `start_reconfig` forms the next + * committee from Sui's active validator set (pinning the governed MPC parameters + * for the new epoch), `submit_committee_handoff` records the outgoing committee's + * certificate approving the incoming committee, and `end_reconfig` verifies the + * new committee's certificate over the MPC threshold public key and activates the + * epoch. The initial (genesis) reconfig skips the handoff — no prior committee + * exists — and is gated on the publisher's launch switch + * (`hashi::finish_publish`). + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +import * as committee from './committee.js'; +const $moduleName = '@local-pkg/hashi::reconfig'; +export const ReconfigCompletionMessage = new MoveStruct({ + name: `${$moduleName}::ReconfigCompletionMessage`, + fields: { + /** The epoch of the new committee. */ + epoch: bcs.u64(), + /** The MPC committee's threshold public key. */ + mpc_public_key: bcs.vector(bcs.u8()), + }, +}); +export const CommitteeTransitionRequest = new MoveStruct({ + name: `${$moduleName}::CommitteeTransitionRequest`, + fields: { + new_committee: committee.Committee, + }, +}); +export const ReconfigStarted = new MoveStruct({ + name: `${$moduleName}::ReconfigStarted`, + fields: { + epoch: bcs.u64(), + }, +}); +export const ReconfigEnded = new MoveStruct({ + name: `${$moduleName}::ReconfigEnded`, + fields: { + from_epoch: bcs.u64(), + epoch: bcs.u64(), + /** The MPC committee's threshold public key. */ + mpc_public_key: bcs.vector(bcs.u8()), + }, +}); +export interface StartReconfigArguments { + self: RawTransactionArgument; +} +export interface StartReconfigOptions { + package?: string; + arguments: StartReconfigArguments | [self: RawTransactionArgument]; +} +export function startReconfig(options: StartReconfigOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x3::sui_system::SuiSystemState'] satisfies (string | null)[]; + const parameterNames = ['self']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'reconfig', + function: 'start_reconfig', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface EndReconfigArguments { + self: RawTransactionArgument; + mpcPublicKey: RawTransactionArgument; + mpcCert: RawTransactionArgument; +} +export interface EndReconfigOptions { + package?: string; + arguments: + | EndReconfigArguments + | [ + self: RawTransactionArgument, + mpcPublicKey: RawTransactionArgument, + mpcCert: RawTransactionArgument, + ]; +} +export function endReconfig(options: EndReconfigOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'vector', null] satisfies (string | null)[]; + const parameterNames = ['self', 'mpcPublicKey', 'mpcCert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'reconfig', + function: 'end_reconfig', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface SubmitCommitteeHandoffArguments { + self: RawTransactionArgument; + committeeHandoffCert: RawTransactionArgument; +} +export interface SubmitCommitteeHandoffOptions { + package?: string; + arguments: + | SubmitCommitteeHandoffArguments + | [self: RawTransactionArgument, committeeHandoffCert: RawTransactionArgument]; +} +export function submitCommitteeHandoff(options: SubmitCommitteeHandoffOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['self', 'committeeHandoffCert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'reconfig', + function: 'submit_committee_handoff', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/tob.ts b/packages/hashi/src/contracts/hashi/tob.ts new file mode 100644 index 000000000..5b48e9db0 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/tob.ts @@ -0,0 +1,57 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Totally Ordered Broadcast (TOB) certificate storage for MPC ceremonies. Dealer + * submissions — a dealer-messages hash plus its committee signature — are bucketed + * per (epoch, optional batch, protocol type) in `EpochCertsV1`, + * first-submission-wins per dealer. Signature verification is deferred to + * off-chain readers, and a bucket may be destroyed once the current epoch is at + * least two past the bucket's epoch. + */ + +import { MoveEnum, MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import * as linked_table from './deps/sui/linked_table.js'; +import * as committee from './committee.js'; +const $moduleName = '@local-pkg/hashi::tob'; +export const ProtocolType = new MoveEnum({ + name: `${$moduleName}::ProtocolType`, + fields: { + Dkg: null, + KeyRotation: null, + NonceGeneration: null, + }, +}); +export const TobKey = new MoveStruct({ + name: `${$moduleName}::TobKey`, + fields: { + epoch: bcs.u64(), + batch_index: bcs.option(bcs.u32()), + protocol_type: ProtocolType, + }, +}); +export const EpochCertsV1 = new MoveStruct({ + name: `${$moduleName}::EpochCertsV1`, + fields: { + epoch: bcs.u64(), + protocol_type: ProtocolType, + /** Dealer submissions indexed by dealer address (first-submission-wins). */ + certs: linked_table.LinkedTable(bcs.Address), + }, +}); +export const DealerMessagesHashV1 = new MoveStruct({ + name: `${$moduleName}::DealerMessagesHashV1`, + fields: { + dealer_address: bcs.Address, + messages_hash: bcs.vector(bcs.u8()), + }, +}); +export const DealerSubmissionV1 = new MoveStruct({ + name: `${$moduleName}::DealerSubmissionV1`, + fields: { + message: DealerMessagesHashV1, + signature: committee.CommitteeSignature, + }, +}); diff --git a/packages/hashi/src/contracts/hashi/treasury.ts b/packages/hashi/src/contracts/hashi/treasury.ts new file mode 100644 index 000000000..fb611e420 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/treasury.ts @@ -0,0 +1,39 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Custody of the coin capabilities for bridge-issued assets. `Treasury` holds the + * `TreasuryCap` and `MetadataCap` of each registered coin type in an `ObjectBag` + * keyed by cap type, and exposes package-only mint/burn that emit + * `Minted`/`Burned` events for off-chain watchers. + */ + +import { MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import * as object_bag from './deps/sui/object_bag.js'; +const $moduleName = '@local-pkg/hashi::treasury'; +export const Treasury = new MoveStruct({ + name: `${$moduleName}::Treasury`, + fields: { + objects: object_bag.ObjectBag, + }, +}); +export const Key = new MoveStruct({ + name: `${$moduleName}::Key`, + fields: { + dummy_field: bcs.bool(), + }, +}); +export const Minted = new MoveStruct({ + name: `${$moduleName}::Minted`, + fields: { + amount: bcs.u64(), + }, +}); +export const Burned = new MoveStruct({ + name: `${$moduleName}::Burned`, + fields: { + amount: bcs.u64(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/update_config.ts b/packages/hashi/src/contracts/hashi/update_config.ts new file mode 100644 index 000000000..cf0256358 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/update_config.ts @@ -0,0 +1,78 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Governance proposal for updating entries in the global config. A proposal + * carries a map of key/value entries; on execution every entry must refer to an + * existing key with a matching value type (and pass MPC-config range validation) + * before being upserted, so governance can tune parameters but never introduce + * unknown keys or change an entry's type. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +import * as vec_map from './deps/sui/vec_map.js'; +import * as config_value from './config_value.js'; +const $moduleName = '@local-pkg/hashi::update_config'; +export const UpdateConfig = new MoveStruct({ + name: `${$moduleName}::UpdateConfig`, + fields: { + entries: vec_map.VecMap(bcs.string(), config_value.Value), + }, +}); +export interface ProposeArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + entries: RawTransactionArgument; + metadata: RawTransactionArgument; +} +export interface ProposeOptions { + package?: string; + arguments: + | ProposeArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + entries: RawTransactionArgument, + metadata: RawTransactionArgument, + ]; +} +export function propose(options: ProposeOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', null, null, '0x2::clock::Clock'] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'validatorAddress', 'entries', 'metadata']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'update_config', + function: 'propose', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ExecuteArguments { + hashi: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface ExecuteOptions { + package?: string; + arguments: + | ExecuteArguments + | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; +} +export function execute(options: ExecuteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'update_config', + function: 'execute', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/update_guardian.ts b/packages/hashi/src/contracts/hashi/update_guardian.ts new file mode 100644 index 000000000..228f2f928 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/update_guardian.ts @@ -0,0 +1,79 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Governance proposal for updating the guardian's URL in the global config. Only + * the URL is governable: the guardian's BTC public key is immutable once set + * (rotating it would invalidate derived deposit addresses), and the ephemeral + * signing key is intentionally not pinned on-chain — nodes authenticate the + * guardian over TLS plus the immutable BTC key. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::update_guardian'; +export const UpdateGuardian = new MoveStruct({ + name: `${$moduleName}::UpdateGuardian`, + fields: { + url: bcs.string(), + }, +}); +export interface ProposeArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + url: RawTransactionArgument; + metadata: RawTransactionArgument; +} +export interface ProposeOptions { + package?: string; + arguments: + | ProposeArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + url: RawTransactionArgument, + metadata: RawTransactionArgument, + ]; +} +export function propose(options: ProposeOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [ + null, + 'address', + '0x1::string::String', + null, + '0x2::clock::Clock', + ] satisfies (string | null)[]; + const parameterNames = ['hashi', 'validatorAddress', 'url', 'metadata']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'update_guardian', + function: 'propose', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ExecuteArguments { + hashi: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface ExecuteOptions { + package?: string; + arguments: + | ExecuteArguments + | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; +} +export function execute(options: ExecuteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'update_guardian', + function: 'execute', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/upgrade.ts b/packages/hashi/src/contracts/hashi/upgrade.ts new file mode 100644 index 000000000..9c6cd5789 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/upgrade.ts @@ -0,0 +1,119 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Package upgrade governance module. + * + * ## Upgrade Flow + * + * 1. A committee member calls `upgrade::propose()` with the new package digest + * 2. Committee members vote on the `Proposal` until quorum is reached + * 3. `upgrade::execute(Proposal, &mut Hashi)` -> `UpgradeTicket` + * - Authorizes the upgrade using the stored `UpgradeCap` + * 4. `sui::package::upgrade(UpgradeTicket, ...)` -> `UpgradeReceipt` + * - Performed by the Sui runtime during package publish transaction + * 5. `versioning::commit_upgrade(UpgradeReceipt)` + * - Commits the upgrade to the `UpgradeCap` and auto-enables the new version + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::upgrade'; +export const Upgrade = new MoveStruct({ + name: `${$moduleName}::Upgrade`, + fields: { + digest: bcs.vector(bcs.u8()), + }, +}); +export const PackageUpgraded = new MoveStruct({ + name: `${$moduleName}::PackageUpgraded`, + fields: { + package: bcs.Address, + version: bcs.u64(), + }, +}); +export interface ProposeArguments { + hashi: RawTransactionArgument; + validatorAddress: RawTransactionArgument; + digest: RawTransactionArgument; + metadata: RawTransactionArgument; +} +export interface ProposeOptions { + package?: string; + arguments: + | ProposeArguments + | [ + hashi: RawTransactionArgument, + validatorAddress: RawTransactionArgument, + digest: RawTransactionArgument, + metadata: RawTransactionArgument, + ]; +} +export function propose(options: ProposeOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'vector', null, '0x2::clock::Clock'] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'validatorAddress', 'digest', 'metadata']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'upgrade', + function: 'propose', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ExecuteArguments { + hashi: RawTransactionArgument; + proposalId: RawTransactionArgument; +} +export interface ExecuteOptions { + package?: string; + arguments: + | ExecuteArguments + | [hashi: RawTransactionArgument, proposalId: RawTransactionArgument]; +} +/** + * Executes an approved upgrade proposal. + * + * Returns an `UpgradeTicket` that must be used in the same transaction to publish + * the new package. The Sui runtime will return an `UpgradeReceipt` which must then + * be passed to `finalize_upgrade()` to finalize the upgrade. + */ +export function execute(options: ExecuteOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::object::ID', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'proposalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'upgrade', + function: 'execute', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface FinalizeUpgradeArguments { + hashi: RawTransactionArgument; + receipt: RawTransactionArgument; +} +export interface FinalizeUpgradeOptions { + package?: string; + arguments: + | FinalizeUpgradeArguments + | [hashi: RawTransactionArgument, receipt: RawTransactionArgument]; +} +export function finalizeUpgrade(options: FinalizeUpgradeOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['hashi', 'receipt']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'upgrade', + function: 'finalize_upgrade', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/utxo.ts b/packages/hashi/src/contracts/hashi/utxo.ts new file mode 100644 index 000000000..d25ac02e6 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/utxo.ts @@ -0,0 +1,80 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Bitcoin UTXO value types shared by the deposit and withdrawal flows. A `UtxoId` + * identifies an outpoint (txid:vout) and a `Utxo` pairs it with its satoshi amount + * and an optional derivation path (the Sui address a deposit mints to). The + * constructors are `public` so PTBs can assemble UTXOs when calling into the + * bridge; everything else is package-only. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::utxo'; +export const UtxoId = new MoveStruct({ + name: `${$moduleName}::UtxoId`, + fields: { + txid: bcs.Address, + vout: bcs.u32(), + }, +}); +export const Utxo = new MoveStruct({ + name: `${$moduleName}::Utxo`, + fields: { + id: UtxoId, + amount: bcs.u64(), + derivation_path: bcs.option(bcs.Address), + }, +}); +export interface UtxoIdArguments { + txid: RawTransactionArgument; + vout: RawTransactionArgument; +} +export interface UtxoIdOptions { + package?: string; + arguments: + | UtxoIdArguments + | [txid: RawTransactionArgument, vout: RawTransactionArgument]; +} +export function utxoId(options: UtxoIdOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['address', 'u32'] satisfies (string | null)[]; + const parameterNames = ['txid', 'vout']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'utxo', + function: 'utxo_id', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface UtxoArguments { + utxoId: RawTransactionArgument; + amount: RawTransactionArgument; + derivationPath: RawTransactionArgument; +} +export interface UtxoOptions { + package?: string; + arguments: + | UtxoArguments + | [ + utxoId: RawTransactionArgument, + amount: RawTransactionArgument, + derivationPath: RawTransactionArgument, + ]; +} +export function utxo(options: UtxoOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'u64', '0x1::option::Option
'] satisfies (string | null)[]; + const parameterNames = ['utxoId', 'amount', 'derivationPath']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'utxo', + function: 'utxo', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/utxo_pool.ts b/packages/hashi/src/contracts/hashi/utxo_pool.ts new file mode 100644 index 000000000..85baff386 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/utxo_pool.ts @@ -0,0 +1,42 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * On-chain bookkeeping for the bridge's Bitcoin UTXO set. Confirmed deposit + * outputs and unconfirmed withdrawal change outputs live in `utxo_records` from + * insertion until the withdrawal that spends them confirms on Bitcoin, after which + * their IDs move to `spent_utxos` — kept permanently as replay protection so an + * already-spent outpoint can never be re-inserted. + */ + +import { MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import * as bag from './deps/sui/bag.js'; +import * as bag_1 from './deps/sui/bag.js'; +import * as utxo from './utxo.js'; +import * as utxo_1 from './utxo.js'; +const $moduleName = '@local-pkg/hashi::utxo_pool'; +export const UtxoPool = new MoveStruct({ + name: `${$moduleName}::UtxoPool`, + fields: { + utxo_records: bag.Bag, + spent_utxos: bag_1.Bag, + }, +}); +export const UtxoRecord = new MoveStruct({ + name: `${$moduleName}::UtxoRecord`, + fields: { + utxo: utxo.Utxo, + produced_by: bcs.option(bcs.Address), + spent_by: bcs.option(bcs.Address), + spent_epoch: bcs.option(bcs.u64()), + }, +}); +export const UtxoSpent = new MoveStruct({ + name: `${$moduleName}::UtxoSpent`, + fields: { + utxo_id: utxo_1.UtxoId, + spent_epoch: bcs.u64(), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/validator.ts b/packages/hashi/src/contracts/hashi/validator.ts new file mode 100644 index 000000000..22a361e46 --- /dev/null +++ b/packages/hashi/src/contracts/hashi/validator.ts @@ -0,0 +1,191 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Validator registration and metadata maintenance. Entry points let a Sui + * validator register as a Hashi committee member and update its next-epoch BLS + * key, operator address, endpoint URL, TLS key, and next-epoch encryption key. + * Every mutation emits an event for off-chain watchers. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +const $moduleName = '@local-pkg/hashi::validator'; +export const ValidatorRegistered = new MoveStruct({ + name: `${$moduleName}::ValidatorRegistered`, + fields: { + validator: bcs.Address, + }, +}); +export const ValidatorUpdated = new MoveStruct({ + name: `${$moduleName}::ValidatorUpdated`, + fields: { + validator: bcs.Address, + }, +}); +export interface RegisterArguments { + self: RawTransactionArgument; +} +export interface RegisterOptions { + package?: string; + arguments: RegisterArguments | [self: RawTransactionArgument]; +} +/** + * Registration and key/metadata updates (below) are deliberately NOT gated on + * pause/reconfig: operators must be able to rotate keys and prepare nodes while + * the system is paused, and blocking updates during reconfig would let a stalled + * reconfig freeze operator maintenance. + */ +export function register(options: RegisterOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x3::sui_system::SuiSystemState'] satisfies (string | null)[]; + const parameterNames = ['self']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'validator', + function: 'register', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface UpdateNextEpochPublicKeyArguments { + self: RawTransactionArgument; + validator: RawTransactionArgument; + nextEpochPublicKey: RawTransactionArgument; + proofOfPossessionSignature: RawTransactionArgument; +} +export interface UpdateNextEpochPublicKeyOptions { + package?: string; + arguments: + | UpdateNextEpochPublicKeyArguments + | [ + self: RawTransactionArgument, + validator: RawTransactionArgument, + nextEpochPublicKey: RawTransactionArgument, + proofOfPossessionSignature: RawTransactionArgument, + ]; +} +export function updateNextEpochPublicKey(options: UpdateNextEpochPublicKeyOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'vector', 'vector'] satisfies (string | null)[]; + const parameterNames = ['self', 'validator', 'nextEpochPublicKey', 'proofOfPossessionSignature']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'validator', + function: 'update_next_epoch_public_key', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface UpdateOperatorAddressArguments { + self: RawTransactionArgument; + validator: RawTransactionArgument; + operator: RawTransactionArgument; +} +export interface UpdateOperatorAddressOptions { + package?: string; + arguments: + | UpdateOperatorAddressArguments + | [ + self: RawTransactionArgument, + validator: RawTransactionArgument, + operator: RawTransactionArgument, + ]; +} +export function updateOperatorAddress(options: UpdateOperatorAddressOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'address'] satisfies (string | null)[]; + const parameterNames = ['self', 'validator', 'operator']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'validator', + function: 'update_operator_address', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface UpdateEndpointUrlArguments { + self: RawTransactionArgument; + validator: RawTransactionArgument; + endpointUrl: RawTransactionArgument; +} +export interface UpdateEndpointUrlOptions { + package?: string; + arguments: + | UpdateEndpointUrlArguments + | [ + self: RawTransactionArgument, + validator: RawTransactionArgument, + endpointUrl: RawTransactionArgument, + ]; +} +export function updateEndpointUrl(options: UpdateEndpointUrlOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', '0x1::string::String'] satisfies (string | null)[]; + const parameterNames = ['self', 'validator', 'endpointUrl']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'validator', + function: 'update_endpoint_url', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface UpdateTlsPublicKeyArguments { + self: RawTransactionArgument; + validator: RawTransactionArgument; + tlsPublicKey: RawTransactionArgument; +} +export interface UpdateTlsPublicKeyOptions { + package?: string; + arguments: + | UpdateTlsPublicKeyArguments + | [ + self: RawTransactionArgument, + validator: RawTransactionArgument, + tlsPublicKey: RawTransactionArgument, + ]; +} +export function updateTlsPublicKey(options: UpdateTlsPublicKeyOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'vector'] satisfies (string | null)[]; + const parameterNames = ['self', 'validator', 'tlsPublicKey']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'validator', + function: 'update_tls_public_key', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface UpdateNextEpochEncryptionPublicKeyArguments { + self: RawTransactionArgument; + validator: RawTransactionArgument; + nextEpochEncryptionPublicKey: RawTransactionArgument; +} +export interface UpdateNextEpochEncryptionPublicKeyOptions { + package?: string; + arguments: + | UpdateNextEpochEncryptionPublicKeyArguments + | [ + self: RawTransactionArgument, + validator: RawTransactionArgument, + nextEpochEncryptionPublicKey: RawTransactionArgument, + ]; +} +export function updateNextEpochEncryptionPublicKey( + options: UpdateNextEpochEncryptionPublicKeyOptions, +) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'vector'] satisfies (string | null)[]; + const parameterNames = ['self', 'validator', 'nextEpochEncryptionPublicKey']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'validator', + function: 'update_next_epoch_encryption_public_key', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/versioning.ts b/packages/hashi/src/contracts/hashi/versioning.ts new file mode 100644 index 000000000..5afc0035c --- /dev/null +++ b/packages/hashi/src/contracts/hashi/versioning.ts @@ -0,0 +1,30 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Package version gating and upgrade authority. + * + * Holds the set of package versions allowed to run and custodies the package + * `UpgradeCap`. The two live together because they are one lifecycle: committing + * an upgrade through the cap auto-enables the new version. Every entry function + * gates on `assert_version_enabled` so a disabled version cannot be executed. + */ + +import { MoveStruct } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import * as vec_set from './deps/sui/vec_set.js'; +import * as _package from './deps/sui/package.js'; +const $moduleName = '@local-pkg/hashi::versioning'; +export const Versioning = new MoveStruct({ + name: `${$moduleName}::Versioning`, + fields: { + /** Package versions allowed to run; gated on every entry. */ + enabled_versions: vec_set.VecSet(bcs.u64()), + /** + * The package's UpgradeCap. Custodied here because committing an upgrade + * auto-enables the new version. + */ + upgrade_cap: bcs.option(_package.UpgradeCap), + }, +}); diff --git a/packages/hashi/src/contracts/hashi/withdraw.ts b/packages/hashi/src/contracts/hashi/withdraw.ts new file mode 100644 index 000000000..aa02726fc --- /dev/null +++ b/packages/hashi/src/contracts/hashi/withdraw.ts @@ -0,0 +1,372 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * User-facing Bitcoin withdrawal flow. A user escrows hBTC against a target + * Bitcoin address; the committee approves the request, batches one or more + * requests into a withdrawal transaction (burning the hBTC and locking the input + * UTXOs), accumulates per-input MPC signatures incrementally, finalizes with the + * one-shot guardian signatures, and confirms once the transaction lands on + * Bitcoin. A not-yet-committed request can be cancelled by its requester after a + * cooldown, refunding the hBTC. + */ + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +import * as utxo from './utxo.js'; +import * as withdrawal_queue from './withdrawal_queue.js'; +const $moduleName = '@local-pkg/hashi::withdraw'; +export const RequestApprovalMessage = new MoveStruct({ + name: `${$moduleName}::RequestApprovalMessage`, + fields: { + request_id: bcs.Address, + }, +}); +export const WithdrawalCommitmentMessage = new MoveStruct({ + name: `${$moduleName}::WithdrawalCommitmentMessage`, + fields: { + request_ids: bcs.vector(bcs.Address), + selected_utxos: bcs.vector(utxo.UtxoId), + outputs: bcs.vector(withdrawal_queue.OutputUtxo), + txid: bcs.Address, + }, +}); +export const WithdrawalSignedMessage = new MoveStruct({ + name: `${$moduleName}::WithdrawalSignedMessage`, + fields: { + withdrawal_id: bcs.Address, + request_ids: bcs.vector(bcs.Address), + signatures: bcs.vector(bcs.vector(bcs.u8())), + guardian_signatures: bcs.vector(bcs.vector(bcs.u8())), + }, +}); +export const MpcInputSignaturesMessage = new MoveStruct({ + name: `${$moduleName}::MpcInputSignaturesMessage`, + fields: { + withdrawal_id: bcs.Address, + indices: bcs.vector(bcs.u64()), + signatures: bcs.vector(bcs.vector(bcs.u8())), + }, +}); +export const WithdrawalConfirmationMessage = new MoveStruct({ + name: `${$moduleName}::WithdrawalConfirmationMessage`, + fields: { + withdrawal_id: bcs.Address, + }, +}); +export interface ApproveRequestArguments { + hashi: RawTransactionArgument; + requestId: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface ApproveRequestOptions { + package?: string; + arguments: + | ApproveRequestArguments + | [ + hashi: RawTransactionArgument, + requestId: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +export function approveRequest(options: ApproveRequestOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', null, '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'requestId', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'approve_request', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface CommitWithdrawalTxArguments { + hashi: RawTransactionArgument; + requestIds: RawTransactionArgument; + selectedUtxos: RawTransactionArgument; + outputs: RawTransactionArgument; + txid: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface CommitWithdrawalTxOptions { + package?: string; + arguments: + | CommitWithdrawalTxArguments + | [ + hashi: RawTransactionArgument, + requestIds: RawTransactionArgument, + selectedUtxos: RawTransactionArgument, + outputs: RawTransactionArgument, + txid: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +export function commitWithdrawalTx(options: CommitWithdrawalTxOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [ + null, + 'vector
', + 'vector', + 'vector', + 'address', + null, + '0x2::clock::Clock', + '0x2::random::Random', + ] satisfies (string | null)[]; + const parameterNames = ['hashi', 'requestIds', 'selectedUtxos', 'outputs', 'txid', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'commit_withdrawal_tx', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface CommitInputSignaturesArguments { + hashi: RawTransactionArgument; + withdrawalId: RawTransactionArgument; + indices: RawTransactionArgument; + signatures: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface CommitInputSignaturesOptions { + package?: string; + arguments: + | CommitInputSignaturesArguments + | [ + hashi: RawTransactionArgument, + withdrawalId: RawTransactionArgument, + indices: RawTransactionArgument, + signatures: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +/** + * Record a chunk of completed per-input MPC signatures into the withdrawal's + * signing batch (out-of-order, first-writer-wins). Cert-gated over exactly the + * `(withdrawal_id, indices, signatures)` written, by the current committee. + * Repeated across checkpoints/leaders until every input is signed; the leader may + * bundle a final chunk + `finalize_withdrawal` in one PTB for small txns. + */ +export function commitInputSignatures(options: CommitInputSignaturesOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', 'vector', 'vector>', null] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'withdrawalId', 'indices', 'signatures', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'commit_input_signatures', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface FinalizeWithdrawalArguments { + hashi: RawTransactionArgument; + withdrawalId: RawTransactionArgument; + requestIds: RawTransactionArgument; + guardianSignatures: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface FinalizeWithdrawalOptions { + package?: string; + arguments: + | FinalizeWithdrawalArguments + | [ + hashi: RawTransactionArgument, + withdrawalId: RawTransactionArgument, + requestIds: RawTransactionArgument, + guardianSignatures: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +/** + * Finalize a withdrawal once all MPC signatures are in: attach the one-shot + * guardian signatures and flip the broadcast gate. The cert binds the full MPC + * signature set (read from the batch) together with the guardian signatures, so a + * malicious leader cannot pair valid MPC sigs with garbage guardian sigs. + */ +export function finalizeWithdrawal(options: FinalizeWithdrawalOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [ + null, + 'address', + 'vector
', + 'vector>', + null, + '0x2::clock::Clock', + ] satisfies (string | null)[]; + const parameterNames = ['hashi', 'withdrawalId', 'requestIds', 'guardianSignatures', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'finalize_withdrawal', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ConfirmWithdrawalArguments { + hashi: RawTransactionArgument; + withdrawalId: RawTransactionArgument; + cert: RawTransactionArgument; +} +export interface ConfirmWithdrawalOptions { + package?: string; + arguments: + | ConfirmWithdrawalArguments + | [ + hashi: RawTransactionArgument, + withdrawalId: RawTransactionArgument, + cert: RawTransactionArgument, + ]; +} +export function confirmWithdrawal(options: ConfirmWithdrawalOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', null, '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'withdrawalId', 'cert']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'confirm_withdrawal', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface ReallocatePresigsArguments { + hashi: RawTransactionArgument; + withdrawalId: RawTransactionArgument; +} +export interface ReallocatePresigsOptions { + package?: string; + arguments: + | ReallocatePresigsArguments + | [hashi: RawTransactionArgument, withdrawalId: RawTransactionArgument]; +} +/** + * Reassign fresh presignatures to the still-unsigned inputs of a withdrawal whose + * signing batch is from a previous epoch. Only the pending tail is re-presigned; + * already-collected signatures are final and epoch-independent. + * + * Gated like commit/finalize (version-enabled, unpaused, not-reconfiguring): an + * in-progress reconfiguration settles first, then this runs afterward to recover + * the now-stale batch. Carries no committee cert: it authorizes no signatures, + * only re-points pending presig indices, bounded to once-per-withdrawal-per-epoch + * by the `mpc_signing` stale-epoch guard. + */ +export function reallocatePresigs(options: ReallocatePresigsOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'withdrawalId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'reallocate_presigs', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface CleanupSpentUtxosArguments { + hashi: RawTransactionArgument; + utxoIds: RawTransactionArgument; +} +export interface CleanupSpentUtxosOptions { + package?: string; + arguments: + | CleanupSpentUtxosArguments + | [hashi: RawTransactionArgument, utxoIds: RawTransactionArgument]; +} +/** + * Finalize the on-chain bookkeeping for spent UTXOs. Moves each UTXO's record from + * `utxo_records` to `spent_utxos`, reading the spent epoch from the record's + * `spent_epoch` field (set by `mark_spent` during `confirm_withdrawal`). Callers + * pass the individual UTXO IDs to clean up. + * + * Garbage collection: deliberately NOT gated on pause/reconfig — it moves no funds + * and must stay callable during an emergency pause. + */ +export function cleanupSpentUtxos(options: CleanupSpentUtxosOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'vector'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'utxoIds']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'cleanup_spent_utxos', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface RequestWithdrawalArguments { + hashi: RawTransactionArgument; + btc: RawTransactionArgument; + bitcoinAddress: RawTransactionArgument; +} +export interface RequestWithdrawalOptions { + package?: string; + arguments: + | RequestWithdrawalArguments + | [ + hashi: RawTransactionArgument, + btc: RawTransactionArgument, + bitcoinAddress: RawTransactionArgument, + ]; +} +/** + * Request a withdrawal of BTC from the bridge. + * + * The full BTC amount is stored in the withdrawal request. The miner fee is + * deducted later at commitment time. + * + * The user must provide at least `bitcoin_withdrawal_minimum()` sats, which + * guarantees the amount covers worst-case miner fees plus dust. + */ +export function requestWithdrawal(options: RequestWithdrawalOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, '0x2::clock::Clock', null, 'vector'] satisfies ( + | string + | null + )[]; + const parameterNames = ['hashi', 'btc', 'bitcoinAddress']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'request_withdrawal', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface CancelWithdrawalArguments { + hashi: RawTransactionArgument; + requestId: RawTransactionArgument; +} +export interface CancelWithdrawalOptions { + package?: string; + arguments: + | CancelWithdrawalArguments + | [hashi: RawTransactionArgument, requestId: RawTransactionArgument]; +} +/** + * Cancel a pending withdrawal request and return the stored BTC to the requester. + * + * Cancellation is allowed while the request is in the `Requested` or `Approved` + * state (i.e. still in the active requests bag). Once the committee commits the + * request to a `WithdrawalTransaction` it moves to `Processing` in the processed + * bag and its BTC is burned — cancellation is no longer possible. + */ +export function cancelWithdrawal(options: CancelWithdrawalOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = [null, 'address', '0x2::clock::Clock'] satisfies (string | null)[]; + const parameterNames = ['hashi', 'requestId']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdraw', + function: 'cancel_withdrawal', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/hashi/withdrawal_queue.ts b/packages/hashi/src/contracts/hashi/withdrawal_queue.ts new file mode 100644 index 000000000..93259472c --- /dev/null +++ b/packages/hashi/src/contracts/hashi/withdrawal_queue.ts @@ -0,0 +1,251 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Storage and state machine for Bitcoin withdrawals. Holds `WithdrawalRequest` + * objects as they move from Requested through Approved, Processing, Signed, and + * Confirmed, plus the `WithdrawalTransaction` objects that batch them: each + * transaction tracks its input UTXOs, withdrawal and change outputs, and the + * incrementally collected MPC and guardian signatures. Certificate verification + * and funds movement are driven by `hashi::withdraw`. + */ + +import { + MoveStruct, + MoveEnum, + normalizeMoveArguments, + type RawTransactionArgument, +} from '../utils/index.js'; +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; +import * as object_bag from './deps/sui/object_bag.js'; +import * as object_bag_1 from './deps/sui/object_bag.js'; +import * as object_bag_2 from './deps/sui/object_bag.js'; +import * as object_bag_3 from './deps/sui/object_bag.js'; +import * as committee from './committee.js'; +import * as balance from './deps/sui/balance.js'; +import * as utxo from './utxo.js'; +import * as mpc_signing from './mpc_signing.js'; +import * as utxo_1 from './utxo.js'; +import * as utxo_2 from './utxo.js'; +const $moduleName = '@local-pkg/hashi::withdrawal_queue'; +export const WithdrawalRequestQueue = new MoveStruct({ + name: `${$moduleName}::WithdrawalRequestQueue`, + fields: { + /** + * Active requests awaiting action (Requested, Approved). ObjectBag so + * WithdrawalRequest UIDs are directly accessible via getObject. + */ + requests: object_bag.ObjectBag, + /** + * Processed requests — BTC consumed, lifecycle continuing or complete (Processing, + * Signed, Confirmed). + */ + processed: object_bag_1.ObjectBag, + /** + * In-flight withdrawal transactions (unsigned, signed but unconfirmed). ObjectBag + * so WithdrawalTransaction UIDs are directly accessible via getObject. + */ + withdrawal_txns: object_bag_2.ObjectBag, + /** Confirmed withdrawal transactions (historical record). */ + confirmed_txns: object_bag_3.ObjectBag, + }, +}); +export const OutputUtxo = new MoveStruct({ + name: `${$moduleName}::OutputUtxo`, + fields: { + amount: bcs.u64(), + bitcoin_address: bcs.vector(bcs.u8()), + }, +}); +export const WithdrawalStatus = new MoveEnum({ + name: `${$moduleName}::WithdrawalStatus`, + fields: { + Requested: null, + Approved: null, + Processing: null, + Signed: null, + Confirmed: null, + }, +}); +export const WithdrawalRequest = new MoveStruct({ + name: `${$moduleName}::WithdrawalRequest`, + fields: { + id: bcs.Address, + sender: bcs.Address, + btc_amount: bcs.u64(), + bitcoin_address: bcs.vector(bcs.u8()), + created_timestamp_ms: bcs.u64(), + status: WithdrawalStatus, + /** + * Committee certificate recorded at approval time. `None` until `approve_request` + * has been called. + */ + approval_cert: bcs.option(committee.CommitteeSignature), + /** + * Clock timestamp at the moment of approval. `None` until `approve_request` has + * been called. + */ + approved_timestamp_ms: bcs.option(bcs.u64()), + withdrawal_txn_id: bcs.option(bcs.Address), + sui_tx_digest: bcs.vector(bcs.u8()), + btc: balance.Balance, + }, +}); +export const WithdrawalTransaction = new MoveStruct({ + name: `${$moduleName}::WithdrawalTransaction`, + fields: { + id: bcs.Address, + txid: bcs.Address, + request_ids: bcs.vector(bcs.Address), + /** + * UTXOs consumed by this withdrawal. The UTXOs remain locked in the pool until + * `confirm_withdrawal()` moves them to spent; these copies are kept for event + * emission and fee accounting. + */ + inputs: bcs.vector(utxo.Utxo), + withdrawal_outputs: bcs.vector(OutputUtxo), + /** + * Change outputs back to the bridge, in BTC transaction order. These are the + * trailing outputs of the transaction: change output `j` sits at vout + * `withdrawal_outputs.length() + j`. Empty when the transaction has no change. + */ + change_outputs: bcs.vector(OutputUtxo), + created_timestamp_ms: bcs.u64(), + /** + * Clock timestamp at which the transaction became fully signed (guardian + * signatures attached). `None` until `finalize_withdrawal`. + */ + signed_timestamp_ms: bcs.option(bcs.u64()), + /** + * Clock timestamp at which the Bitcoin transaction was confirmed. `None` until + * `confirm_withdrawal`. + */ + confirmed_timestamp_ms: bcs.option(bcs.u64()), + randomness: bcs.vector(bcs.u8()), + /** + * Per-input MPC committee signatures, accumulated incrementally and out-of-order + * across checkpoints/leaders/epochs. Owns the presignature bookkeeping (see + * `hashi::mpc_signing`). + */ + signing: mpc_signing.SigningBatch, + /** + * Per-input Schnorr signatures from the guardian enclave. Same length as the MPC + * signatures; together they form the 2-of-2 taproot witness. Written once at + * `finalize_withdrawal` (the guardian signs in one shot). + */ + guardian_signatures: bcs.option(bcs.vector(bcs.vector(bcs.u8()))), + }, +}); +export const CommittedRequestInfo = new MoveStruct({ + name: `${$moduleName}::CommittedRequestInfo`, + fields: { + btc_amount: bcs.u64(), + bitcoin_address: bcs.vector(bcs.u8()), + }, +}); +export const WithdrawalRequested = new MoveStruct({ + name: `${$moduleName}::WithdrawalRequested`, + fields: { + request_id: bcs.Address, + btc_amount: bcs.u64(), + bitcoin_address: bcs.vector(bcs.u8()), + timestamp_ms: bcs.u64(), + requester_address: bcs.Address, + sui_tx_digest: bcs.vector(bcs.u8()), + }, +}); +export const WithdrawalApproved = new MoveStruct({ + name: `${$moduleName}::WithdrawalApproved`, + fields: { + request_id: bcs.Address, + }, +}); +export const WithdrawalPickedForProcessing = new MoveStruct({ + name: `${$moduleName}::WithdrawalPickedForProcessing`, + fields: { + withdrawal_txn_id: bcs.Address, + txid: bcs.Address, + request_ids: bcs.vector(bcs.Address), + inputs: bcs.vector(utxo_1.Utxo), + withdrawal_outputs: bcs.vector(OutputUtxo), + change_outputs: bcs.vector(OutputUtxo), + timestamp_ms: bcs.u64(), + randomness: bcs.vector(bcs.u8()), + }, +}); +export const WithdrawalInputsSigned = new MoveStruct({ + name: `${$moduleName}::WithdrawalInputsSigned`, + fields: { + withdrawal_txn_id: bcs.Address, + signed_count: bcs.u64(), + num_inputs: bcs.u64(), + }, +}); +export const WithdrawalSigned = new MoveStruct({ + name: `${$moduleName}::WithdrawalSigned`, + fields: { + withdrawal_txn_id: bcs.Address, + request_ids: bcs.vector(bcs.Address), + /** Per-input Schnorr signatures from the MPC committee. */ + signatures: bcs.vector(bcs.vector(bcs.u8())), + /** + * Per-input Schnorr signatures from the guardian enclave. Same length as + * `signatures`; the watcher pairs index `i` of both to form the witness for input + * `i` at broadcast time. + */ + guardian_signatures: bcs.vector(bcs.vector(bcs.u8())), + }, +}); +export const WithdrawalPresigsReassigned = new MoveStruct({ + name: `${$moduleName}::WithdrawalPresigsReassigned`, + fields: { + withdrawal_txn_id: bcs.Address, + epoch: bcs.u64(), + presig_start_index: bcs.u64(), + }, +}); +export const WithdrawalConfirmed = new MoveStruct({ + name: `${$moduleName}::WithdrawalConfirmed`, + fields: { + withdrawal_txn_id: bcs.Address, + txid: bcs.Address, + change_utxo_ids: bcs.vector(utxo_2.UtxoId), + request_ids: bcs.vector(bcs.Address), + change_utxo_amounts: bcs.vector(bcs.u64()), + }, +}); +export const WithdrawalCancelled = new MoveStruct({ + name: `${$moduleName}::WithdrawalCancelled`, + fields: { + request_id: bcs.Address, + requester_address: bcs.Address, + btc_amount: bcs.u64(), + }, +}); +export interface OutputUtxoArguments { + amount: RawTransactionArgument; + bitcoinAddress: RawTransactionArgument; +} +export interface OutputUtxoOptions { + package?: string; + arguments: + | OutputUtxoArguments + | [ + amount: RawTransactionArgument, + bitcoinAddress: RawTransactionArgument, + ]; +} +export function outputUtxo(options: OutputUtxoOptions) { + const packageAddress = options.package ?? '@local-pkg/hashi'; + const argumentsTypes = ['u64', 'vector'] satisfies (string | null)[]; + const parameterNames = ['amount', 'bitcoinAddress']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'withdrawal_queue', + function: 'output_utxo', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/packages/hashi/src/contracts/utils/index.ts b/packages/hashi/src/contracts/utils/index.ts new file mode 100644 index 000000000..9e2b22b6e --- /dev/null +++ b/packages/hashi/src/contracts/utils/index.ts @@ -0,0 +1,234 @@ +import { + bcs, + BcsType, + TypeTag, + TypeTagSerializer, + BcsStruct, + BcsEnum, + BcsTuple, +} from '@mysten/sui/bcs'; +import { normalizeSuiAddress } from '@mysten/sui/utils'; +import { TransactionArgument, isArgument } from '@mysten/sui/transactions'; +import { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; + +const MOVE_STDLIB_ADDRESS = normalizeSuiAddress('0x1'); +const SUI_FRAMEWORK_ADDRESS = normalizeSuiAddress('0x2'); + +export type RawTransactionArgument = T | TransactionArgument; + +export interface GetOptions< + Include extends Omit = {}, +> extends SuiClientTypes.GetObjectOptions { + client: ClientWithCoreApi; +} + +export interface GetManyOptions< + Include extends Omit = {}, +> extends SuiClientTypes.GetObjectsOptions { + client: ClientWithCoreApi; +} + +export function getPureBcsSchema(typeTag: string | TypeTag): BcsType | null { + const parsedTag = typeof typeTag === 'string' ? TypeTagSerializer.parseFromStr(typeTag) : typeTag; + + if ('u8' in parsedTag) { + return bcs.U8; + } else if ('u16' in parsedTag) { + return bcs.U16; + } else if ('u32' in parsedTag) { + return bcs.U32; + } else if ('u64' in parsedTag) { + return bcs.U64; + } else if ('u128' in parsedTag) { + return bcs.U128; + } else if ('u256' in parsedTag) { + return bcs.U256; + } else if ('address' in parsedTag) { + return bcs.Address; + } else if ('bool' in parsedTag) { + return bcs.Bool; + } else if ('vector' in parsedTag) { + const type = getPureBcsSchema(parsedTag.vector); + return type ? bcs.vector(type) : null; + } else if ('struct' in parsedTag) { + const structTag = parsedTag.struct; + const pkg = normalizeSuiAddress(structTag.address); + + if (pkg === MOVE_STDLIB_ADDRESS) { + if ( + (structTag.module === 'ascii' || structTag.module === 'string') && + structTag.name === 'String' + ) { + return bcs.String; + } + + if (structTag.module === 'option' && structTag.name === 'Option') { + const type = getPureBcsSchema(structTag.typeParams[0]); + return type ? bcs.option(type) : null; + } + } + + if ( + pkg === SUI_FRAMEWORK_ADDRESS && + structTag.module === 'object' && + (structTag.name === 'ID' || structTag.name === 'UID') + ) { + return bcs.Address; + } + } + + return null; +} + +export function normalizeMoveArguments( + args: unknown[] | object, + argTypes: readonly (string | null)[], + parameterNames?: string[], +) { + const argLen = Array.isArray(args) ? args.length : Object.keys(args).length; + if (parameterNames && argLen !== parameterNames.length) { + throw new Error( + `Invalid number of arguments, expected ${parameterNames.length}, got ${argLen}`, + ); + } + + const normalizedArgs: TransactionArgument[] = []; + + let index = 0; + for (const [i, argType] of argTypes.entries()) { + if (argType === '0x2::clock::Clock') { + normalizedArgs.push((tx) => tx.object.clock()); + continue; + } + + if (argType === '0x2::random::Random') { + normalizedArgs.push((tx) => tx.object.random()); + continue; + } + + if (argType === '0x2::deny_list::DenyList') { + normalizedArgs.push((tx) => tx.object.denyList()); + continue; + } + + if (argType === '0x3::sui_system::SuiSystemState') { + normalizedArgs.push((tx) => tx.object.system()); + continue; + } + + let arg; + if (Array.isArray(args)) { + if (index >= args.length) { + throw new Error( + `Invalid number of arguments, expected at least ${index + 1}, got ${args.length}`, + ); + } + arg = args[index]; + } else { + if (!parameterNames) { + throw new Error(`Expected arguments to be passed as an array`); + } + const name = parameterNames[index]; + arg = args[name as keyof typeof args]; + + if (arg === undefined) { + throw new Error(`Parameter ${name} is required`); + } + } + + index += 1; + + if (typeof arg === 'function' || isArgument(arg)) { + normalizedArgs.push(arg as TransactionArgument); + continue; + } + + const type = argTypes[i]; + const bcsType = type === null ? null : getPureBcsSchema(type); + + if (bcsType) { + const bytes = bcsType.serialize(arg as never); + normalizedArgs.push((tx) => tx.pure(bytes)); + continue; + } else if (typeof arg === 'string') { + normalizedArgs.push((tx) => tx.object(arg)); + continue; + } + + throw new Error(`Invalid argument ${stringify(arg)} for type ${type}`); + } + + return normalizedArgs; +} + +export class MoveStruct< + T extends Record>, + const Name extends string = string, +> extends BcsStruct { + async get = {}>({ + objectId, + ...options + }: GetOptions): Promise< + SuiClientTypes.Object & { + json: BcsStruct['$inferType']; + } + > { + const [res] = await this.getMany({ + ...options, + objectIds: [objectId], + }); + + return res; + } + + async getMany = {}>({ + client, + ...options + }: GetManyOptions): Promise< + Array< + SuiClientTypes.Object & { + json: BcsStruct['$inferType']; + } + > + > { + const response = (await client.core.getObjects({ + ...options, + include: { + ...options.include, + content: true, + }, + })) as SuiClientTypes.GetObjectsResponse; + + return response.objects.map((obj) => { + if (obj instanceof Error) { + throw obj; + } + + return { + ...obj, + json: this.parse(obj.content), + }; + }); + } +} + +export class MoveEnum< + T extends Record | null>, + const Name extends string, +> extends BcsEnum {} + +export class MoveTuple< + const T extends readonly BcsType[], + const Name extends string, +> extends BcsTuple {} + +function stringify(val: unknown) { + if (typeof val === 'object') { + return JSON.stringify(val, (val: unknown) => val); + } + if (typeof val === 'bigint') { + return val.toString(); + } + + return val; +} diff --git a/packages/hashi/src/errors.ts b/packages/hashi/src/errors.ts new file mode 100644 index 000000000..ea7a2a8b7 --- /dev/null +++ b/packages/hashi/src/errors.ts @@ -0,0 +1,236 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Custom error classes thrown by the Hashi SDK. Consumers can `instanceof`-check + * these to distinguish SDK-structured failures (missing/malformed on-chain data, + * chain-fetch failures) from generic runtime errors. + */ + +/** + * Thrown when a governance config entry on-chain is missing, has an unexpected + * variant, or has a payload that cannot be decoded to the expected type. + * Carries the offending `key` and `expectedVariant` as structured fields so + * callers can react programmatically without string-parsing the message. + */ +export class HashiConfigError extends Error { + readonly key: string; + readonly expectedVariant: string; + readonly actualVariant?: string; + + constructor( + message: string, + details: { key: string; expectedVariant: string; actualVariant?: string }, + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = 'HashiConfigError'; + this.key = details.key; + this.expectedVariant = details.expectedVariant; + this.actualVariant = details.actualVariant; + } + + static missing(key: string, expectedVariant: string): HashiConfigError { + return new HashiConfigError(`Config key "${key}" not found on-chain.`, { + key, + expectedVariant, + }); + } + + static wrongVariant( + key: string, + expectedVariant: string, + actualVariant: string, + ): HashiConfigError { + return new HashiConfigError( + `Config key "${key}" is ${actualVariant}, expected ${expectedVariant}.`, + { key, expectedVariant, actualVariant }, + ); + } + + static malformedPayload( + key: string, + expectedVariant: string, + detail: string, + cause?: unknown, + ): HashiConfigError { + return new HashiConfigError( + `Config key "${key}" ${expectedVariant} payload is malformed: ${detail}.`, + { key, expectedVariant, actualVariant: expectedVariant }, + { cause }, + ); + } +} + +/** + * Thrown when fetching the Hashi shared object fails or returns an + * unexpectedly shaped response. Wraps the underlying Sui-client error via + * `cause` so callers can still access the network-layer detail. + */ +export class HashiFetchError extends Error { + readonly hashiObjectId: string; + + constructor(message: string, hashiObjectId: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'HashiFetchError'; + this.hashiObjectId = hashiObjectId; + } +} + +/** + * Stable discriminator for `HashiGuardianError`, so callers can `switch` on + * `code` rather than string-parse the message: `not-configured` (no guardian URL + * resolvable), `unreachable` (`fetch` threw), `http-error` (non-2xx), + * `malformed-response` (bad JSON or limiter field), `not-initialized` (no limiter yet). + */ +export type GuardianErrorCode = + | 'not-configured' + | 'unreachable' + | 'http-error' + | 'malformed-response' + | 'not-initialized'; + +/** + * Thrown by the `client.hashi.guardian.*` methods when the guardian `/info` + * endpoint can't be resolved, reached, parsed, or is not yet initialized. + * `code` is a stable discriminator for programmatic handling; `url` is the + * `/info` endpoint that failed (`null` for `not-configured`); `status` carries + * the HTTP status for `http-error`. + */ +export class HashiGuardianError extends Error { + readonly code: GuardianErrorCode; + readonly url: string | null; + readonly status?: number; + + constructor( + details: { message: string; code: GuardianErrorCode; url?: string | null; status?: number }, + options?: { cause?: unknown }, + ) { + super(details.message, options); + this.name = 'HashiGuardianError'; + this.code = details.code; + this.url = details.url ?? null; + this.status = details.status; + } +} + +/** + * One amount that failed a client-side minimum check. `vout` is present for + * deposit-UTXO violations (so callers can map each offender back to a Bitcoin + * output) and absent for withdrawal violations, where there is a single + * top-level amount and no output index. + */ +export interface AmountViolation { + readonly amount: bigint; + readonly minimum: bigint; + readonly vout?: number; +} + +/** + * Thrown by `HashiClient.deposit()` and `HashiClient.requestWithdrawal()` when + * one or more amounts are below the live on-chain minimum. Deposits may carry + * multiple violations (one per offending UTXO) so callers can fix the whole + * batch in one round-trip; withdrawals always carry exactly one. Raised after + * the governance snapshot is fetched but before any PTB is built — mirrors + * the Move-side `EBelowMinimumDeposit` / `EBelowMinimumWithdrawal` aborts. + */ +export class AmountBelowMinimumError extends Error { + readonly violations: readonly AmountViolation[]; + + constructor(details: { violations: readonly AmountViolation[] }) { + const { violations } = details; + const head = violations[0]; + let summary: string; + if (violations.length === 1) { + summary = + head.vout === undefined + ? `Amount ${head.amount} sats is below the protocol minimum ` + `of ${head.minimum} sats.` + : `UTXO at vout ${head.vout} has amount ${head.amount} sats, ` + + `below the protocol minimum of ${head.minimum} sats.`; + } else { + summary = + `${violations.length} UTXOs are below the protocol minimum ` + + `(${head.minimum} sats): ${violations + .map((v) => + v.vout === undefined ? `${v.amount} sats` : `vout ${v.vout} = ${v.amount} sats`, + ) + .join(', ')}.`; + } + super(summary); + this.name = 'AmountBelowMinimumError'; + this.violations = violations; + } +} + +/** + * Thrown by user-facing entry points when `paused` is `true` in the governance + * config snapshot. Mirrors the Move-side `ESystemPaused` abort in + * `hashi::assert_unpaused` so the SDK can fail early with a typed error + * instead of a gas-burning on-chain abort. + */ +export class HashiPausedError extends Error { + readonly operation?: string; + + constructor(details?: { operation?: string }, options?: { cause?: unknown }) { + const op = details?.operation; + super( + op + ? `Hashi protocol is currently paused; cannot ${op}.` + : 'Hashi protocol is currently paused.', + options, + ); + this.name = 'HashiPausedError'; + this.operation = op; + } +} + +/** + * Thrown by `HashiClient` direct methods when the caller-supplied params + * don't meet structural preconditions (empty `utxos`, duplicate `vout`, + * malformed hex). Raised before any chain read so even a paused or + * unreachable protocol surfaces the client-side bug first. + */ +export class InvalidParamsError extends Error { + readonly reason: string; + readonly detail?: string; + + constructor(details: { reason: string; detail?: string }, options?: { cause?: unknown }) { + super(details.detail ? `${details.reason}: ${details.detail}` : details.reason, options); + this.name = 'InvalidParamsError'; + this.reason = details.reason; + this.detail = details.detail; + } +} + +/** + * Stable discriminator for `InvalidBitcoinAddressError`. Callers can switch on + * `code` to surface targeted UX (e.g. "wrong network" → prompt the user to + * switch, "bad-checksum" → flag a typo) without string-parsing the message. + */ +export type InvalidBitcoinAddressCode = + | 'malformed' + | 'bad-checksum' + | 'wrong-network' + | 'unsupported-version' + | 'bad-program-length'; + +/** + * Thrown when a user-supplied Bitcoin address cannot be decoded into a + * witness program that the Hashi withdrawal path accepts. `code` is a stable + * discriminator suitable for programmatic handling; `address` echoes the + * offending input back so callers can display it. + */ +export class InvalidBitcoinAddressError extends Error { + readonly address: string; + readonly code: InvalidBitcoinAddressCode; + + constructor( + details: { address: string; code: InvalidBitcoinAddressCode; message: string }, + options?: { cause?: unknown }, + ) { + super(details.message, options); + this.name = 'InvalidBitcoinAddressError'; + this.address = details.address; + this.code = details.code; + } +} diff --git a/packages/hashi/src/guardian.ts b/packages/hashi/src/guardian.ts new file mode 100644 index 000000000..c9adc832c --- /dev/null +++ b/packages/hashi/src/guardian.ts @@ -0,0 +1,160 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { HashiGuardianError } from './errors.js'; +import type { GuardianLimiterConfig, GuardianLimiterState, RawGuardianInfo } from './types.js'; + +/** + * Project the token-bucket capacity (sats) forward to `timestampSecs`, mirroring + * the guardian's Rust limiter: `min(available + elapsed * refillRate, max)`. + * Timestamps are unix seconds; one at or before `lastUpdatedAtSecs` yields the + * stored balance. + */ +export function projectCapacity( + config: GuardianLimiterConfig, + state: GuardianLimiterState, + timestampSecs: bigint, +): bigint { + const elapsed = + timestampSecs > state.lastUpdatedAtSecs ? timestampSecs - state.lastUpdatedAtSecs : 0n; + const refilled = elapsed * config.refillRateSatsPerSec; + const projected = state.numTokensAvailableSats + refilled; + return projected < config.maxBucketCapacitySats ? projected : config.maxBucketCapacitySats; +} + +/** + * Estimate the seconds until `amountSats` of capacity is available given the + * current bucket. Returns `0n` if it is available now, or `null` if it can + * never be satisfied in a single withdrawal — either the amount exceeds the + * bucket's maximum capacity, or the refill rate is `0` and a deficit remains. + */ +export function estimateWaitSecs( + config: GuardianLimiterConfig, + state: GuardianLimiterState, + amountSats: bigint, + nowSecs: bigint, +): bigint | null { + if (amountSats > config.maxBucketCapacitySats) return null; + const available = projectCapacity(config, state, nowSecs); + if (available >= amountSats) return 0n; + const deficit = amountSats - available; + if (config.refillRateSatsPerSec === 0n) return null; + return (deficit + config.refillRateSatsPerSec - 1n) / config.refillRateSatsPerSec; +} + +/** Curated JSON shape of `GET {origin}/info` — every `u64` arrives as a string. */ +interface GuardianInfoJson { + limiter?: { + state?: { numTokensAvailableSats?: string; lastUpdatedAtSecs?: string; nextSeq?: string }; + config?: { refillRateSatsPerSec?: string; maxBucketCapacitySats?: string }; + } | null; + gitRevision?: string; + committeeEpoch?: string | null; + btcPubkey?: string | null; + signingPubKey?: string; + signedAtMs?: string | null; +} + +/** + * Fetch and parse the guardian's read-only `/info` JSON. `origin` is the base + * URL (no path — e.g. the on-chain `guardian_url`); `/info` is appended here. + * `u64` strings are parsed to `bigint`, and `limiter` is `null` for an + * unprovisioned guardian. Failures are wrapped in {@link HashiGuardianError}. + */ +export async function fetchGuardianInfo(origin: string): Promise { + const endpoint = `${origin.replace(/\/+$/, '')}/info`; + + let res: Response; + try { + // A simple GET with only the safelisted `Accept` header ⇒ no CORS preflight. + res = await fetch(endpoint, { headers: { Accept: 'application/json' } }); + } catch (cause) { + throw new HashiGuardianError( + { message: `Guardian endpoint unreachable: ${endpoint}`, code: 'unreachable', url: endpoint }, + { cause }, + ); + } + if (!res.ok) { + throw new HashiGuardianError({ + message: `Guardian /info returned HTTP ${res.status}`, + code: 'http-error', + url: endpoint, + status: res.status, + }); + } + + let body: GuardianInfoJson; + try { + body = (await res.json()) as GuardianInfoJson; + } catch (cause) { + throw new HashiGuardianError( + { + message: 'Guardian /info returned a non-JSON body', + code: 'malformed-response', + url: endpoint, + }, + { cause }, + ); + } + + const u64 = (value: unknown, field: string): bigint => { + if (typeof value !== 'string') { + throw new HashiGuardianError({ + message: `Guardian /info field \`${field}\` must be a string, got ${typeof value}`, + code: 'malformed-response', + url: endpoint, + }); + } + try { + return BigInt(value); + } catch (cause) { + throw new HashiGuardianError( + { + message: `Guardian /info field \`${field}\` is not an integer: ${JSON.stringify(value)}`, + code: 'malformed-response', + url: endpoint, + }, + { cause }, + ); + } + }; + + // Limiter is all-or-nothing: a partial limiter is malformed, never defaulted + // to 0 (which would look like an empty bucket). Identity fields are lenient. + const rawLimiter = body.limiter; + const limiter = + rawLimiter == null + ? null + : { + state: { + numTokensAvailableSats: u64( + rawLimiter.state?.numTokensAvailableSats, + 'limiter.state.numTokensAvailableSats', + ), + lastUpdatedAtSecs: u64( + rawLimiter.state?.lastUpdatedAtSecs, + 'limiter.state.lastUpdatedAtSecs', + ), + nextSeq: u64(rawLimiter.state?.nextSeq, 'limiter.state.nextSeq'), + }, + config: { + refillRateSatsPerSec: u64( + rawLimiter.config?.refillRateSatsPerSec, + 'limiter.config.refillRateSatsPerSec', + ), + maxBucketCapacitySats: u64( + rawLimiter.config?.maxBucketCapacitySats, + 'limiter.config.maxBucketCapacitySats', + ), + }, + }; + + return { + limiter, + gitRevision: body.gitRevision ?? '', + committeeEpoch: body.committeeEpoch == null ? null : u64(body.committeeEpoch, 'committeeEpoch'), + btcPubkey: body.btcPubkey ?? null, + signingPubKey: body.signingPubKey ?? '', + signedAtMs: body.signedAtMs == null ? null : u64(body.signedAtMs, 'signedAtMs'), + }; +} diff --git a/packages/hashi/src/index.ts b/packages/hashi/src/index.ts new file mode 100644 index 000000000..fd7bb0808 --- /dev/null +++ b/packages/hashi/src/index.ts @@ -0,0 +1,56 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +export { HashiClient, hashi } from './client.js'; +export { + AmountBelowMinimumError, + HashiConfigError, + HashiFetchError, + HashiGuardianError, + HashiPausedError, + InvalidBitcoinAddressError, + InvalidParamsError, +} from './errors.js'; +export type { AmountViolation, GuardianErrorCode, InvalidBitcoinAddressCode } from './errors.js'; +export { + arkworksToSec1Compressed, + bitcoinAddressToWitnessProgram, + deriveChildPubkey, + generateDepositAddress, + twoOfTwoTaprootScriptPathAddress, + witnessProgramToAddress, +} from './bitcoin.js'; +export type { DepositAddressInputs } from './bitcoin.js'; +export { estimateWaitSecs, fetchGuardianInfo, projectCapacity } from './guardian.js'; +export type { + BitcoinNetwork, + CancelWithdrawalParams, + DepositFees, + DepositHistoryItem, + DepositInfo, + DepositParams, + DepositStatus, + GovernanceConfig, + GuardianInfoProvider, + GuardianLimiterConfig, + GuardianLimiterRaw, + GuardianLimiterSnapshot, + GuardianLimiterState, + GuardianWithdrawCheck, + HashiClientOptions, + HbtcBalance, + NetworkConfig, + RawGuardianInfo, + SuiNetwork, + TransactionHistoryItem, + UtxoId, + UtxoLookupResult, + UtxoOutput, + UtxoUsageResult, + WaitOptions, + WithdrawalFees, + WithdrawalHistoryItem, + WithdrawalInfo, + WithdrawalParams, + WithdrawalStatus, +} from './types.js'; diff --git a/packages/hashi/src/types.ts b/packages/hashi/src/types.ts new file mode 100644 index 000000000..aee336a62 --- /dev/null +++ b/packages/hashi/src/types.ts @@ -0,0 +1,355 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +export type BitcoinNetwork = 'mainnet' | 'testnet' | 'signet' | 'regtest'; + +export type SuiNetwork = 'devnet' | 'testnet' | 'mainnet' | 'localnet'; + +export interface NetworkConfig { + hashiObjectId: string; + packageId: string; + bitcoinNetwork: BitcoinNetwork; +} + +export interface HashiClientOptions { + name?: Name; + /** Sui network — determines Hashi object IDs and default Bitcoin network. */ + network: SuiNetwork; + /** Override the auto-resolved Hashi shared object ID (for custom/local deployments). */ + hashiObjectId?: string; + /** Override the auto-resolved Hashi package ID (for custom/local deployments). */ + packageId?: string; + /** Override the auto-resolved Bitcoin network for address encoding. */ + bitcoinNetwork?: BitcoinNetwork; + /** Optional Bitcoin Core JSON-RPC URL for UTXO lookups and confirmation checks. */ + btcRpcUrl?: string; + /** Override the Sui GraphQL endpoint URL (defaults to `https://fullnode.{network}.sui.io:443/graphql`). */ + graphqlUrl?: string; + /** Guardian origin URL (e.g. `https://hashi-guardian-devnet.mystenlabs.com`); the SDK appends `/info`. Takes precedence over the on-chain `guardian_url` config. */ + guardianUrl?: string; + /** Custom guardian-info source. When set, the SDK calls this instead of fetching `/info` — useful for caching, tests, or bespoke transports. */ + guardianInfoProvider?: GuardianInfoProvider; +} + +/** + * Frozen snapshot of every governance-controlled protocol parameter, returned + * by `HashiClient.view.all()`. Fields are `readonly` because the snapshot is + * a point-in-time read from chain — mutating it locally cannot change on-chain + * state. `depositMinimum` is a Move-side alias of `bitcoinDepositMinimum`; + * `worstCaseNetworkFee` is derived as `bitcoinWithdrawalMinimum - 546` (the + * dust relay floor) and is always ≥ 1. + * + * The three `guardian*` fields are `null` on deployments where the guardian + * config hasn't been written yet (pre-feature chains or in-flight rollouts). + * `guardianBtcPublicKey` is required for {@link HashiClient.generateDepositAddress} + * to succeed. + */ +export interface GovernanceConfig { + readonly paused: boolean; + readonly bitcoinChainId: string; + readonly bitcoinDepositMinimum: bigint; + readonly bitcoinWithdrawalMinimum: bigint; + readonly bitcoinConfirmationThreshold: bigint; + readonly withdrawalCancellationCooldownMs: bigint; + readonly bitcoinDepositTimeDelayMs: bigint; + readonly depositMinimum: bigint; + readonly worstCaseNetworkFee: bigint; + /** Guardian gRPC/HTTP endpoint from on-chain config. `null` if unset. */ + readonly guardianUrl: string | null; + /** + * Guardian's Ed25519 attestation key (32 bytes), used to verify signed + * `GetGuardianInfo` responses. `null` if unset. + */ + readonly guardianPublicKey: Uint8Array | null; + /** + * Guardian's BIP-340 x-only secp256k1 BTC public key (32 bytes), the + * `pk1` slot of the immediate 2-of-2 leaf in the on-chain deposit-address + * descriptor. `null` if unset (deposit-address derivation will fail). + */ + readonly guardianBtcPublicKey: Uint8Array | null; +} + +/** + * A single UTXO output within a Bitcoin transaction used to fund a deposit. + * `vout` is the output index (Bitcoin u32); `amountSats` must be ≥ the + * on-chain deposit minimum (enforced at deposit time). + */ +export interface UtxoOutput { + readonly vout: number; + readonly amountSats: bigint; +} + +/** Parameters for `HashiClient.deposit()` — one Bitcoin txid, one or more outputs paying the deposit address. */ +export interface DepositParams { + /** + * 0x-prefixed 32-byte Bitcoin txid of the funding transaction, in + * **display byte order** — the form mempool.space, blockstream.info, + * and `bitcoin-cli` show. The SDK reverses to internal byte order + * before recording on-chain (see `reverseTxidBytes` in `util.ts`). + */ + readonly txid: string; + /** UTXOs from `txid` that paid the deposit address (one per output to the address). */ + readonly utxos: readonly UtxoOutput[]; + /** + * Sui address that derived the deposit address and will receive the minted + * hBTC. Becomes the `derivation_path` of every `Utxo` built in the PTB. + */ + readonly recipient: string; +} + +/** Parameters for `HashiClient.requestWithdrawal()`. */ +export interface WithdrawalParams { + /** Amount in satoshis to withdraw. Must be ≥ the on-chain withdrawal minimum. */ + readonly amountSats: bigint; + /** + * Recipient Bitcoin address. Bech32 for P2WPKH (`bc1q…`, `tb1q…`) or + * bech32m for P2TR (`bc1p…`, `tb1p…`). Decoded client-side into a witness + * program and must match the client's configured Bitcoin network. + */ + readonly bitcoinAddress: string; +} + +/** Parameters for `HashiClient.cancelWithdrawal()`. */ +export interface CancelWithdrawalParams { + /** 0x-prefixed 32-byte object ID of the pending withdrawal request. */ + readonly requestId: string; +} + +// --------------------------------------------------------------------------- +// View-layer types — returned by `HashiClient.view.*` read methods. +// --------------------------------------------------------------------------- + +/** + * Identifies a single Bitcoin UTXO by its funding transaction and output + * index. `txid` is in **display byte order** — the form mempool.space, + * blockstream.info, and `bitcoin-cli` show. + */ +export interface UtxoId { + /** 0x-prefixed 32-byte Bitcoin txid in display byte order. */ + readonly txid: string; + /** Output index within the Bitcoin transaction (u32). */ + readonly vout: number; +} + +/** + * Result of checking a single UTXO against the on-chain `UtxoPool` bags. + * `inActivePool` means the UTXO is live (confirmed deposit, not yet + * consumed by a withdrawal); `inSpentPool` means it was consumed. + */ +export interface UtxoUsageResult { + readonly utxoId: UtxoId; + readonly inActivePool: boolean; + readonly inSpentPool: boolean; + /** Convenience: `inActivePool || inSpentPool`. */ + readonly isUsed: boolean; +} + +/** Discriminated union of deposit and withdrawal history entries. */ +export type TransactionHistoryItem = DepositHistoryItem | WithdrawalHistoryItem; + +export interface DepositHistoryItem { + readonly kind: 'deposit'; + readonly requestId: string; + readonly sender: string; + readonly timestampMs: bigint; + readonly suiTxDigest: string; + readonly amountSats: bigint; + /** Bitcoin txid of the funding transaction, in display byte order. */ + readonly btcTxid: string; + /** Output index within the funding transaction. */ + readonly btcVout: number; + /** `true` once the committee has approved the deposit. */ + readonly approved: boolean; + readonly approvalTimestampMs: bigint | null; + /** Earliest wall-clock time (ms since epoch) at which the deposit can be confirmed. `null` until approved. */ + readonly confirmableAtMs: bigint | null; +} + +export type WithdrawalStatus = 'Requested' | 'Approved' | 'Processing' | 'Signed' | 'Confirmed'; + +export interface WithdrawalHistoryItem { + readonly kind: 'withdrawal'; + readonly requestId: string; + readonly sender: string; + readonly btcAmountSats: bigint; + /** Raw witness program bytes of the destination Bitcoin address. */ + readonly bitcoinAddress: Uint8Array; + readonly timestampMs: bigint; + readonly suiTxDigest: string; + readonly status: WithdrawalStatus; + /** Object ID of the linked `WithdrawalTransaction`, if one exists. */ + readonly withdrawalTxnId: string | null; + /** Bitcoin txid from the `WithdrawalTransaction`, in display byte order. `null` until the committee commits. */ + readonly btcTxid: string | null; +} + +// --------------------------------------------------------------------------- +// Balance +// --------------------------------------------------------------------------- + +export interface HbtcBalance { + /** Total hBTC balance in satoshis. */ + readonly totalBalance: bigint; + /** Number of coin objects held. */ + readonly coinObjectCount: number; +} + +// --------------------------------------------------------------------------- +// Deposit status (by Sui tx digest) +// --------------------------------------------------------------------------- + +export type DepositStatus = 'pending' | 'confirmed' | 'expired' | 'unknown'; + +export interface DepositInfo { + /** Unique request ID on-chain. */ + readonly requestId: string; + /** Deposit amount in satoshis. */ + readonly amountSats: bigint; + /** Recipient Sui address (derivation path). */ + readonly recipient: string | null; + /** Bitcoin transaction ID (display byte order). */ + readonly btcTxid: string; + /** Bitcoin output index. */ + readonly btcVout: number; + /** Request timestamp (ms since epoch). */ + readonly timestampMs: bigint; + /** Timestamp (ms since epoch) when the committee approved this deposit. `null` if not yet approved. */ + readonly approvalTimestampMs: bigint | null; + /** Earliest wall-clock time (ms since epoch) at which the deposit can be confirmed. `null` until approved or if the config could not be read. */ + readonly confirmableAtMs: bigint | null; + /** Current deposit status. */ + readonly status: DepositStatus; + /** Sui transaction digest that created this request. */ + readonly suiTxDigest: string; +} + +// --------------------------------------------------------------------------- +// Withdrawal status (by Sui tx digest) +// --------------------------------------------------------------------------- + +export interface WithdrawalInfo { + /** Unique request ID on-chain. */ + readonly requestId: string; + /** Withdrawal amount in satoshis. */ + readonly btcAmountSats: bigint; + /** Raw witness program bytes of the destination Bitcoin address. */ + readonly bitcoinAddress: Uint8Array; + /** Sui address of the requester. */ + readonly sender: string; + /** Request timestamp (ms since epoch). */ + readonly timestampMs: bigint; + /** Current withdrawal status. */ + readonly status: WithdrawalStatus | 'cancelled'; + /** Sui transaction digest that created this request. */ + readonly suiTxDigest: string; + /** Bitcoin txid from the `WithdrawalTransaction`, in display byte order. `null` until the committee commits. */ + readonly btcTxid: string | null; +} + +// --------------------------------------------------------------------------- +// Fee estimation +// --------------------------------------------------------------------------- + +export interface DepositFees { + /** Estimated gas cost in MIST. */ + readonly gasEstimateMist: bigint; +} + +export interface WithdrawalFees { + /** Worst-case BTC network fee in satoshis. */ + readonly worstCaseNetworkFeeSats: bigint; + /** Minimum withdrawal amount in satoshis. */ + readonly withdrawalMinimumSats: bigint; + /** Estimated gas cost in MIST (`0n` if `sender` was not provided). */ + readonly gasEstimateMist: bigint; +} + +// --------------------------------------------------------------------------- +// Polling options +// --------------------------------------------------------------------------- + +export interface WaitOptions { + /** Polling interval in milliseconds (default: 15_000). */ + readonly intervalMs?: number; + /** Abort signal to cancel polling. */ + readonly signal?: AbortSignal; +} + +// --------------------------------------------------------------------------- +// Bitcoin RPC +// --------------------------------------------------------------------------- + +export interface UtxoLookupResult { + /** Output index. */ + readonly vout: number; + /** Amount in satoshis. */ + readonly amountSats: bigint; +} + +// --------------------------------------------------------------------------- +// Guardian rate limiter +// --------------------------------------------------------------------------- + +export interface GuardianLimiterState { + /** Available tokens in satoshis at the snapshot instant. */ + readonly numTokensAvailableSats: bigint; + /** Unix seconds when the bucket was last updated. */ + readonly lastUpdatedAtSecs: bigint; + /** Next expected withdrawal sequence number. */ + readonly nextSeq: bigint; +} + +export interface GuardianLimiterConfig { + /** Token refill rate in satoshis per second. */ + readonly refillRateSatsPerSec: bigint; + /** Maximum bucket capacity in satoshis. */ + readonly maxBucketCapacitySats: bigint; +} + +/** Raw limiter state + config, as returned by the guardian `/info` endpoint. */ +export interface GuardianLimiterRaw { + readonly state: GuardianLimiterState; + readonly config: GuardianLimiterConfig; +} + +/** + * Curated guardian identity + limiter, parsed from `GET {guardianUrl}/info`. + * `limiter` is `null` until the guardian is provisioned/activated. + */ +export interface RawGuardianInfo { + readonly limiter: GuardianLimiterRaw | null; + /** Guardian build git revision (untrusted, enclave-self-reported). */ + readonly gitRevision: string; + /** Current committee epoch; `null` before the guardian is initialized. */ + readonly committeeEpoch: bigint | null; + /** Guardian x-only 32-byte BTC pubkey (hex); `null` before provisioning. */ + readonly btcPubkey: string | null; + /** Guardian ed25519 32-byte signing pubkey (hex). */ + readonly signingPubKey: string; + /** `GuardianInfo` signing timestamp (ms since epoch); `null` if absent. */ + readonly signedAtMs: bigint | null; +} + +/** Pluggable source for {@link RawGuardianInfo}; see `HashiClientOptions.guardianInfoProvider`. */ +export type GuardianInfoProvider = () => Promise; + +/** Derived limiter view returned by `client.hashi.guardian.limiterStatus()`. */ +export interface GuardianLimiterSnapshot { + readonly state: GuardianLimiterState; + readonly config: GuardianLimiterConfig; + /** Projected available tokens (sats), accounting for refill since `lastUpdatedAtSecs`. */ + readonly availableNowSats: bigint; + /** Bucket fill as a percentage in [0, 100]. */ + readonly bucketFillPercent: number; + /** Unix seconds at which the bucket refills to full (assuming no withdrawals). `null` if already full or the refill rate is 0. */ + readonly fullAtSecs: bigint | null; +} + +/** Result of `client.hashi.guardian.canWithdraw(amountSats)`. */ +export interface GuardianWithdrawCheck { + readonly allowed: boolean; + /** Current available capacity in sats. */ + readonly availableNowSats: bigint; + /** Seconds until `amountSats` is available; `0n` if available now; `null` if it exceeds max capacity (or the refill rate is 0). */ + readonly estimatedWaitSecs: bigint | null; +} diff --git a/packages/hashi/src/util.ts b/packages/hashi/src/util.ts new file mode 100644 index 000000000..5b7ad1c6b --- /dev/null +++ b/packages/hashi/src/util.ts @@ -0,0 +1,87 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Value } from './contracts/hashi/config_value.js'; +import { HashiConfigError, InvalidParamsError } from './errors.js'; + +export type ConfigValue = typeof Value.$inferType; +export type ConfigEntry = { key: string; value: ConfigValue }; + +/** 0x-prefixed 32-byte hex (66 chars). Matches Sui addresses and Bitcoin txids. */ +const HEX32_RE = /^0x[0-9a-fA-F]{64}$/; + +/** + * Guards that `value` is a 0x-prefixed 32-byte hex string, throwing + * `InvalidParamsError` otherwise. `fieldName` is interpolated into + * the error message so callers can tell which deposit parameter failed. + */ +export function assertHex32(value: unknown, fieldName: string): void { + if (typeof value !== 'string' || !HEX32_RE.test(value)) { + throw new InvalidParamsError({ + reason: `\`${fieldName}\` must be a 0x-prefixed 32-byte hex string`, + detail: `got ${JSON.stringify(value)}`, + }); + } +} + +/** + * Reverse the 32 bytes of a Bitcoin txid between display order (the + * big-endian form shown by mempool.space, blockstream.info, and + * `bitcoin-cli`) and internal byte order (the little-endian form Bitcoin + * Core stores natively, and the form `bitcoin::Txid` parses to). + * + * The Hashi committee verifier reads `Utxo.txid` from on-chain state as a + * `bitcoin::Txid`, so deposits must record the txid in **internal byte + * order**. End users hold display-order txids (everything user-facing + * shows that), so the SDK accepts display-order as input and reverses + * here before recording. + */ +export function reverseTxidBytes(txid: string): string { + assertHex32(txid, 'txid'); + const hex = txid.slice(2); + let reversed = ''; + for (let i = hex.length - 2; i >= 0; i -= 2) { + reversed += hex.slice(i, i + 2); + } + return reversed; +} + +/** + * Find a VecMap entry by key and narrow its `Value` variant. Discriminating + * on `$kind` lets TypeScript narrow the returned payload — callers get the + * variant-specific fields (e.g. `.U64: string`, `.Bool: boolean`) without + * any manual type assertions. + */ +export function entry( + contents: readonly ConfigEntry[], + key: string, + expectedVariant: K, +): Extract { + const e = contents.find((c) => c.key === key); + if (!e) throw HashiConfigError.missing(key, expectedVariant); + if (e.value.$kind !== expectedVariant) { + throw HashiConfigError.wrongVariant(key, expectedVariant, e.value.$kind); + } + return e.value as Extract; +} + +/** + * Read a `Bytes` config entry and assert its byte length. Throws + * `HashiConfigError` if the key is missing, holds a non-`Bytes` variant, or + * has the wrong length. + */ +export function configBytes( + contents: readonly ConfigEntry[], + key: string, + expectedLen: number, +): Uint8Array { + const v = entry(contents, key, 'Bytes'); + if (v.Bytes.length !== expectedLen) { + throw HashiConfigError.malformedPayload( + key, + 'Bytes', + `expected ${expectedLen} bytes, got ${v.Bytes.length}`, + ); + } + return new Uint8Array(v.Bytes); +} diff --git a/packages/hashi/sui-codegen.config.ts b/packages/hashi/sui-codegen.config.ts new file mode 100644 index 000000000..af8e537bd --- /dev/null +++ b/packages/hashi/sui-codegen.config.ts @@ -0,0 +1,16 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { SuiCodegenConfig } from '@mysten/codegen'; + +const config: SuiCodegenConfig = { + output: './src/contracts', + packages: [ + { + package: '@local-pkg/hashi', // TODO: update this when hashi is published on MVR. + path: '../../../hashi/packages/hashi', + }, + ], +}; + +export default config; diff --git a/packages/hashi/test/integration/_env.ts b/packages/hashi/test/integration/_env.ts new file mode 100644 index 000000000..55a05a5bb --- /dev/null +++ b/packages/hashi/test/integration/_env.ts @@ -0,0 +1,323 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { execFile } from 'node:child_process'; +import { createPrivateKey } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { promisify } from 'node:util'; +import { SuiGrpcClient } from '@mysten/sui/grpc'; +import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'; +import { decodeSuiPrivateKey } from '@mysten/sui/cryptography'; +import { HashiClient, hashi } from '../../src/client.js'; +import { NETWORK_CONFIG } from '../../src/constants.js'; +import type { BitcoinNetwork, SuiNetwork } from '../../src/types.js'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_DEVNET_RPC = 'https://fullnode.devnet.sui.io:443'; + +export type ExtendedHashiClient = SuiGrpcClient & { hashi: HashiClient }; + +/** + * Localnet target is selected by `HASHI_E2E_SUI_NETWORK=localnet`. The CI + * workflow exports this alongside the freshly-published package/Hashi-object + * IDs; locally, leaving it unset (or `=devnet`) falls back to the historical + * devnet flow that `view.test.ts` and `deposit.test.ts` already document. + */ +export function isLocalnet(): boolean { + return process.env.HASHI_E2E_SUI_NETWORK === 'localnet'; +} + +interface ResolvedClientConfig { + rpcUrl: string; + network: SuiNetwork; + packageId: string; + hashiObjectId: string; + bitcoinNetwork: BitcoinNetwork; +} + +function resolveClientConfig(): ResolvedClientConfig { + const network = (process.env.HASHI_E2E_SUI_NETWORK ?? 'devnet') as SuiNetwork; + const rpcUrl = process.env.HASHI_E2E_SUI_RPC_URL ?? DEFAULT_DEVNET_RPC; + const fallback = NETWORK_CONFIG[network]; + const packageId = process.env.HASHI_E2E_PACKAGE_ID ?? fallback?.packageId; + const hashiObjectId = process.env.HASHI_E2E_HASHI_OBJECT_ID ?? fallback?.hashiObjectId; + const bitcoinNetwork = (process.env.HASHI_E2E_BITCOIN_NETWORK ?? fallback?.bitcoinNetwork) as + | BitcoinNetwork + | undefined; + if (!packageId || !hashiObjectId || !bitcoinNetwork) { + throw new Error( + `Missing integration-test config for network=${network}. ` + + 'Set HASHI_E2E_PACKAGE_ID, HASHI_E2E_HASHI_OBJECT_ID, and ' + + 'HASHI_E2E_BITCOIN_NETWORK (localnet), or run against a network ' + + 'that has a NETWORK_CONFIG entry (devnet).', + ); + } + return { rpcUrl, network, packageId, hashiObjectId, bitcoinNetwork }; +} + +/** + * Constructs the SDK client wired to whatever target the env points at. + * Single source of truth for `new SuiGrpcClient(...).$extend(hashi(...))` + * so devnet and localnet tests stay byte-identical at the call site. + */ +export function makeClient(): ExtendedHashiClient { + const cfg = resolveClientConfig(); + return new SuiGrpcClient({ network: cfg.network, baseUrl: cfg.rpcUrl }).$extend( + hashi({ + network: cfg.network, + packageId: cfg.packageId, + hashiObjectId: cfg.hashiObjectId, + bitcoinNetwork: cfg.bitcoinNetwork, + }), + ); +} + +/** + * Resolves the test signer. Localnet supplies a PKCS#8 PEM written by + * `hashi-localnet start` (the genesis-funded user key); devnet supplies a + * `suiprivkey1…` bech32 string via `.env`. Node's `createPrivateKey` parses + * the PKCS#8 wrapper and exposes the Ed25519 seed via JWK `d`, which feeds + * straight into `Ed25519Keypair.fromSecretKey`. + */ +export function makeSigner(): Ed25519Keypair { + const pemPath = process.env.HASHI_E2E_FUNDED_KEY_PEM; + if (pemPath) { + const pem = readFileSync(pemPath, 'utf8'); + const key = createPrivateKey({ key: pem, format: 'pem' }); + const jwk = key.export({ format: 'jwk' }) as { d?: string; kty?: string; crv?: string }; + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !jwk.d) { + throw new Error( + `Expected an Ed25519 PKCS#8 PEM at ${pemPath}, got jwk=${JSON.stringify(jwk)}`, + ); + } + const seed = Buffer.from(jwk.d, 'base64url'); + if (seed.length !== 32) { + throw new Error(`Expected 32-byte Ed25519 seed at ${pemPath}, got ${seed.length}`); + } + return Ed25519Keypair.fromSecretKey(new Uint8Array(seed)); + } + const bech32 = process.env.HASHI_E2E_SUI_PRIVATE_KEY; + if (!bech32) { + throw new Error( + 'No signer available: set HASHI_E2E_FUNDED_KEY_PEM (localnet, written ' + + 'by `hashi-localnet start`) or HASHI_E2E_SUI_PRIVATE_KEY (devnet, bech32).', + ); + } + return Ed25519Keypair.fromSecretKey(decodeSuiPrivateKey(bech32).secretKey); +} + +/** + * Invokes the `hashi-localnet` Rust CLI from the submodule. The CI workflow + * sets `HASHI_E2E_LOCALNET_BIN` to its absolute path and `HASHI_E2E_LOCALNET_DATA_DIR` + * to the state dir; we always inject `--data-dir` so callers can't forget it. + * + * `--data-dir` is a *per-subcommand* flag in clap (each of `start`, `stop`, + * `mine`, `faucet-sui`, `faucet-btc`, `deposit`, … defines its own), not a + * top-level flag — so it must appear *after* the subcommand. Putting it + * before the subcommand fails with `error: unexpected argument '--data-dir'`. + */ +export async function localnetCli(args: string[]): Promise<{ stdout: string; stderr: string }> { + const bin = process.env.HASHI_E2E_LOCALNET_BIN; + const dataDir = process.env.HASHI_E2E_LOCALNET_DATA_DIR; + if (!bin || !dataDir) { + throw new Error( + 'HASHI_E2E_LOCALNET_BIN and HASHI_E2E_LOCALNET_DATA_DIR must be set on localnet', + ); + } + if (args.length === 0) { + throw new Error('localnetCli requires at least a subcommand'); + } + const [subcommand, ...rest] = args; + return execFileAsync(bin, [subcommand, '--data-dir', dataDir, ...rest]); +} + +interface BtcRpcOptions { + /** Wallet name to scope the RPC against (e.g. "test"). */ + wallet?: string; +} + +let btcRpcRequestId = 0; + +/** + * Calls Bitcoin Core JSON-RPC against the local regtest node. Reads URL/creds + * from env (`HASHI_E2E_BTC_RPC_URL`, `HASHI_E2E_BTC_RPC_USER`, + * `HASHI_E2E_BTC_RPC_PASS`). Pass `wallet` to target a specific wallet endpoint. + */ +export async function btcRpc( + method: string, + params: unknown[] = [], + opts: BtcRpcOptions = {}, +): Promise { + const url = process.env.HASHI_E2E_BTC_RPC_URL; + const user = process.env.HASHI_E2E_BTC_RPC_USER; + const pass = process.env.HASHI_E2E_BTC_RPC_PASS; + if (!url || !user || !pass) { + throw new Error( + 'Bitcoin RPC env not configured: set HASHI_E2E_BTC_RPC_URL/USER/PASS (localnet).', + ); + } + const target = opts.wallet ? `${url}/wallet/${opts.wallet}` : url; + const auth = Buffer.from(`${user}:${pass}`).toString('base64'); + const resp = await fetch(target, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Basic ${auth}` }, + body: JSON.stringify({ jsonrpc: '1.0', id: ++btcRpcRequestId, method, params }), + }); + const body = (await resp.json()) as { result?: T; error?: { message: string } | null }; + if (body.error) throw new Error(`bitcoin RPC ${method} failed: ${body.error.message}`); + return body.result as T; +} + +/** + * Fetches a Sui address's balance of a given coin type via the gRPC client. + * + * Uses gRPC `state.getBalance` rather than JSON-RPC `suix_getBalance` so a + * read issued immediately after a `signAndExecuteTransaction` returns the + * post-tx state. The JSON-RPC indexer can lag a committed checkpoint by a + * few hundred milliseconds on localnet — long enough for the previous + * implementation to fail withdrawal-lifecycle's "balance dropped after + * requestWithdrawal" assertion (PR #11 run 25099701367). + */ +export async function fetchCoinBalance( + client: ExtendedHashiClient, + address: string, + coinType: string, +): Promise { + const { balance } = await client.core.getBalance({ owner: address, coinType }); + return BigInt(balance.balance); +} + +/** Default per-test SUI gas allocation: 1000 SUI in MIST. Plenty of headroom. */ +const DEFAULT_FAUCET_AMOUNT_MIST = 1_000_000_000_000n; + +/** + * Localnet-only — generates a fresh Ed25519 keypair and funds its Sui + * address with gas via `hashi-localnet faucet-sui`. Each test that needs + * a signer should call this in `beforeAll` so concurrent integration test + * files don't share a sender (no gas-object races, no hBTC cross-talk + * between tests). The genesis-funded keypair stays the faucet source — + * no test should sign with it directly. + */ +export async function freshFundedSigner( + opts: { suiAmountMist?: bigint } = {}, +): Promise { + const keypair = Ed25519Keypair.generate(); + const amount = opts.suiAmountMist ?? DEFAULT_FAUCET_AMOUNT_MIST; + await localnetCli(['faucet-sui', keypair.toSuiAddress(), '--amount', String(amount)]); + return keypair; +} + +export function btcCoinType(): string { + const cfg = resolveClientConfig(); + return `${cfg.packageId}::btc::BTC`; +} + +export interface FundedUtxo { + /** 0x-prefixed display-order Bitcoin txid. */ + readonly txid: string; + readonly vout: number; + readonly amountSats: bigint; +} + +/** + * Localnet-only — derive the deposit address for `suiAddress`, send a + * funding tx to it from the pre-mined `test` wallet, mine enough blocks + * to clear the on-chain confirmation threshold, and return the resulting + * UTXO in the exact shape the SDK's `deposit()` accepts. + * + * Padding: `bitcoinDepositMinimum + 100_000` sats — leaves headroom for + * governance changes without tripping `AmountBelowMinimumError`. + */ +export async function fundDepositOnLocalnet( + client: ExtendedHashiClient, + suiAddress: string, +): Promise<{ funded: FundedUtxo; depositAddress: string }> { + const depositAddress = await client.hashi.generateDepositAddress({ suiAddress }); + const minDepositSats = await client.hashi.view.bitcoinDepositMinimum(); + const amountSats = minDepositSats + 100_000n; + const amountBtc = Number(amountSats) / 1e8; + + const txid = await btcRpc('sendtoaddress', [depositAddress, amountBtc], { + wallet: 'test', + }); + + const threshold = await client.hashi.view.bitcoinConfirmationThreshold(); + await localnetCli(['mine', '--blocks', String(Number(threshold) + 1)]); + + interface RawTxOut { + n: number; + value: number; + scriptPubKey: { address?: string }; + } + const tx = await btcRpc<{ vout: RawTxOut[] }>('getrawtransaction', [txid, true], { + wallet: 'test', + }); + const out = tx.vout.find((v) => v.scriptPubKey.address === depositAddress); + if (!out) { + throw new Error(`localnet funding tx ${txid} did not contain an output for ${depositAddress}`); + } + return { + funded: { + txid: `0x${txid}`, + vout: out.n, + amountSats: BigInt(Math.round(out.value * 1e8)), + }, + depositAddress, + }; +} + +/** + * Polls a Sui address's coin balance until it reaches `target` or the + * deadline expires. Returns the final balance on success; throws with + * the last observed value on timeout. + */ +export async function waitForCoinBalance( + client: ExtendedHashiClient, + address: string, + coinType: string, + target: bigint, + opts: { timeoutMs: number; intervalMs: number }, +): Promise { + const deadline = Date.now() + opts.timeoutMs; + let last = 0n; + for (;;) { + last = await fetchCoinBalance(client, address, coinType); + if (last >= target) return last; + if (Date.now() >= deadline) { + throw new Error( + `coin balance did not reach target within ${opts.timeoutMs} ms — ` + + `address=${address}, coin=${coinType}, target=${target}, last=${last}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, opts.intervalMs)); + } +} + +/** + * Polls until the balance equals `expected` exactly. Use after a transaction + * that should leave a known balance (e.g. the post-`requestWithdrawal` lock) + * — Sui's balance index updates a beat after `signAndExecuteTransaction` + * returns, so a same-tick read can briefly return the pre-tx value. + */ +export async function waitForCoinBalanceExact( + client: ExtendedHashiClient, + address: string, + coinType: string, + expected: bigint, + opts: { timeoutMs: number; intervalMs: number }, +): Promise { + const deadline = Date.now() + opts.timeoutMs; + let last = 0n; + for (;;) { + last = await fetchCoinBalance(client, address, coinType); + if (last === expected) return last; + if (Date.now() >= deadline) { + throw new Error( + `coin balance did not reach ${expected} within ${opts.timeoutMs} ms — ` + + `address=${address}, coin=${coinType}, last=${last}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, opts.intervalMs)); + } +} diff --git a/packages/hashi/test/integration/deposit.test.ts b/packages/hashi/test/integration/deposit.test.ts new file mode 100644 index 000000000..748ff8b29 --- /dev/null +++ b/packages/hashi/test/integration/deposit.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; + +import type { DepositHistoryItem } from '../../src/types.js'; +import { + btcCoinType, + fetchCoinBalance, + freshFundedSigner, + fundDepositOnLocalnet, + isLocalnet, + makeClient, + makeSigner, + waitForCoinBalance, +} from './_env.js'; + +/** + * Real-network deposit smoke test. Two execution targets, same assertions: + * + * - **devnet** (default): submits the env-configured signet UTXO to Sui + * devnet. Polling hBTC arrival is opt-in (`HASHI_E2E_WAIT_FOR_HBTC=1`) + * because committee latency on devnet varies (8 min – 1.5 h). + * - **localnet** (`HASHI_E2E_SUI_NETWORK=localnet`, set by CI): derives a + * deposit address, sends real BTC to it via `bitcoin-cli`, mines enough + * confirmations, captures the resulting txid/vout, then submits via the + * SDK and asserts hBTC arrival within ~60 s. The full contract — submit + * + committee verify + mint — is exactly what SEDEFI-190 (txid byte-order + * bug) silently violated for weeks; this lane is the regression backstop. + * + * For local devnet runs, populate `.env`: + * HASHI_E2E_SUI_PRIVATE_KEY=suiprivkey1… + * HASHI_E2E_BTC_TXID=<64-char hex, no 0x prefix> + * HASHI_E2E_BTC_VOUT= + * HASHI_E2E_BTC_AMOUNT_SATS= + * then `pnpm test:integration`. + */ + +const HBTC_POLL_INTERVAL_MS = 30_000; +const DEVNET_HBTC_TIMEOUT_MS = 15 * 60_000; +// 300 s tolerates Kyoto BIP-157 light-client peer-discovery + sync warmup +// on a cold CI runner. Empirically the "Deposit request detected" → +// "Processing" gap is ~180 s on the first deposit (validators block on +// Kyoto peering with bitcoind and back-filling block filters); once warm, +// subsequent deposits flush in <5 s. PR #11 CI run 25059655658 had +// detection→confirmation take 184 s and missed a 180 s SDK budget by 4 s. +// 300 s gives ~2× headroom over the observed steady warmup. +const LOCALNET_HBTC_TIMEOUT_MS = 300_000; +const LOCALNET_HBTC_INTERVAL_MS = 2_000; + +describe('HashiClient.deposit (real network)', () => { + if (isLocalnet()) { + it( + 'fund regtest deposit address, submit via SDK, and verify hBTC arrival', + async () => { + const client = makeClient(); + // Fresh signer per test so concurrent test files don't share a + // sender — eliminates gas-object races and hBTC cross-talk + // between deposit.test.ts and withdrawal-lifecycle.test.ts. + const signer = await freshFundedSigner(); + const recipient = signer.toSuiAddress(); + + // A fresh signer starts with zero hBTC, so this is 0n by + // construction. Read it anyway to keep the assertion shape + // identical to the devnet path and resilient against any + // future change that pre-funds new signers with hBTC. + const balanceBefore = await fetchCoinBalance(client, recipient, btcCoinType()); + + const { funded } = await fundDepositOnLocalnet(client, recipient); + + const result = await client.hashi.deposit({ + signer, + txid: funded.txid, + utxos: [{ vout: funded.vout, amountSats: funded.amountSats }], + recipient, + }); + + expect(result.$kind).toBe('Transaction'); + if (result.$kind !== 'Transaction') { + throw new Error(`Transaction failed: ${JSON.stringify(result.FailedTransaction)}`); + } + expect(result.Transaction.status.success).toBe(true); + + const evt = result.Transaction.events?.find((e) => + e.eventType.endsWith('::deposit::DepositRequested'), + ); + expect(evt).toBeDefined(); + + const target = balanceBefore + funded.amountSats; + const final = await waitForCoinBalance(client, recipient, btcCoinType(), target, { + timeoutMs: LOCALNET_HBTC_TIMEOUT_MS, + intervalMs: LOCALNET_HBTC_INTERVAL_MS, + }); + expect(final).toBeGreaterThanOrEqual(target); + + // --- view method assertions (SEDEFI-201) --- + + // The deposited UTXO should now appear in the active pool. + const usage = await client.hashi.view.findUsedUtxos([ + { txid: funded.txid, vout: funded.vout }, + ]); + expect(usage).toHaveLength(1); + expect(usage[0].isUsed).toBe(true); + expect(usage[0].inActivePool).toBe(true); + + // Transaction history should contain the deposit with correct + // btcTxid and btcVout extracted from the on-chain DepositRequest. + const history = await client.hashi.view.transactionHistory(recipient); + const dep = history.find( + (h): h is DepositHistoryItem => + h.kind === 'deposit' && h.btcTxid === funded.txid.replace(/^0x/, ''), + ); + expect(dep).toBeDefined(); + expect(dep!.btcVout).toBe(funded.vout); + expect(dep!.amountSats).toBe(funded.amountSats); + expect(dep!.sender).toBe(recipient); + }, + LOCALNET_HBTC_TIMEOUT_MS + 60_000, + ); + return; + } + + // Devnet path — env-configured signet UTXO. Fail loudly at module load + // if any var is missing so a misconfigured `.env` doesn't masquerade as + // a clean run with zero tests. + const TEST_TXID = process.env.HASHI_E2E_BTC_TXID; + const TEST_VOUT = process.env.HASHI_E2E_BTC_VOUT; + const TEST_AMOUNT_SATS = process.env.HASHI_E2E_BTC_AMOUNT_SATS; + if (!process.env.HASHI_E2E_SUI_PRIVATE_KEY || !TEST_TXID || !TEST_VOUT || !TEST_AMOUNT_SATS) { + throw new Error( + 'Set HASHI_E2E_SUI_PRIVATE_KEY, HASHI_E2E_BTC_TXID, HASHI_E2E_BTC_VOUT, ' + + 'and HASHI_E2E_BTC_AMOUNT_SATS in `.env` (or run with ' + + 'HASHI_E2E_SUI_NETWORK=localnet for the localnet flow).', + ); + } + + const waitForHBtc = process.env.HASHI_E2E_WAIT_FOR_HBTC === '1'; + const testTimeoutMs = waitForHBtc ? DEVNET_HBTC_TIMEOUT_MS + 60_000 : 120_000; + + it( + 'submits a real deposit for the configured signet UTXO and emits DepositRequested', + async () => { + const client = makeClient(); + const signer = makeSigner(); + const recipient = signer.toSuiAddress(); + const amountSats = BigInt(TEST_AMOUNT_SATS); + + const balanceBefore = waitForHBtc + ? await fetchCoinBalance(client, recipient, btcCoinType()) + : 0n; + + const result = await client.hashi.deposit({ + signer, + txid: `0x${TEST_TXID}`, + utxos: [{ vout: Number(TEST_VOUT), amountSats }], + recipient, + }); + + expect(result.$kind).toBe('Transaction'); + if (result.$kind !== 'Transaction') { + throw new Error(`Transaction failed: ${JSON.stringify(result.FailedTransaction)}`); + } + expect(result.Transaction.status.success).toBe(true); + + const evt = result.Transaction.events?.find((e) => + e.eventType.endsWith('::deposit::DepositRequested'), + ); + expect(evt).toBeDefined(); + + if (!waitForHBtc) return; + + const target = balanceBefore + amountSats; + const deadline = Date.now() + DEVNET_HBTC_TIMEOUT_MS; + // eslint-disable-next-line no-console + console.log( + `[deposit.test] waiting for hBTC at ${recipient}: ` + + `before=${balanceBefore}, target=${target}, ` + + `timeout=${DEVNET_HBTC_TIMEOUT_MS / 60_000} min`, + ); + for (;;) { + const current = await fetchCoinBalance(client, recipient, btcCoinType()); + if (current >= target) { + // eslint-disable-next-line no-console + console.log(`[deposit.test] hBTC arrived: balance=${current}`); + expect(current).toBeGreaterThanOrEqual(target); + return; + } + if (Date.now() >= deadline) { + throw new Error( + `hBTC did not arrive within ${DEVNET_HBTC_TIMEOUT_MS / 60_000} min — ` + + `recipient=${recipient}, before=${balanceBefore}, ` + + `target=${target}, last=${current}. ` + + `Most likely the committee couldn't verify the deposit ` + + `(check txid byte-order, BTC confirmations, or committee health).`, + ); + } + await new Promise((resolve) => setTimeout(resolve, HBTC_POLL_INTERVAL_MS)); + } + }, + testTimeoutMs, + ); +}); diff --git a/packages/hashi/test/integration/generate-deposit-address.test.ts b/packages/hashi/test/integration/generate-deposit-address.test.ts new file mode 100644 index 000000000..0f01d91da --- /dev/null +++ b/packages/hashi/test/integration/generate-deposit-address.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'; +import { fromHex } from '@mysten/sui/utils'; +import { + generateDepositAddress, + twoOfTwoTaprootScriptPathAddress, + deriveChildPubkey, +} from '../../src/bitcoin.js'; +import { isLocalnet, makeClient } from './_env.js'; + +/** + * Replaces `_show-address.test.ts`. Asserts that `generateDepositAddress` + * returns a P2TR taproot address with the correct human-readable prefix + * for the configured Bitcoin network — `bcrt1p…` on regtest (localnet), + * `tb1p…` on signet/testnet (devnet). Both targets exercise the same + * SDK code path: only the on-chain MPC key + bitcoin-network hint differ. + * + * Pure address derivation — no signing, no funding. A generated keypair's + * Sui address is the only input that matters; the test never submits a tx. + */ +describe('HashiClient.generateDepositAddress (real network)', () => { + it('returns a P2TR address whose HRP matches the configured Bitcoin network', async () => { + const client = makeClient(); + const suiAddress = Ed25519Keypair.generate().toSuiAddress(); + + const btcAddress = await client.hashi.generateDepositAddress({ suiAddress }); + + const expectedPrefix = isLocalnet() ? 'bcrt1p' : 'tb1p'; + expect(btcAddress.startsWith(expectedPrefix)).toBe(true); + }, 30_000); + + /** + * End-to-end cross-check: rebuild the same address through the public + * primitives (`view.mpcPublicKey` + `view.all().guardianBtcPublicKey` + + * pure helpers). Catches drift between `HashiClient.generateDepositAddress` + * and the lower-level primitives. If the on-chain `guardian_btc_public_key` + * is not yet provisioned, this test skips itself rather than failing — + * pre-deploy windows are expected. + */ + it('matches the manual primitive composition (mpc + guardian + derive + 2-of-2)', async () => { + const client = makeClient(); + const suiAddress = Ed25519Keypair.generate().toSuiAddress(); + + const [mpc, snap] = await Promise.all([ + client.hashi.view.mpcPublicKey(), + client.hashi.view.all(), + ]); + if (!snap.guardianBtcPublicKey) { + // Deployment isn't guardian-provisioned yet; the unit tests cover + // the deterministic path. + return; + } + + const fromClient = await client.hashi.generateDepositAddress({ suiAddress }); + + const network = isLocalnet() ? 'regtest' : 'signet'; + const fromHelpers = generateDepositAddress({ + mpcMasterCompressed: mpc, + guardianBtcXOnly: snap.guardianBtcPublicKey, + suiAddress: fromHex(suiAddress), + network, + }); + + // Also reconstruct via the bottom-level helper. + const child = deriveChildPubkey(mpc, fromHex(suiAddress)); + const fromTwoOfTwo = twoOfTwoTaprootScriptPathAddress( + snap.guardianBtcPublicKey, + child, + network, + ); + + expect(fromClient).toBe(fromHelpers); + expect(fromClient).toBe(fromTwoOfTwo); + }, 30_000); +}); diff --git a/packages/hashi/test/integration/view.test.ts b/packages/hashi/test/integration/view.test.ts new file mode 100644 index 000000000..4cea4851f --- /dev/null +++ b/packages/hashi/test/integration/view.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { ExtendedHashiClient, isLocalnet, makeClient } from './_env.js'; + +/** + * Integration tests for `HashiClient.view.*`. + * + * Same test bodies run against both targets: + * - **devnet** (default): hits `https://fullnode.devnet.sui.io:443`. A + * 5-second spacer runs after every test to avoid public-RPC rate limiting. + * - **localnet** (CI): hits a fresh `hashi-localnet` Sui node on `127.0.0.1`. + * No rate limit, so we drop the spacer. + * + * Values are checked as loose invariants (types, positivity, floor + * relationships) rather than exact matches, because both targets carry + * configurable governance state. + */ +describe('HashiClient.view', () => { + let client: ExtendedHashiClient; + + beforeAll(() => { + client = makeClient(); + }); + + if (!isLocalnet()) { + afterEach(() => new Promise((resolve) => setTimeout(resolve, 5000))); + } + + const TIMEOUT = 30_000; + + it( + 'all returns a full governance snapshot in a single round-trip', + async () => { + const snap = await client.hashi.view.all(); + + expect(typeof snap.paused).toBe('boolean'); + expect(typeof snap.bitcoinChainId).toBe('string'); + expect(snap.bitcoinChainId).toMatch(/^0x[0-9a-f]{64}$/); + expect(typeof snap.bitcoinDepositMinimum).toBe('bigint'); + expect(typeof snap.bitcoinWithdrawalMinimum).toBe('bigint'); + expect(typeof snap.bitcoinConfirmationThreshold).toBe('bigint'); + expect(typeof snap.withdrawalCancellationCooldownMs).toBe('bigint'); + expect(typeof snap.depositMinimum).toBe('bigint'); + expect(typeof snap.worstCaseNetworkFee).toBe('bigint'); + }, + TIMEOUT, + ); + + it( + 'mpcPublicKey returns a 33-byte compressed secp256k1 key', + async () => { + const key = await client.hashi.view.mpcPublicKey(); + expect(key).toBeInstanceOf(Uint8Array); + expect(key.length).toBe(33); + expect(key[0]).toBeOneOf([0x02, 0x03]); + }, + TIMEOUT, + ); + + it( + 'paused returns a boolean', + async () => { + const paused = await client.hashi.view.paused(); + expect(typeof paused).toBe('boolean'); + }, + TIMEOUT, + ); + + it( + 'bitcoinDepositMinimum is at least DUST_RELAY_MIN_VALUE (546)', + async () => { + const min = await client.hashi.view.bitcoinDepositMinimum(); + expect(min).toBeGreaterThanOrEqual(546n); + }, + TIMEOUT, + ); + + it( + 'bitcoinWithdrawalMinimum is at least DUST_RELAY_MIN_VALUE + 1 (547)', + async () => { + const min = await client.hashi.view.bitcoinWithdrawalMinimum(); + expect(min).toBeGreaterThanOrEqual(547n); + }, + TIMEOUT, + ); + + it( + 'bitcoinConfirmationThreshold is positive', + async () => { + const n = await client.hashi.view.bitcoinConfirmationThreshold(); + expect(n).toBeGreaterThan(0n); + }, + TIMEOUT, + ); + + it( + 'withdrawalCancellationCooldownMs is non-negative', + async () => { + const ms = await client.hashi.view.withdrawalCancellationCooldownMs(); + expect(ms).toBeGreaterThanOrEqual(0n); + }, + TIMEOUT, + ); + + it( + 'bitcoinChainId is a 0x-prefixed 32-byte hex address', + async () => { + const id = await client.hashi.view.bitcoinChainId(); + expect(id).toMatch(/^0x[0-9a-f]{64}$/); + }, + TIMEOUT, + ); + + it( + 'depositMinimum equals bitcoinDepositMinimum', + async () => { + const snap = await client.hashi.view.all(); + expect(snap.depositMinimum).toBe(snap.bitcoinDepositMinimum); + }, + TIMEOUT, + ); + + it( + 'worstCaseNetworkFee equals bitcoinWithdrawalMinimum - 546', + async () => { + const snap = await client.hashi.view.all(); + expect(snap.worstCaseNetworkFee).toBe(snap.bitcoinWithdrawalMinimum - 546n); + expect(snap.worstCaseNetworkFee).toBeGreaterThanOrEqual(1n); + }, + TIMEOUT, + ); + + it( + 'findUsedUtxos returns not-used for a made-up UTXO', + async () => { + const results = await client.hashi.view.findUsedUtxos([ + { txid: '0x' + 'ab'.repeat(32), vout: 0 }, + ]); + expect(results).toHaveLength(1); + expect(results[0].isUsed).toBe(false); + expect(results[0].inActivePool).toBe(false); + expect(results[0].inSpentPool).toBe(false); + }, + TIMEOUT, + ); + + it( + 'findUsedUtxos returns empty array for empty input', + async () => { + const results = await client.hashi.view.findUsedUtxos([]); + expect(results).toEqual([]); + }, + TIMEOUT, + ); + + it( + 'transactionHistory returns empty array for an address with no requests', + async () => { + const items = await client.hashi.view.transactionHistory('0x' + '00'.repeat(32)); + expect(items).toEqual([]); + }, + TIMEOUT, + ); +}); diff --git a/packages/hashi/test/integration/withdrawal-lifecycle.test.ts b/packages/hashi/test/integration/withdrawal-lifecycle.test.ts new file mode 100644 index 000000000..f6fc85f0d --- /dev/null +++ b/packages/hashi/test/integration/withdrawal-lifecycle.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeAll } from 'vitest'; +import type { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'; + +import type { WithdrawalHistoryItem } from '../../src/types.js'; +import { + btcCoinType, + btcRpc, + fetchCoinBalance, + freshFundedSigner, + fundDepositOnLocalnet, + isLocalnet, + makeClient, + waitForCoinBalance, + waitForCoinBalanceExact, + type ExtendedHashiClient, +} from './_env.js'; + +/** + * Localnet-only — full deposit → request withdrawal → cancel withdrawal + * lifecycle as three sequential `it()` blocks. Sharing state via the + * `describe` scope keeps the chain of preconditions (signer must own + * hBTC; cancel must reference the just-emitted `request_id`) inside one + * file so vitest's intra-describe ordering is the only ordering guarantee + * we rely on. + * + * Skipped on devnet: cancellation cooldown alone (devnet is configured for + * minutes-to-hours) makes a single-test-run lifecycle infeasible. The + * underscore-prefixed dev-tool tests this replaces handled the two halves + * separately, with operator-driven waits in between. + */ + +// See deposit.test.ts for why 300 s — Kyoto BIP-157 warmup on cold CI. +const LOCALNET_HBTC_TIMEOUT_MS = 300_000; +const LOCALNET_HBTC_INTERVAL_MS = 2_000; +const COOLDOWN_BUDGET_MS = 30_000; + +interface LifecycleState { + client: ExtendedHashiClient; + signer: Ed25519Keypair; + recipient: string; + balanceBeforeDeposit: bigint; + balanceAfterDeposit: bigint; + requestId: string; + withdrawAmountSats: bigint; + cancellationCooldownMs: bigint; + /** Set when `requestWithdrawal` succeeds, used to gate the cooldown wait. */ + requestSubmittedAt: number; +} + +describe.skipIf(!isLocalnet())('HashiClient withdrawal lifecycle (localnet)', () => { + const state = {} as LifecycleState; + + beforeAll(async () => { + state.client = makeClient(); + // Fresh signer per test — see freshFundedSigner docstring. Each + // localnet integration test owns its own Sui address, so concurrent + // test files don't share gas objects or hBTC balances. + state.signer = await freshFundedSigner(); + state.recipient = state.signer.toSuiAddress(); + }); + + it( + 'deposit: signer mints hBTC by funding the derived deposit address', + async () => { + state.balanceBeforeDeposit = await fetchCoinBalance( + state.client, + state.recipient, + btcCoinType(), + ); + + const { funded } = await fundDepositOnLocalnet(state.client, state.recipient); + + const result = await state.client.hashi.deposit({ + signer: state.signer, + txid: funded.txid, + utxos: [{ vout: funded.vout, amountSats: funded.amountSats }], + recipient: state.recipient, + }); + + expect(result.$kind).toBe('Transaction'); + if (result.$kind !== 'Transaction') { + throw new Error(`Transaction failed: ${JSON.stringify(result.FailedTransaction)}`); + } + expect(result.Transaction.status.success).toBe(true); + + const target = state.balanceBeforeDeposit + funded.amountSats; + state.balanceAfterDeposit = await waitForCoinBalance( + state.client, + state.recipient, + btcCoinType(), + target, + { timeoutMs: LOCALNET_HBTC_TIMEOUT_MS, intervalMs: LOCALNET_HBTC_INTERVAL_MS }, + ); + expect(state.balanceAfterDeposit).toBeGreaterThanOrEqual(target); + }, + LOCALNET_HBTC_TIMEOUT_MS + 60_000, + ); + + it('requestWithdrawal: emits WithdrawalRequested and burns hBTC', async () => { + const snap = await state.client.hashi.view.all(); + state.cancellationCooldownMs = snap.withdrawalCancellationCooldownMs; + + // Withdraw min + 1000 so the change-of-balance assertion is + // unambiguously decided by request/cancel, not by floor rounding. + state.withdrawAmountSats = snap.bitcoinWithdrawalMinimum + 1_000n; + + // A regtest BTC address from the Core test wallet — used as the + // withdrawal target. The test never expects this address to be + // paid (we cancel before the committee processes), so any valid + // bech32 regtest address works. + const bitcoinAddress = await btcRpc('getnewaddress', [], { wallet: 'test' }); + + const result = await state.client.hashi.requestWithdrawal({ + signer: state.signer, + amountSats: state.withdrawAmountSats, + bitcoinAddress, + }); + state.requestSubmittedAt = Date.now(); + + expect(result.$kind).toBe('Transaction'); + if (result.$kind !== 'Transaction') { + throw new Error(`Transaction failed: ${JSON.stringify(result.FailedTransaction)}`); + } + expect(result.Transaction.status.success).toBe(true); + + const evt = result.Transaction.events?.find((e) => + e.eventType.endsWith('::withdrawal_queue::WithdrawalRequested'), + ); + expect(evt).toBeDefined(); + + // The gRPC client (`@mysten/sui/grpc`) surfaces parsed Move event + // data under `event.json`, not the JSON-RPC-era `event.parsedJson`. + // Mixing the two silently returns undefined and looks like the + // event was malformed. + const parsed = (evt as unknown as { json?: { request_id?: string } }).json; + if (!parsed?.request_id) { + throw new Error(`WithdrawalRequested missing request_id: ${JSON.stringify(evt)}`); + } + state.requestId = parsed.request_id; + + // Sanity: hBTC is locked at request time — balance should drop by + // exactly the requested amount once the request lands. The balance + // index updates a beat after `signAndExecuteTransaction` returns, + // so we poll until it matches rather than reading once. + const expected = state.balanceAfterDeposit - state.withdrawAmountSats; + await waitForCoinBalanceExact(state.client, state.recipient, btcCoinType(), expected, { + timeoutMs: 5_000, + intervalMs: 100, + }); + + // --- view method assertions (SEDEFI-201) --- + // Transaction history should include the withdrawal with status "Requested". + const history = await state.client.hashi.view.transactionHistory(state.recipient); + const wd = history.find( + (h): h is WithdrawalHistoryItem => h.kind === 'withdrawal' && h.requestId === state.requestId, + ); + expect(wd).toBeDefined(); + expect(wd!.status).toBe('Requested'); + expect(wd!.btcAmountSats).toBe(state.withdrawAmountSats); + expect(wd!.btcTxid).toBeNull(); // committee hasn't broadcast yet + }, 60_000); + + it( + 'cancelWithdrawal: returns the locked hBTC to the requester', + async () => { + if (state.cancellationCooldownMs > BigInt(COOLDOWN_BUDGET_MS)) { + // eslint-disable-next-line no-console + console.log( + `[withdrawal-lifecycle] skipping cancel: cooldown ` + + `${state.cancellationCooldownMs} ms exceeds test budget ` + + `${COOLDOWN_BUDGET_MS} ms. Lower it in localnet defaults to ` + + `re-enable.`, + ); + return; + } + + const elapsed = Date.now() - state.requestSubmittedAt; + const remaining = Number(state.cancellationCooldownMs) - elapsed; + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining + 250)); + } + + const result = await state.client.hashi.cancelWithdrawal({ + signer: state.signer, + requestId: state.requestId, + }); + + expect(result.$kind).toBe('Transaction'); + if (result.$kind !== 'Transaction') { + throw new Error(`Transaction failed: ${JSON.stringify(result.FailedTransaction)}`); + } + expect(result.Transaction.status.success).toBe(true); + + const evt = result.Transaction.events?.find((e) => + e.eventType.endsWith('::withdrawal_queue::WithdrawalCancelledEvent'), + ); + expect(evt).toBeDefined(); + + // Balance must return to the post-deposit value: cancel unlocks + // exactly what request locked. Same balance-index lag as the + // request step, so poll for equality. + await waitForCoinBalanceExact( + state.client, + state.recipient, + btcCoinType(), + state.balanceAfterDeposit, + { timeoutMs: 5_000, intervalMs: 100 }, + ); + }, + COOLDOWN_BUDGET_MS + 60_000, + ); +}); diff --git a/packages/hashi/test/integration/withdrawal-payout.test.ts b/packages/hashi/test/integration/withdrawal-payout.test.ts new file mode 100644 index 000000000..21ecf11de --- /dev/null +++ b/packages/hashi/test/integration/withdrawal-payout.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; + +import type { WithdrawalHistoryItem } from '../../src/types.js'; +import { + btcCoinType, + btcRpc, + fetchCoinBalance, + freshFundedSigner, + fundDepositOnLocalnet, + isLocalnet, + localnetCli, + makeClient, + waitForCoinBalance, +} from './_env.js'; + +/** + * Localnet-only — full withdrawal happy path. Complements + * `withdrawal-lifecycle.test.ts`, which cancels the request before the + * committee broadcasts, by letting the pipeline run end-to-end: + * + * deposit → mint hBTC → request withdrawal → committee builds + signs + * (MPC) → broadcast to bitcoind → target address receives BTC + * + * The assertion is the BTC payout, not the Sui-side event: the SDK does + * not consume `WithdrawalConfirmedEvent` (operator-only), so polling the + * Bitcoin RPC is the most direct check that the request actually paid out. + * + * Skipped on devnet: the BTC payout end of the pipeline isn't reachable + * from the SDK on devnet (no shared regtest node, no controlled wallet). + */ + +// Same 300 s budget as `deposit.test.ts` — Kyoto BIP-157 warmup on cold CI. +const LOCALNET_HBTC_TIMEOUT_MS = 300_000; +const LOCALNET_HBTC_INTERVAL_MS = 2_000; + +// The withdrawal pipeline (request seen → presigs allocated → MPC sign → +// broadcast) is comparable in cost to deposit confirmation on a cold +// localnet, so we budget the same 300 s. Mining 1 block per poll keeps +// the chain advancing while we wait for the broadcast tx to surface. +const LOCALNET_BTC_PAYOUT_TIMEOUT_MS = 300_000; +const LOCALNET_BTC_PAYOUT_INTERVAL_MS = 2_000; + +describe.skipIf(!isLocalnet())('HashiClient withdrawal payout (localnet)', () => { + it( + 'request withdrawal → committee broadcasts BTC → target address receives funds', + async () => { + const client = makeClient(); + const signer = await freshFundedSigner(); + const recipient = signer.toSuiAddress(); + + // 1. Fund the deposit address and mint hBTC. Reuses the same + // helpers as deposit.test.ts so any flake in this stage is + // pre-existing rather than introduced here. + const balanceBefore = await fetchCoinBalance(client, recipient, btcCoinType()); + const { funded } = await fundDepositOnLocalnet(client, recipient); + + const depositResult = await client.hashi.deposit({ + signer, + txid: funded.txid, + utxos: [{ vout: funded.vout, amountSats: funded.amountSats }], + recipient, + }); + expect(depositResult.$kind).toBe('Transaction'); + if (depositResult.$kind !== 'Transaction') { + throw new Error(`Deposit tx failed: ${JSON.stringify(depositResult.FailedTransaction)}`); + } + expect(depositResult.Transaction.status.success).toBe(true); + + const hbtcTarget = balanceBefore + funded.amountSats; + await waitForCoinBalance(client, recipient, btcCoinType(), hbtcTarget, { + timeoutMs: LOCALNET_HBTC_TIMEOUT_MS, + intervalMs: LOCALNET_HBTC_INTERVAL_MS, + }); + + // 2. Pick a withdrawal amount above the on-chain minimum and a + // BTC destination from a wallet we control so we can poll + // `getreceivedbyaddress` for arrival. + const snap = await client.hashi.view.all(); + const withdrawAmountSats = snap.bitcoinWithdrawalMinimum + 50_000n; + const destination = await btcRpc('getnewaddress', [], { wallet: 'test' }); + + const withdrawResult = await client.hashi.requestWithdrawal({ + signer, + amountSats: withdrawAmountSats, + bitcoinAddress: destination, + }); + expect(withdrawResult.$kind).toBe('Transaction'); + if (withdrawResult.$kind !== 'Transaction') { + throw new Error( + `requestWithdrawal tx failed: ${JSON.stringify(withdrawResult.FailedTransaction)}`, + ); + } + expect(withdrawResult.Transaction.status.success).toBe(true); + + // 3. Poll the destination address until the committee's BTC tx + // surfaces. `minconf=0` includes mempool, so we see the payout + // as soon as the committee broadcasts — no need to wait for a + // confirmation. Mining 1 block per cycle keeps the chain + // moving in case any pipeline step waits on a new block. + // + // The actual amount received is `withdrawAmountSats` minus + // the miner fee the committee paid. `worstCaseNetworkFee` is + // the upper bound on that fee, so the received amount lives + // in the closed interval below. + const minReceived = withdrawAmountSats - snap.worstCaseNetworkFee; + const deadline = Date.now() + LOCALNET_BTC_PAYOUT_TIMEOUT_MS; + let receivedSats = 0n; + for (;;) { + await localnetCli(['mine', '--blocks', '1']); + const receivedBtc = await btcRpc('getreceivedbyaddress', [destination, 0], { + wallet: 'test', + }); + receivedSats = BigInt(Math.round(receivedBtc * 1e8)); + if (receivedSats >= minReceived) break; + if (Date.now() >= deadline) { + throw new Error( + `BTC payout to ${destination} did not arrive within ` + + `${LOCALNET_BTC_PAYOUT_TIMEOUT_MS / 1000} s — ` + + `received=${receivedSats}, minExpected=${minReceived}, ` + + `withdrawAmount=${withdrawAmountSats}, ` + + `worstCaseNetworkFee=${snap.worstCaseNetworkFee}.`, + ); + } + await new Promise((resolve) => setTimeout(resolve, LOCALNET_BTC_PAYOUT_INTERVAL_MS)); + } + + expect(receivedSats).toBeGreaterThanOrEqual(minReceived); + expect(receivedSats).toBeLessThanOrEqual(withdrawAmountSats); + + // --- view method assertions (SEDEFI-201) --- + // After the committee broadcasts, the withdrawal's btcTxid + // should be populated from the linked WithdrawalTransaction. + const history = await client.hashi.view.transactionHistory(recipient); + const wd = history.find( + (h): h is WithdrawalHistoryItem => + h.kind === 'withdrawal' && h.btcAmountSats === withdrawAmountSats, + ); + expect(wd).toBeDefined(); + expect(wd!.btcTxid).toBeTypeOf('string'); + expect(wd!.btcTxid).toMatch(/^[0-9a-f]{64}$/); + // Status should be Processing, Signed, or Confirmed by this point. + expect(['Processing', 'Signed', 'Confirmed']).toContain(wd!.status); + }, + LOCALNET_HBTC_TIMEOUT_MS + LOCALNET_BTC_PAYOUT_TIMEOUT_MS + 120_000, + ); +}); diff --git a/packages/hashi/test/tsconfig.json b/packages/hashi/test/tsconfig.json new file mode 100644 index 000000000..1d74bb46b --- /dev/null +++ b/packages/hashi/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*", "../src"], + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "emitDeclarationOnly": false, + "resolveJsonModule": true, + "types": ["node"] + } +} diff --git a/packages/hashi/test/unit/bitcoin.test.ts b/packages/hashi/test/unit/bitcoin.test.ts new file mode 100644 index 000000000..a1c7d3418 --- /dev/null +++ b/packages/hashi/test/unit/bitcoin.test.ts @@ -0,0 +1,550 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { + deriveChildPubkey, + twoOfTwoTaprootScriptPathAddress, + generateDepositAddress, + arkworksToSec1Compressed, + bitcoinAddressToWitnessProgram, + witnessProgramToAddress, +} from '../../src/bitcoin.js'; +import { InvalidBitcoinAddressError } from '../../src/errors.js'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { bech32, bech32m } from '@scure/base'; +import { fromHex } from '@mysten/sui/utils'; + +/** + * Deterministic test key: secret key = 2 (small-scalar convenience, used for + * tests that just need *some* valid pubkey). For Rust-cross-language vectors + * we use the bytes-of-all-twos form below instead. + */ +const TEST_SECRET_KEY = new Uint8Array(32); +TEST_SECRET_KEY[31] = 2; // scalar = 2 +const TEST_COMPRESSED_KEY = secp256k1.getPublicKey(TEST_SECRET_KEY, true); + +const ZERO_ADDRESS = new Uint8Array(32); // 0x000…000 + +// --------------------------------------------------------------------------- +// Rust-cross-language test fixtures +// --------------------------------------------------------------------------- +// +// Mirrored byte-for-byte from +// `crates/hashi-types/src/bitcoin/taproot.rs` test +// `cross_lang_2of2_test_vectors`. The Rust test uses +// TEST_ENCLAVE_BTC_SK = [1u8; 32] → enclave/guardian keypair +// TEST_HASHI_BTC_SK = [2u8; 32] → MPC master keypair +// and `keypair.x_only_public_key().0` returns the BIP-340 even-y x-coordinate. +// To match that on the TS side, we feed the same secret bytes to noble's +// `getPublicKey(..., true)` and take only the x-coordinate (bytes [1..33]), +// dropping the parity prefix; that is the form the on-chain bridge stores. + +const RUST_ENCLAVE_SK = new Uint8Array(32).fill(1); +const RUST_HASHI_SK = new Uint8Array(32).fill(2); + +/** 32-byte BIP-340 x-only guardian/enclave public key. */ +const RUST_GUARDIAN_X_ONLY = secp256k1.getPublicKey(RUST_ENCLAVE_SK, true).slice(1); + +/** + * 33-byte SEC1 compressed MPC master key with the even-y prefix forced to + * 0x02. The matching Rust `cross_lang_2of2_test_vectors` test uses + * `hashi_master_g_from_xonly` which calls `G::with_even_y_from_x_be_bytes` + * to reconstruct the parent with even-y parity from the x-only bytes. The + * TS `deriveChildPubkey` preserves whatever parity is in the SEC1 prefix, + * so hard-coding `0x02` here keeps the derivations byte-identical. + * + * The companion `RUST_HASHI_MASTER_SEC1_NATURAL_ODD_Y` (below) exercises + * the **odd-y** path — the actual bug class that `derive_hashi_child_pubkey` + * fixed for production DKG outputs. + */ +const RUST_HASHI_MASTER_SEC1_EVEN_Y = (() => { + const natural = secp256k1.getPublicKey(RUST_HASHI_SK, true); + const evenY = new Uint8Array(33); + evenY[0] = 0x02; + evenY.set(natural.slice(1), 1); + return evenY; +})(); + +/** + * Seed for the odd-y cross-language vector. `[4u8; 32]` is the first scalar + * in `3..=255` whose `s · G` lands on odd y on secp256k1 — verified on the + * Rust side by `cross_lang_2of2_test_vectors_odd_y`. Both sides use the + * **natural** SEC1 form (`0x03` prefix); no parity forcing. + */ +const RUST_HASHI_SK_ODD_Y = new Uint8Array(32).fill(4); +const RUST_HASHI_MASTER_SEC1_NATURAL_ODD_Y = secp256k1.getPublicKey(RUST_HASHI_SK_ODD_Y, true); + +const RUST_PATH_ZERO = new Uint8Array(32); +const RUST_PATH_ONES = new Uint8Array(32).fill(1); +const RUST_PATH_AB_CD = (() => { + const p = new Uint8Array(32); + p[0] = 0xab; + p[31] = 0xcd; + return p; +})(); + +describe('deriveChildPubkey', () => { + it('returns a 32-byte x-only key', () => { + const child = deriveChildPubkey(TEST_COMPRESSED_KEY, ZERO_ADDRESS); + expect(child).toBeInstanceOf(Uint8Array); + expect(child.length).toBe(32); + }); + + it('produces different keys for different Sui addresses', () => { + const addr1 = new Uint8Array(32); + addr1[31] = 1; + const addr2 = new Uint8Array(32); + addr2[31] = 2; + + const child1 = deriveChildPubkey(TEST_COMPRESSED_KEY, addr1); + const child2 = deriveChildPubkey(TEST_COMPRESSED_KEY, addr2); + + expect(child1).not.toEqual(child2); + }); + + it('is deterministic', () => { + const a = deriveChildPubkey(TEST_COMPRESSED_KEY, ZERO_ADDRESS); + const b = deriveChildPubkey(TEST_COMPRESSED_KEY, ZERO_ADDRESS); + expect(a).toEqual(b); + }); + + it('throws for wrong key length', () => { + expect(() => deriveChildPubkey(new Uint8Array(32), ZERO_ADDRESS)).toThrow('33-byte'); + }); + + it('throws for wrong address length', () => { + expect(() => deriveChildPubkey(TEST_COMPRESSED_KEY, new Uint8Array(20))).toThrow('32-byte'); + }); +}); + +describe('arkworksToSec1Compressed', () => { + it('converts a known arkworks key to valid SEC1 compressed format', () => { + // Known devnet MPC key in arkworks format + const ark = fromHex('0x466d7e0035ec8c4b3056d28c9faab29228a89332a12dec1a6a68aaa5669d9e0380'); + const sec1 = arkworksToSec1Compressed(ark); + + expect(sec1.length).toBe(33); + // SEC1 prefix must be 0x02 or 0x03 + expect([0x02, 0x03]).toContain(sec1[0]); + // x-coordinate should be the LE bytes reversed to BE + expect(Buffer.from(sec1.slice(1)).toString('hex')).toBe( + '039e9d66a5aa686a1aec2da13293a82892b2aa9f8cd256304b8cec35007e6d46', + ); + }); + + it('round-trips a SEC1 key through arkworks encoding', () => { + // Build an arkworks-encoded version of TEST_COMPRESSED_KEY (SEC1, secret=2): + // SEC1: prefix(1) + x_be(32). Arkworks: x_le(32) + flags(1). + const xBe = TEST_COMPRESSED_KEY.slice(1); // 32-byte BE x-coordinate + const xLe = new Uint8Array(xBe).reverse(); // LE + + // Determine arkworks flag: y > (p-1)/2 → bit 7 + const Point = secp256k1.Point; + const point = Point.fromBytes(TEST_COMPRESSED_KEY); + const y = point.toAffine().y; + const p = Point.CURVE().p; + const yIsNeg = y > (p - 1n) / 2n; + + const ark = new Uint8Array(33); + ark.set(xLe, 0); + ark[32] = yIsNeg ? 0x80 : 0x00; + + const sec1 = arkworksToSec1Compressed(ark); + + // Must recover the original SEC1 compressed key + expect(sec1).toEqual(TEST_COMPRESSED_KEY); + }); + + it('throws for wrong length', () => { + expect(() => arkworksToSec1Compressed(new Uint8Array(32))).toThrow('33-byte'); + }); +}); + +describe('twoOfTwoTaprootScriptPathAddress', () => { + // Use two distinct x-only inputs derived from the Rust-matching secrets. + // Both are guaranteed-on-curve (they're outputs of `getPublicKey(...)`). + const guardian = RUST_GUARDIAN_X_ONLY; + const childKey = deriveChildPubkey(RUST_HASHI_MASTER_SEC1_EVEN_Y, ZERO_ADDRESS); + + it('returns a bech32m address with correct prefix per network', () => { + expect(twoOfTwoTaprootScriptPathAddress(guardian, childKey, 'mainnet')).toMatch(/^bc1p/); + expect(twoOfTwoTaprootScriptPathAddress(guardian, childKey, 'testnet')).toMatch(/^tb1p/); + expect(twoOfTwoTaprootScriptPathAddress(guardian, childKey, 'signet')).toMatch(/^tb1p/); + expect(twoOfTwoTaprootScriptPathAddress(guardian, childKey, 'regtest')).toMatch(/^bcrt1p/); + }); + + it('is deterministic', () => { + const a = twoOfTwoTaprootScriptPathAddress(guardian, childKey, 'testnet'); + const b = twoOfTwoTaprootScriptPathAddress(guardian, childKey, 'testnet'); + expect(a).toBe(b); + }); + + it('differs when guardian and MPC-child arguments are swapped', () => { + // The Rust descriptor is `tr(NUMS, multi_a(2, enclave, derived_hashi))` — + // miniscript does NOT auto-sort keys. Swapping the two arguments must + // produce a different address, otherwise we've accidentally introduced + // canonical sorting and the SDK would silently diverge from the bridge. + const a = twoOfTwoTaprootScriptPathAddress(guardian, childKey, 'regtest'); + const b = twoOfTwoTaprootScriptPathAddress(childKey, guardian, 'regtest'); + expect(a).not.toBe(b); + }); + + it('rejects non-32-byte inputs', () => { + expect(() => twoOfTwoTaprootScriptPathAddress(new Uint8Array(31), childKey, 'testnet')).toThrow( + '32-byte', + ); + expect(() => twoOfTwoTaprootScriptPathAddress(guardian, new Uint8Array(33), 'testnet')).toThrow( + '32-byte', + ); + }); + + // Cross-language vector — Rust ground truth. Values captured from + // `cargo nextest run -p hashi-types cross_lang_2of2_test_vectors`. + it('matches Rust vector (regtest, path = zero)', () => { + const btcAddress = twoOfTwoTaprootScriptPathAddress( + RUST_GUARDIAN_X_ONLY, + fromHex('0x80583e4abd7e73b0868a44e24dd05379375f1c3a85c4c1329bb0572df8577985'), + 'regtest', + ); + expect(btcAddress).toBe('bcrt1p674xfkudr0myzu3jpschmc4wx9xjllf5wyqt4x8y48jnd099dchs0ww4kp'); + }); +}); + +describe('generateDepositAddress', () => { + it('produces a valid P2TR address end-to-end', () => { + const suiAddr = new Uint8Array(32); + suiAddr[31] = 0x42; + + const btcAddress = generateDepositAddress({ + mpcMasterCompressed: TEST_COMPRESSED_KEY, + guardianBtcXOnly: RUST_GUARDIAN_X_ONLY, + suiAddress: suiAddr, + network: 'regtest', + }); + + expect(btcAddress).toMatch(/^bcrt1p/); + expect(btcAddress.length).toBeGreaterThan(40); + }); + + it('matches manual two-step derivation', () => { + const suiAddr = new Uint8Array(32); + suiAddr[0] = 0xab; + + const composed = generateDepositAddress({ + mpcMasterCompressed: TEST_COMPRESSED_KEY, + guardianBtcXOnly: RUST_GUARDIAN_X_ONLY, + suiAddress: suiAddr, + network: 'testnet', + }); + + const child = deriveChildPubkey(TEST_COMPRESSED_KEY, suiAddr); + const manual = twoOfTwoTaprootScriptPathAddress(RUST_GUARDIAN_X_ONLY, child, 'testnet'); + + expect(composed).toBe(manual); + }); + + /** + * Cross-language test vectors captured byte-for-byte from + * `cargo nextest run -p hashi-types cross_lang_2of2_test_vectors`. Both + * sides MUST produce the same `(derived_mpc, address)` for the same + * `(enclave/guardian_x, hashi_master_x, path)` triple — any drift between + * the SDK and the Rust bridge silently sends user funds to addresses the + * validator rejects. + */ + it.each([ + { + label: 'path = zero', + path: RUST_PATH_ZERO, + expectedDerivedHex: '80583e4abd7e73b0868a44e24dd05379375f1c3a85c4c1329bb0572df8577985', + expectedRegtest: 'bcrt1p674xfkudr0myzu3jpschmc4wx9xjllf5wyqt4x8y48jnd099dchs0ww4kp', + expectedSignet: 'tb1p674xfkudr0myzu3jpschmc4wx9xjllf5wyqt4x8y48jnd099dchszhynrm', + }, + { + label: 'path = [1u8; 32]', + path: RUST_PATH_ONES, + expectedDerivedHex: '1b79f716fb1f7beba697f012edcf7b81a96ceac2920b181bd217c9cc017ac7fb', + expectedRegtest: 'bcrt1plf0jem4745f5yhu4x3q226q4f34jw6nxysyqvyxjxem0gugqrxnsn6mjae', + expectedSignet: 'tb1plf0jem4745f5yhu4x3q226q4f34jw6nxysyqvyxjxem0gugqrxns7r35gr', + }, + { + label: 'path = 0xab..00..cd', + path: RUST_PATH_AB_CD, + expectedDerivedHex: '1403322badfd7823bebf81e9c5ff74f32f856348ac0f5abe33130cc4b6a14c84', + expectedRegtest: 'bcrt1p2zdq5arv2k7cec0jwstrt3twsnvrze66q4eaqujr4aykuzzu7wwq893cha', + expectedSignet: 'tb1p2zdq5arv2k7cec0jwstrt3twsnvrze66q4eaqujr4aykuzzu7wwq2um7z8', + }, + ])( + 'matches Rust cross-language vector: $label', + ({ path, expectedDerivedHex, expectedRegtest, expectedSignet }) => { + // Derived child x-only key matches Rust's derive_hashi_child_pubkey output. + const derived = deriveChildPubkey(RUST_HASHI_MASTER_SEC1_EVEN_Y, path); + expect(Buffer.from(derived).toString('hex')).toBe(expectedDerivedHex); + + // Final 2-of-2 addresses match for both regtest and signet. + for (const [network, expected] of [ + ['regtest', expectedRegtest], + ['signet', expectedSignet], + ] as const) { + const btcAddress = generateDepositAddress({ + mpcMasterCompressed: RUST_HASHI_MASTER_SEC1_EVEN_Y, + guardianBtcXOnly: RUST_GUARDIAN_X_ONLY, + suiAddress: path, + network, + }); + expect(btcAddress).toBe(expected); + } + }, + ); + + /** + * Cross-language **odd-y** vector. Captured byte-for-byte from + * `cargo nextest run -p hashi-types cross_lang_2of2_test_vectors_odd_y`. + * + * The even-y vectors above force `0x02` on both sides, so they only + * exercise the path that worked before PR #609. This test pins the path + * that PR #609 actually fixed — for an odd-y master, the legacy code + * built the descriptor against the even-y projection but the MPC signed + * against raw `G`, so Bitcoin rejected the witness for ~50% of DKG + * outputs. Both sides now use the natural SEC1 prefix (`0x03`) here. + */ + it('matches Rust cross-language vector: odd-y master, path = [1u8; 32]', () => { + // Lock the property under test: secp256k1's 4·G has odd y. If this + // assertion ever fires, the upstream curve impl changed and the + // pinned vectors below must be regenerated. + expect(RUST_HASHI_MASTER_SEC1_NATURAL_ODD_Y[0]).toBe(0x03); + + // Full production path: the bridge stores `bcs::to_bytes(&G)` (arkworks + // LE-x ‖ flag) on-chain; `view.mpcPublicKey()` runs these exact bytes + // through `arkworksToSec1Compressed`. Pin the odd-y master's on-chain + // bytes (captured from `bcs::to_bytes(&(G::generator() * [4u8;32]))`) + // and assert the conversion recovers the natural `0x03` SEC1 form. This + // ties the arkworks→SEC1 step into the cross-language guarantee — the + // arkworks "y > (p-1)/2" flag and the SEC1 parity prefix are different + // conventions, so odd-y is exactly where a naive copy would break. + const onchainArkworksOddY = fromHex( + '0x0b5be51c72b8b5ef30e0e493a5c7e1102f5f08711a7514465139ad4aad79274600', + ); + expect(Buffer.from(arkworksToSec1Compressed(onchainArkworksOddY)).toString('hex')).toBe( + Buffer.from(RUST_HASHI_MASTER_SEC1_NATURAL_ODD_Y).toString('hex'), + ); + + const path = RUST_PATH_ONES; + const expectedDerivedHex = 'd6305db510d6cb87554c942aaaffa3ff277366c2a04b8e64f633cceebd05f937'; + const expectedRegtest = 'bcrt1p09kjf0dz6a4qmdvwqydp902zxz4tr0rp60pe4nl7y4y8vfakf7zsv6mzk8'; + const expectedSignet = 'tb1p09kjf0dz6a4qmdvwqydp902zxz4tr0rp60pe4nl7y4y8vfakf7zspr3yra'; + + const derived = deriveChildPubkey(RUST_HASHI_MASTER_SEC1_NATURAL_ODD_Y, path); + expect(Buffer.from(derived).toString('hex')).toBe(expectedDerivedHex); + + for (const [network, expected] of [ + ['regtest', expectedRegtest], + ['signet', expectedSignet], + ] as const) { + const btcAddress = generateDepositAddress({ + mpcMasterCompressed: RUST_HASHI_MASTER_SEC1_NATURAL_ODD_Y, + guardianBtcXOnly: RUST_GUARDIAN_X_ONLY, + suiAddress: path, + network, + }); + expect(btcAddress).toBe(expected); + } + }); +}); + +describe('bitcoinAddressToWitnessProgram', () => { + // BIP-173 canonical mainnet P2WPKH test vector. + const BIP173_P2WPKH = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + const BIP173_PROGRAM_HEX = '751e76e8199196d454941c45d1b3a323f1433bd6'; + + /** + * Encode (hrp, version, program) into a bech32(m) SegWit address. Used to + * construct edge-case inputs: wrong-variant checksums, unsupported + * versions, mismatched program lengths. `@scure/base` doesn't enforce + * SegWit-specific rules (v0/20|32, version-variant coupling), so we can + * produce technically-invalid combinations and let the decoder reject + * them. + */ + function encode( + hrp: string, + version: number, + program: Uint8Array, + variant: 'bech32' | 'bech32m', + ): string { + const codec = variant === 'bech32' ? bech32 : bech32m; + const words = [version, ...codec.toWords(program)]; + return codec.encode(hrp as 'bc' | 'tb' | 'bcrt', words); + } + + const P2WPKH_PROGRAM = new Uint8Array(20).fill(0x01); + const P2TR_PROGRAM = new Uint8Array(32).fill(0x02); + + it('decodes a BIP-173 canonical P2WPKH mainnet address', () => { + const { version, program } = bitcoinAddressToWitnessProgram(BIP173_P2WPKH, 'mainnet'); + expect(version).toBe(0); + expect(program).toBeInstanceOf(Uint8Array); + expect(program.length).toBe(20); + expect(Buffer.from(program).toString('hex')).toBe(BIP173_PROGRAM_HEX); + }); + + it('decodes a constructed mainnet P2TR (v1, 32-byte, bech32m)', () => { + const addr = encode('bc', 1, P2TR_PROGRAM, 'bech32m'); + const { version, program } = bitcoinAddressToWitnessProgram(addr, 'mainnet'); + expect(version).toBe(1); + expect(program).toEqual(P2TR_PROGRAM); + }); + + it('decodes a signet P2TR (tb1p…)', () => { + const addr = encode('tb', 1, P2TR_PROGRAM, 'bech32m'); + const { version, program } = bitcoinAddressToWitnessProgram(addr, 'signet'); + expect(version).toBe(1); + expect(program).toEqual(P2TR_PROGRAM); + }); + + it('decodes a regtest P2WPKH (bcrt1q…)', () => { + const addr = encode('bcrt', 0, P2WPKH_PROGRAM, 'bech32'); + const { version, program } = bitcoinAddressToWitnessProgram(addr, 'regtest'); + expect(version).toBe(0); + expect(program).toEqual(P2WPKH_PROGRAM); + }); + + it('rejects garbage strings with code `malformed`', () => { + try { + bitcoinAddressToWitnessProgram('not-an-address', 'mainnet'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidBitcoinAddressError); + expect((err as InvalidBitcoinAddressError).code).toBe('malformed'); + expect((err as InvalidBitcoinAddressError).address).toBe('not-an-address'); + } + }); + + it('rejects legacy base58 addresses with code `malformed`', () => { + const legacy = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'; + try { + bitcoinAddressToWitnessProgram(legacy, 'mainnet'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidBitcoinAddressError); + expect((err as InvalidBitcoinAddressError).code).toBe('malformed'); + } + }); + + it('rejects a v0 address encoded with bech32m (bad-checksum per BIP-350)', () => { + const addr = encode('bc', 0, P2WPKH_PROGRAM, 'bech32m'); + try { + bitcoinAddressToWitnessProgram(addr, 'mainnet'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidBitcoinAddressError); + expect((err as InvalidBitcoinAddressError).code).toBe('bad-checksum'); + } + }); + + it('rejects a v1 address encoded with bech32 (bad-checksum per BIP-350)', () => { + const addr = encode('bc', 1, P2TR_PROGRAM, 'bech32'); + try { + bitcoinAddressToWitnessProgram(addr, 'mainnet'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidBitcoinAddressError); + expect((err as InvalidBitcoinAddressError).code).toBe('bad-checksum'); + } + }); + + it('rejects a mainnet address on signet with code `wrong-network`', () => { + try { + bitcoinAddressToWitnessProgram(BIP173_P2WPKH, 'signet'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidBitcoinAddressError); + expect((err as InvalidBitcoinAddressError).code).toBe('wrong-network'); + } + }); + + it('rejects witness version 2 with code `unsupported-version`', () => { + const addr = encode('bc', 2, P2TR_PROGRAM, 'bech32m'); + try { + bitcoinAddressToWitnessProgram(addr, 'mainnet'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidBitcoinAddressError); + expect((err as InvalidBitcoinAddressError).code).toBe('unsupported-version'); + } + }); + + it('rejects v0 with a 32-byte program (P2WSH — not supported)', () => { + const addr = encode('bc', 0, P2TR_PROGRAM, 'bech32'); + try { + bitcoinAddressToWitnessProgram(addr, 'mainnet'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidBitcoinAddressError); + expect((err as InvalidBitcoinAddressError).code).toBe('bad-program-length'); + } + }); + + it('rejects v1 with a 20-byte program', () => { + const addr = encode('bc', 1, P2WPKH_PROGRAM, 'bech32m'); + try { + bitcoinAddressToWitnessProgram(addr, 'mainnet'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidBitcoinAddressError); + expect((err as InvalidBitcoinAddressError).code).toBe('bad-program-length'); + } + }); + + it('echoes the original input in `.address`', () => { + try { + bitcoinAddressToWitnessProgram('foo', 'mainnet'); + expect.fail('expected to throw'); + } catch (err) { + expect((err as InvalidBitcoinAddressError).address).toBe('foo'); + } + }); +}); + +describe('witnessProgramToAddress', () => { + it('round-trips a P2WPKH address (v0, 20 bytes)', () => { + const program = new Uint8Array(20).fill(0xaa); + const addr = witnessProgramToAddress(program, 'regtest'); + expect(addr).toMatch(/^bcrt1q/); + + const decoded = bitcoinAddressToWitnessProgram(addr, 'regtest'); + expect(decoded.version).toBe(0); + expect(new Uint8Array(decoded.program)).toEqual(program); + }); + + it('round-trips a P2TR address (v1, 32 bytes)', () => { + const program = new Uint8Array(32).fill(0xbb); + const addr = witnessProgramToAddress(program, 'regtest'); + expect(addr).toMatch(/^bcrt1p/); + + const decoded = bitcoinAddressToWitnessProgram(addr, 'regtest'); + expect(decoded.version).toBe(1); + expect(new Uint8Array(decoded.program)).toEqual(program); + }); + + it('produces correct HRP for each network', () => { + const program20 = new Uint8Array(20).fill(0x01); + expect(witnessProgramToAddress(program20, 'mainnet')).toMatch(/^bc1q/); + expect(witnessProgramToAddress(program20, 'testnet')).toMatch(/^tb1q/); + expect(witnessProgramToAddress(program20, 'signet')).toMatch(/^tb1q/); + expect(witnessProgramToAddress(program20, 'regtest')).toMatch(/^bcrt1q/); + }); + + it('throws for unsupported program lengths', () => { + expect(() => witnessProgramToAddress(new Uint8Array(16), 'mainnet')).toThrow( + 'Unsupported witness program length', + ); + }); + + it('is the inverse of bitcoinAddressToWitnessProgram', () => { + // Encode a known mainnet P2WPKH + const knownAddr = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + const { program } = bitcoinAddressToWitnessProgram(knownAddr, 'mainnet'); + const reencoded = witnessProgramToAddress(new Uint8Array(program), 'mainnet'); + expect(reencoded).toBe(knownAddr); + }); +}); diff --git a/packages/hashi/test/unit/btc-rpc.test.ts b/packages/hashi/test/unit/btc-rpc.test.ts new file mode 100644 index 000000000..7f8bc58e4 --- /dev/null +++ b/packages/hashi/test/unit/btc-rpc.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { lookupVout, lookupAllVouts, getTxConfirmations } from '../../src/btc-rpc.js'; + +const BTC_RPC_URL = 'http://localhost:18443'; + +function mockFetch(result: unknown, error?: { message: string }) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + json: async () => ({ result, error }), + } as Response); +} + +const TX_WITH_OUTPUTS = { + vout: [ + { n: 0, value: 0.001, scriptPubKey: { address: 'bcrt1paddr0' } }, + { n: 1, value: 0.05, scriptPubKey: { address: 'bcrt1ptarget' } }, + { n: 2, value: 0.002, scriptPubKey: { address: 'bcrt1ptarget' } }, + { n: 3, value: 0.003, scriptPubKey: { address: 'bcrt1pother' } }, + ], +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('lookupVout', () => { + it('returns the first matching output', async () => { + mockFetch(TX_WITH_OUTPUTS); + + const result = await lookupVout(BTC_RPC_URL, 'abc123', 'bcrt1ptarget'); + expect(result).toEqual({ vout: 1, amountSats: 5_000_000n }); + }); + + it('returns null when no output matches', async () => { + mockFetch(TX_WITH_OUTPUTS); + + const result = await lookupVout(BTC_RPC_URL, 'abc123', 'bcrt1pnomatch'); + expect(result).toBeNull(); + }); + + it('throws on RPC error', async () => { + mockFetch(undefined, { message: 'Transaction not found' }); + + await expect(lookupVout(BTC_RPC_URL, 'abc123', 'addr')).rejects.toThrow( + 'Transaction not found', + ); + }); + + it('sends correct JSON-RPC payload', async () => { + const spy = mockFetch(TX_WITH_OUTPUTS); + + await lookupVout(BTC_RPC_URL, 'txid123', 'addr'); + + expect(spy).toHaveBeenCalledWith(BTC_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '1.0', + id: 'hashi-lookup-vout', + method: 'getrawtransaction', + params: ['txid123', true], + }), + }); + }); +}); + +describe('lookupAllVouts', () => { + it('returns all matching outputs', async () => { + mockFetch(TX_WITH_OUTPUTS); + + const results = await lookupAllVouts(BTC_RPC_URL, 'abc123', 'bcrt1ptarget'); + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ vout: 1, amountSats: 5_000_000n }); + expect(results[1]).toEqual({ vout: 2, amountSats: 200_000n }); + }); + + it('returns empty array when no output matches', async () => { + mockFetch(TX_WITH_OUTPUTS); + + const results = await lookupAllVouts(BTC_RPC_URL, 'abc123', 'bcrt1pnomatch'); + expect(results).toEqual([]); + }); + + it('throws on RPC error', async () => { + mockFetch(undefined, { message: 'bad txid' }); + + await expect(lookupAllVouts(BTC_RPC_URL, 'abc123', 'addr')).rejects.toThrow('bad txid'); + }); +}); + +describe('getTxConfirmations', () => { + it('returns confirmation count', async () => { + mockFetch({ confirmations: 6 }); + + const count = await getTxConfirmations(BTC_RPC_URL, 'abc123'); + expect(count).toBe(6); + }); + + it('returns 0 when confirmations field is absent (mempool tx)', async () => { + mockFetch({}); + + const count = await getTxConfirmations(BTC_RPC_URL, 'abc123'); + expect(count).toBe(0); + }); + + it('throws on RPC error', async () => { + mockFetch(undefined, { message: 'No such mempool transaction' }); + + await expect(getTxConfirmations(BTC_RPC_URL, 'abc123')).rejects.toThrow( + 'No such mempool transaction', + ); + }); +}); diff --git a/packages/hashi/test/unit/client.test.ts b/packages/hashi/test/unit/client.test.ts new file mode 100644 index 000000000..1a71a1a1e --- /dev/null +++ b/packages/hashi/test/unit/client.test.ts @@ -0,0 +1,2316 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HashiClient, hashi } from '../../src/client.js'; +import { + AmountBelowMinimumError, + HashiConfigError, + HashiGuardianError, + HashiPausedError, + InvalidBitcoinAddressError, + InvalidParamsError, +} from '../../src/errors.js'; +import type { GuardianInfoProvider, RawGuardianInfo } from '../../src/types.js'; +import { Hashi } from '../../src/contracts/hashi/hashi.js'; +import { BitcoinState, BitcoinStateKey } from '../../src/contracts/hashi/bitcoin_state.js'; +import { DepositRequest } from '../../src/contracts/hashi/deposit_queue.js'; +import { + WithdrawalRequest, + WithdrawalTransaction, +} from '../../src/contracts/hashi/withdrawal_queue.js'; +import { Bag } from '../../src/contracts/hashi/deps/sui/bag.js'; +import { generateDepositAddress } from '../../src/bitcoin.js'; +import { reverseTxidBytes } from '../../src/util.js'; +import { SuiGrpcClient } from '@mysten/sui/grpc'; +import { bcs } from '@mysten/sui/bcs'; +import { Transaction } from '@mysten/sui/transactions'; +import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { bech32, bech32m } from '@scure/base'; +import { fromHex, normalizeSuiAddress } from '@mysten/sui/utils'; + +const HASHI_OBJECT_ID = '0x0000000000000000000000000000000000000000000000000000000000000001'; +const PACKAGE_ID = '0x0000000000000000000000000000000000000000000000000000000000000002'; +const REQUEST_ID = '0x0000000000000000000000000000000000000000000000000000000000000003'; + +/** Deterministic test key: secret = 2 (matches TEST_HASHI_BTC_SK in Rust tests). */ +const TEST_SECRET = new Uint8Array(32); +TEST_SECRET[31] = 2; +const TEST_MPC_KEY = secp256k1.getPublicKey(TEST_SECRET, true); // 33 bytes, SEC1 compressed + +/** + * Arkworks-encoded form of TEST_MPC_KEY, matching the on-chain storage format. + * Arkworks: bytes[0..32] = x in LE, byte[32] = flag (bit 7 = y > (p-1)/2). + */ +function sec1ToArkworks(sec1: Uint8Array): Uint8Array { + const xBe = sec1.slice(1); + const xLe = new Uint8Array(xBe).reverse(); + const Point = secp256k1.Point; + const point = Point.fromBytes(sec1); + const y = point.toAffine().y; + const p = Point.CURVE().p; + const yIsNeg = y > (p - 1n) / 2n; + const ark = new Uint8Array(33); + ark.set(xLe, 0); + ark[32] = yIsNeg ? 0x80 : 0x00; + return ark; +} +const TEST_MPC_KEY_ARKWORKS = sec1ToArkworks(TEST_MPC_KEY); + +const TEST_SUI_ADDRESS = '0xabcdef0000000000000000000000000000000000000000000000000000000001'; + +/** + * 32-byte x-only guardian BTC pubkey for tests. Matches the + * `TEST_ENCLAVE_BTC_SK = [1u8; 32]` constant in + * `crates/hashi-types/src/guardian/bitcoin_utils.rs`, so SDK tests stay + * cross-language-consistent with the bridge. + */ +const TEST_GUARDIAN_BTC_X_ONLY = secp256k1.getPublicKey(new Uint8Array(32).fill(1), true).slice(1); + +/** 32-byte Ed25519 attestation pubkey placeholder for tests. */ +const TEST_GUARDIAN_ED25519_KEY = new Uint8Array(32).fill(7); + +/** Convenience: the `Bytes` config entry for the guardian BTC pubkey. */ +function guardianBtcConfigEntry(bytes: Uint8Array = TEST_GUARDIAN_BTC_X_ONLY) { + return { + key: 'guardian_btc_public_key', + value: { $kind: 'Bytes', Bytes: Array.from(bytes) }, + }; +} + +/** + * Build a mocked `Hashi.get()` response with a custom config `contents` array. + * Other fields carry minimal-but-valid placeholders so the BCS-decoded json + * shape matches what the SDK expects. + */ +function mockHashiWithConfig( + contents: Array<{ key: string; value: { $kind: string; [k: string]: unknown } }>, +) { + vi.spyOn(Hashi, 'get').mockResolvedValueOnce({ + json: { + id: HASHI_OBJECT_ID, + committee_set: { + members: HASHI_OBJECT_ID, + epoch: 0n, + committees: HASHI_OBJECT_ID, + pending_epoch_change: null, + mpc_public_key: [], + }, + config: { + config: { contents }, + enabled_versions: { contents: [] }, + upgrade_cap: null, + }, + treasury: { objects: HASHI_OBJECT_ID }, + proposals: HASHI_OBJECT_ID, + tob: HASHI_OBJECT_ID, + num_consumed_presigs: 0n, + }, + } as never); +} + +const WELL_FORMED_CONFIG = [ + { key: 'paused', value: { $kind: 'Bool', Bool: false } }, + { key: 'bitcoin_chain_id', value: { $kind: 'Address', Address: `0x${'a'.repeat(64)}` } }, + { key: 'bitcoin_deposit_minimum', value: { $kind: 'U64', U64: '30000' } }, + { key: 'bitcoin_withdrawal_minimum', value: { $kind: 'U64', U64: '30000' } }, + { key: 'bitcoin_confirmation_threshold', value: { $kind: 'U64', U64: '6' } }, + { key: 'withdrawal_cancellation_cooldown_ms', value: { $kind: 'U64', U64: '3600000' } }, + { key: 'bitcoin_deposit_time_delay_ms', value: { $kind: 'U64', U64: '600000' } }, +]; + +describe('HashiClient', () => { + let client: SuiGrpcClient & { hashi: HashiClient }; + + beforeEach(() => { + vi.clearAllMocks(); + client = new SuiGrpcClient({ + network: 'devnet', + baseUrl: 'https://fullnode.devnet.sui.io:443', + }).$extend( + hashi({ + network: 'devnet', + hashiObjectId: HASHI_OBJECT_ID, + packageId: PACKAGE_ID, + bitcoinNetwork: 'regtest', + }), + ); + }); + + describe('generateDepositAddress', () => { + /** + * Build a `Hashi.get()` mock carrying both the MPC arkworks key and a + * configurable governance config. `generateDepositAddress` reads the + * committee key and the guardian key from a single `Hashi.get`; the + * mock is persistent so any incidental extra read still resolves. + */ + function mockHashiWithMpcAndConfig( + mpcArkworks: Uint8Array | number[], + configContents: ReadonlyArray<{ + key: string; + value: { $kind: string; [k: string]: unknown }; + }>, + ) { + vi.spyOn(Hashi, 'get').mockResolvedValue({ + json: { + id: HASHI_OBJECT_ID, + committee_set: { + members: HASHI_OBJECT_ID, + epoch: 0n, + committees: HASHI_OBJECT_ID, + pending_epoch_change: null, + mpc_public_key: Array.from(mpcArkworks), + }, + config: { + config: { contents: configContents }, + enabled_versions: { contents: [] }, + upgrade_cap: null, + }, + treasury: { objects: HASHI_OBJECT_ID }, + proposals: HASHI_OBJECT_ID, + tob: HASHI_OBJECT_ID, + num_consumed_presigs: 0n, + }, + } as never); + } + + it('generates a deposit address by fetching MPC + guardian keys from on-chain', async () => { + mockHashiWithMpcAndConfig(TEST_MPC_KEY_ARKWORKS, [ + ...WELL_FORMED_CONFIG, + guardianBtcConfigEntry(), + ]); + + const btcAddress = await client.hashi.generateDepositAddress({ + suiAddress: TEST_SUI_ADDRESS, + }); + + // Matches the pure-function output for the same inputs. + const expected = generateDepositAddress({ + mpcMasterCompressed: TEST_MPC_KEY, + guardianBtcXOnly: TEST_GUARDIAN_BTC_X_ONLY, + suiAddress: fromHex(TEST_SUI_ADDRESS), + network: 'regtest', + }); + expect(btcAddress).toBe(expected); + expect(btcAddress).toMatch(/^bcrt1p/); + }); + + it('throws when MPC key is not yet available', async () => { + mockHashiWithMpcAndConfig([], [...WELL_FORMED_CONFIG, guardianBtcConfigEntry()]); + + const err = await client.hashi + .generateDepositAddress({ suiAddress: TEST_SUI_ADDRESS }) + .catch((e) => e); + expect(err).toBeInstanceOf(HashiConfigError); + expect((err as HashiConfigError).key).toBe('committee_set.mpc_public_key'); + }); + + it('throws HashiConfigError when guardian_btc_public_key is not on-chain', async () => { + // MPC key present, guardian key absent (pre-feature deployment). + mockHashiWithMpcAndConfig(TEST_MPC_KEY_ARKWORKS, WELL_FORMED_CONFIG); + + const err = await client.hashi + .generateDepositAddress({ suiAddress: TEST_SUI_ADDRESS }) + .catch((e) => e); + expect(err).toBeInstanceOf(HashiConfigError); + expect((err as HashiConfigError).key).toBe('guardian_btc_public_key'); + }); + + it('throws HashiConfigError when guardian_btc_public_key has wrong length', async () => { + mockHashiWithMpcAndConfig(TEST_MPC_KEY_ARKWORKS, [ + ...WELL_FORMED_CONFIG, + { + key: 'guardian_btc_public_key', + value: { $kind: 'Bytes', Bytes: Array.from(new Uint8Array(20)) }, + }, + ]); + + // generateDepositAddress reads guardian_btc_public_key directly + // via `configBytes`, which length-checks the entry. + const err = await client.hashi + .generateDepositAddress({ suiAddress: TEST_SUI_ADDRESS }) + .catch((e) => e); + expect(err).toBeInstanceOf(HashiConfigError); + expect((err as HashiConfigError).key).toBe('guardian_btc_public_key'); + expect(err.message).toMatch(/expected 32 bytes, got 20/); + }); + + it('reads the committee and guardian keys from a single Hashi.get', async () => { + const getSpy = vi.spyOn(Hashi, 'get'); + mockHashiWithMpcAndConfig(TEST_MPC_KEY_ARKWORKS, [ + ...WELL_FORMED_CONFIG, + guardianBtcConfigEntry(), + ]); + + await client.hashi.generateDepositAddress({ suiAddress: TEST_SUI_ADDRESS }); + + expect(getSpy).toHaveBeenCalledTimes(1); + }); + + it('ignores an unrelated malformed config entry (guardian_public_key)', async () => { + // The Ed25519 attestation key is never touched by address + // derivation, so a malformed one must not block it — only the MPC + // key and guardian_btc_public_key are parsed. + mockHashiWithMpcAndConfig(TEST_MPC_KEY_ARKWORKS, [ + ...WELL_FORMED_CONFIG, + guardianBtcConfigEntry(), + { + key: 'guardian_public_key', + value: { $kind: 'Bytes', Bytes: Array.from(new Uint8Array(20)) }, // wrong length + }, + ]); + + const btcAddress = await client.hashi.generateDepositAddress({ + suiAddress: TEST_SUI_ADDRESS, + }); + expect(btcAddress).toMatch(/^bcrt1p/); + }); + + it('normalizes a short-form Sui address before deriving', async () => { + mockHashiWithMpcAndConfig(TEST_MPC_KEY_ARKWORKS, [ + ...WELL_FORMED_CONFIG, + guardianBtcConfigEntry(), + ]); + + const fromShort = await client.hashi.generateDepositAddress({ suiAddress: '0x42' }); + const expected = generateDepositAddress({ + mpcMasterCompressed: TEST_MPC_KEY, + guardianBtcXOnly: TEST_GUARDIAN_BTC_X_ONLY, + suiAddress: fromHex(normalizeSuiAddress('0x42')), + network: 'regtest', + }); + expect(fromShort).toBe(expected); + }); + + it.todo('derives a BTC deposit address from a live devnet MPC + guardian key', async () => { + const devnetClient = new SuiGrpcClient({ + network: 'devnet', + baseUrl: 'https://fullnode.devnet.sui.io:443', + }).$extend(hashi({ network: 'devnet' })); + + const suiAddress = '0xe40c8cf8b53822829b3a6dc9aea84b62653f60b771e9da4bd4e214cae851b87b'; + + const btcAddress = await devnetClient.hashi.generateDepositAddress({ suiAddress }); + + // signet/testnet addresses start with tb1p + expect(btcAddress).toMatch(/^tb1p/); + expect(btcAddress.length).toBeGreaterThan(40); + // Replace with the new 2-of-2 reference address once devnet is + // redeployed with the guardian BTC pubkey published. + }); + + it('allows overriding the network per call', async () => { + mockHashiWithMpcAndConfig(TEST_MPC_KEY_ARKWORKS, [ + ...WELL_FORMED_CONFIG, + guardianBtcConfigEntry(), + ]); + + // Client default is regtest, but we override to testnet + const addr = await client.hashi.generateDepositAddress({ + suiAddress: TEST_SUI_ADDRESS, + bitcoinNetwork: 'testnet', + }); + expect(addr).toMatch(/^tb1p/); + }); + }); + + describe('deposit', () => { + const validTxid = '0x' + 'ef'.repeat(32); + const testSigner = Ed25519Keypair.generate(); + + // Stub the network call so happy-path tests don't hit a real node. + // Spy on `client.core` (the raw CoreClient instance) rather than + // `client` itself — `$extend` wraps the client in a Proxy that caches + // bound method references, so a spy installed on the Proxy gets + // shadowed by the cache on subsequent reads. The underlying target is + // reachable via `client.core` since `CoreClient` sets `core = this`, + // and `HashiClient` stores that same target internally. + let signExecSpy: ReturnType; + beforeEach(() => { + signExecSpy = vi.spyOn(client.core, 'signAndExecuteTransaction').mockResolvedValue({ + $kind: 'Transaction', + Transaction: { status: { success: true } }, + } as never); + }); + + it('throws HashiPausedError when the protocol is paused', async () => { + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG.filter((e) => e.key !== 'paused'), + { key: 'paused', value: { $kind: 'Bool', Bool: true } }, + ]); + + const promise = client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [{ vout: 0, amountSats: 100_000n }], + recipient: TEST_SUI_ADDRESS, + }); + + await expect(promise).rejects.toBeInstanceOf(HashiPausedError); + await expect(promise).rejects.toMatchObject({ operation: 'deposit' }); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('prefers HashiPausedError over AmountBelowMinimumError when both would apply', async () => { + // Pause check must run before the minimum check — if these two + // fired in the wrong order a user depositing dust into a paused + // system would see the wrong recovery signal. + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG.filter((e) => e.key !== 'paused'), + { key: 'paused', value: { $kind: 'Bool', Bool: true } }, + ]); + + await expect( + client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [{ vout: 0, amountSats: 1n }], // well below 30 000 + recipient: TEST_SUI_ADDRESS, + }), + ).rejects.toBeInstanceOf(HashiPausedError); + }); + + it('throws AmountBelowMinimumError carrying every violation for an under-minimum batch', async () => { + // WELL_FORMED_CONFIG sets bitcoin_deposit_minimum = 30_000 sats. + mockHashiWithConfig(WELL_FORMED_CONFIG); + + const promise = client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [ + { vout: 1, amountSats: 10_000n }, + { vout: 3, amountSats: 50_000n }, // passes + { vout: 7, amountSats: 20_000n }, + ], + recipient: TEST_SUI_ADDRESS, + }); + + await expect(promise).rejects.toBeInstanceOf(AmountBelowMinimumError); + await expect(promise).rejects.toMatchObject({ + violations: [ + { amount: 10_000n, minimum: 30_000n, vout: 1 }, + { amount: 20_000n, minimum: 30_000n, vout: 7 }, + ], + }); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('accepts a UTXO at exactly the minimum (boundary = pass)', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + await client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [{ vout: 0, amountSats: 30_000n }], + recipient: TEST_SUI_ADDRESS, + }); + expect(signExecSpy).toHaveBeenCalledTimes(1); + }); + + it('rejects a UTXO one sat below the minimum (boundary = fail)', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + await expect( + client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [{ vout: 0, amountSats: 29_999n }], + recipient: TEST_SUI_ADDRESS, + }), + ).rejects.toBeInstanceOf(AmountBelowMinimumError); + }); + + it('forwards the built PTB and the provided signer to signAndExecuteTransaction', async () => { + // PTB shape is exhaustively covered by `tx.deposit` tests; here we + // just verify the surface method hands off correctly. + mockHashiWithConfig(WELL_FORMED_CONFIG); + + await client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [ + { vout: 0, amountSats: 100_000n }, + { vout: 1, amountSats: 50_000n }, + ], + recipient: TEST_SUI_ADDRESS, + }); + + expect(signExecSpy).toHaveBeenCalledTimes(1); + const call = signExecSpy.mock.calls[0][0] as { + signer: unknown; + transaction: Transaction; + }; + expect(call.signer).toBe(testSigner); + expect(call.transaction).toBeInstanceOf(Transaction); + expect(call.transaction.getData().commands).toHaveLength(6); + }); + + it('fetches the governance snapshot exactly once per deposit call', async () => { + const getSpy = vi.spyOn(Hashi, 'get'); + mockHashiWithConfig(WELL_FORMED_CONFIG); + + await client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [{ vout: 0, amountSats: 100_000n }], + recipient: TEST_SUI_ADDRESS, + }); + + expect(getSpy).toHaveBeenCalledTimes(1); + }); + + describe('structural validation (no chain read)', () => { + it('rejects a malformed txid before reading chain state', async () => { + const getSpy = vi.spyOn(Hashi, 'get'); + await expect( + client.hashi.deposit({ + signer: testSigner, + txid: '0xabc', // too short + utxos: [{ vout: 0, amountSats: 100_000n }], + recipient: TEST_SUI_ADDRESS, + }), + ).rejects.toBeInstanceOf(InvalidParamsError); + expect(getSpy).not.toHaveBeenCalled(); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('rejects a malformed recipient', async () => { + await expect( + client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [{ vout: 0, amountSats: 100_000n }], + recipient: 'not-a-sui-address', + }), + ).rejects.toBeInstanceOf(InvalidParamsError); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('rejects an empty utxos array', async () => { + await expect( + client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [], + recipient: TEST_SUI_ADDRESS, + }), + ).rejects.toMatchObject({ + name: 'InvalidParamsError', + reason: expect.stringContaining('at least one UTXO'), + }); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('rejects duplicate vouts within a single deposit', async () => { + await expect( + client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [ + { vout: 0, amountSats: 100_000n }, + { vout: 0, amountSats: 50_000n }, + ], + recipient: TEST_SUI_ADDRESS, + }), + ).rejects.toMatchObject({ + name: 'InvalidParamsError', + reason: expect.stringContaining('duplicate `vout`'), + }); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('rejects a non-integer vout', async () => { + await expect( + client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [{ vout: 1.5, amountSats: 100_000n }], + recipient: TEST_SUI_ADDRESS, + }), + ).rejects.toBeInstanceOf(InvalidParamsError); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('rejects a negative vout', async () => { + await expect( + client.hashi.deposit({ + signer: testSigner, + txid: validTxid, + utxos: [{ vout: -1, amountSats: 100_000n }], + recipient: TEST_SUI_ADDRESS, + }), + ).rejects.toBeInstanceOf(InvalidParamsError); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('requestWithdrawal', () => { + const testSigner = Ed25519Keypair.generate(); + + // Test client is configured for `bitcoinNetwork: "regtest"` (see outer + // beforeEach), so valid deposit addresses must use the `bcrt` HRP. + const VALID_REGTEST_P2WPKH = bech32.encode('bcrt' as const, [ + 0, + ...bech32.toWords(new Uint8Array(20).fill(0xaa)), + ]); + const VALID_REGTEST_P2TR = bech32m.encode('bcrt' as const, [ + 1, + ...bech32m.toWords(new Uint8Array(32).fill(0xbb)), + ]); + + let signExecSpy: ReturnType; + beforeEach(() => { + signExecSpy = vi.spyOn(client.core, 'signAndExecuteTransaction').mockResolvedValue({ + $kind: 'Transaction', + Transaction: { status: { success: true } }, + } as never); + }); + + it('rejects a malformed address before reading chain state', async () => { + const getSpy = vi.spyOn(Hashi, 'get'); + await expect( + client.hashi.requestWithdrawal({ + signer: testSigner, + amountSats: 100_000n, + bitcoinAddress: 'not-an-address', + }), + ).rejects.toBeInstanceOf(InvalidBitcoinAddressError); + expect(getSpy).not.toHaveBeenCalled(); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('rejects a wrong-network address with code `wrong-network`', async () => { + // Mainnet P2WPKH passed to a regtest-configured client. + await expect( + client.hashi.requestWithdrawal({ + signer: testSigner, + amountSats: 100_000n, + bitcoinAddress: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + }), + ).rejects.toMatchObject({ + name: 'InvalidBitcoinAddressError', + code: 'wrong-network', + }); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('throws HashiPausedError when the protocol is paused', async () => { + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG.filter((e) => e.key !== 'paused'), + { key: 'paused', value: { $kind: 'Bool', Bool: true } }, + ]); + const promise = client.hashi.requestWithdrawal({ + signer: testSigner, + amountSats: 100_000n, + bitcoinAddress: VALID_REGTEST_P2TR, + }); + await expect(promise).rejects.toBeInstanceOf(HashiPausedError); + await expect(promise).rejects.toMatchObject({ operation: 'withdraw' }); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('throws AmountBelowMinimumError with a single vout-less violation', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + + let caught: AmountBelowMinimumError | undefined; + try { + await client.hashi.requestWithdrawal({ + signer: testSigner, + amountSats: 29_999n, // one below 30_000 (WELL_FORMED_CONFIG) + bitcoinAddress: VALID_REGTEST_P2TR, + }); + expect.fail('expected to throw'); + } catch (err) { + caught = err as AmountBelowMinimumError; + } + + expect(caught).toBeInstanceOf(AmountBelowMinimumError); + expect(caught!.violations).toHaveLength(1); + expect(caught!.violations[0]).toEqual({ + amount: 29_999n, + minimum: 30_000n, + }); + // Withdrawal violation carries no `vout` — the optional field is + // absent rather than set to anything falsy-but-present. + expect(caught!.violations[0].vout).toBeUndefined(); + // And the rendered message reflects that (no "UTXO at vout" prefix). + expect(caught!.message).toMatch(/^Amount 29999 sats/); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('accepts an amount exactly at the minimum (boundary = pass)', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + await client.hashi.requestWithdrawal({ + signer: testSigner, + amountSats: 30_000n, + bitcoinAddress: VALID_REGTEST_P2TR, + }); + expect(signExecSpy).toHaveBeenCalledTimes(1); + }); + + it('forwards the built PTB and the provided signer to signAndExecuteTransaction', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + await client.hashi.requestWithdrawal({ + signer: testSigner, + amountSats: 100_000n, + bitcoinAddress: VALID_REGTEST_P2WPKH, + }); + + expect(signExecSpy).toHaveBeenCalledTimes(1); + const call = signExecSpy.mock.calls[0][0] as { + signer: unknown; + transaction: Transaction; + }; + expect(call.signer).toBe(testSigner); + expect(call.transaction).toBeInstanceOf(Transaction); + }); + + it('fetches the governance snapshot exactly once per call', async () => { + const getSpy = vi.spyOn(Hashi, 'get'); + mockHashiWithConfig(WELL_FORMED_CONFIG); + + await client.hashi.requestWithdrawal({ + signer: testSigner, + amountSats: 100_000n, + bitcoinAddress: VALID_REGTEST_P2TR, + }); + + expect(getSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('cancelWithdrawal', () => { + const testSigner = Ed25519Keypair.generate(); + + let signExecSpy: ReturnType; + beforeEach(() => { + signExecSpy = vi.spyOn(client.core, 'signAndExecuteTransaction').mockResolvedValue({ + $kind: 'Transaction', + Transaction: { status: { success: true } }, + } as never); + }); + + it('rejects a malformed requestId before any chain or tx work', async () => { + const getSpy = vi.spyOn(Hashi, 'get'); + await expect( + client.hashi.cancelWithdrawal({ + signer: testSigner, + requestId: '0xabc', // too short + }), + ).rejects.toBeInstanceOf(InvalidParamsError); + expect(getSpy).not.toHaveBeenCalled(); + expect(signExecSpy).not.toHaveBeenCalled(); + }); + + it('happy path: forwards signer + a 3-command PTB to signAndExecuteTransaction', async () => { + await client.hashi.cancelWithdrawal({ + signer: testSigner, + requestId: REQUEST_ID, + }); + + expect(signExecSpy).toHaveBeenCalledTimes(1); + const call = signExecSpy.mock.calls[0][0] as { + signer: unknown; + transaction: Transaction; + }; + expect(call.signer).toBe(testSigner); + expect(call.transaction).toBeInstanceOf(Transaction); + // `tx.cancelWithdrawal` composes cancel_withdrawal + from_balance + + // transferObjects — 3 commands total. + expect(call.transaction.getData().commands).toHaveLength(3); + }); + + it('does not pause-check (Move permits cancellation while paused)', async () => { + // Move's `cancel_withdrawal` has no `assert_unpaused` call, so the + // SDK mirrors that by skipping the governance fetch entirely — + // users must be able to unwind a pending request even if the + // system is paused. + const getSpy = vi.spyOn(Hashi, 'get'); + await client.hashi.cancelWithdrawal({ + signer: testSigner, + requestId: REQUEST_ID, + }); + expect(getSpy).not.toHaveBeenCalled(); + expect(signExecSpy).toHaveBeenCalledTimes(1); + }); + + it("passes the signer's Sui address as recipient to tx.cancelWithdrawal", async () => { + const txSpy = vi.spyOn(client.hashi.tx, 'cancelWithdrawal'); + await client.hashi.cancelWithdrawal({ + signer: testSigner, + requestId: REQUEST_ID, + }); + expect(txSpy).toHaveBeenCalledWith({ + requestId: REQUEST_ID, + recipient: testSigner.toSuiAddress(), + }); + }); + }); + + describe('requestSignetFaucet', () => { + it.todo('requests BTC from the signet faucet'); + }); + + describe('network support', () => { + it('resolves testnet from NETWORK_CONFIG without custom ids', () => { + expect(() => + new SuiGrpcClient({ + network: 'testnet', + baseUrl: 'https://fullnode.testnet.sui.io:443', + }).$extend(hashi({ network: 'testnet' })), + ).not.toThrow(); + }); + + it('throws for mainnet without a custom hashiObjectId', () => { + expect(() => + new SuiGrpcClient({ + network: 'mainnet', + baseUrl: 'https://fullnode.mainnet.sui.io:443', + }).$extend(hashi({ network: 'mainnet' })), + ).toThrow('not yet supported on Sui mainnet'); + }); + + it('allows unsupported networks with a custom hashiObjectId and packageId', () => { + expect(() => + new SuiGrpcClient({ + network: 'mainnet', + baseUrl: 'https://fullnode.mainnet.sui.io:443', + }).$extend( + hashi({ + network: 'mainnet', + hashiObjectId: HASHI_OBJECT_ID, + packageId: PACKAGE_ID, + }), + ), + ).not.toThrow(); + }); + }); + + describe('view', () => { + describe('mpcPublicKey', () => { + it('returns the 33-byte compressed MPC key', async () => { + vi.spyOn(Hashi, 'get').mockResolvedValueOnce({ + json: { + id: HASHI_OBJECT_ID, + committee_set: { + members: HASHI_OBJECT_ID, + epoch: 0n, + committees: HASHI_OBJECT_ID, + pending_epoch_change: null, + mpc_public_key: Array.from(TEST_MPC_KEY_ARKWORKS), + }, + config: { + config: { contents: [] }, + enabled_versions: { contents: [] }, + upgrade_cap: null, + }, + treasury: { objects: HASHI_OBJECT_ID }, + proposals: HASHI_OBJECT_ID, + tob: HASHI_OBJECT_ID, + num_consumed_presigs: 0n, + }, + } as never); + + const key = await client.hashi.view.mpcPublicKey(); + expect(key).toBeInstanceOf(Uint8Array); + expect(key.length).toBe(33); + expect(key[0]).toBeOneOf([0x02, 0x03]); // valid compressed prefix + expect(key).toEqual(TEST_MPC_KEY); + }); + + it('throws when DKG has not completed', async () => { + vi.spyOn(Hashi, 'get').mockResolvedValueOnce({ + json: { + id: HASHI_OBJECT_ID, + committee_set: { + members: HASHI_OBJECT_ID, + epoch: 0n, + committees: HASHI_OBJECT_ID, + pending_epoch_change: null, + mpc_public_key: [], + }, + config: { + config: { contents: [] }, + enabled_versions: { contents: [] }, + upgrade_cap: null, + }, + treasury: { objects: HASHI_OBJECT_ID }, + proposals: HASHI_OBJECT_ID, + tob: HASHI_OBJECT_ID, + num_consumed_presigs: 0n, + }, + } as never); + + const err = await client.hashi.view.mpcPublicKey().catch((e) => e); + expect(err).toBeInstanceOf(HashiConfigError); + expect((err as HashiConfigError).key).toBe('committee_set.mpc_public_key'); + }); + }); + + describe('all / governance getters', () => { + it('all() returns a full typed snapshot from one Hashi.get call', async () => { + const getSpy = vi.spyOn(Hashi, 'get'); + mockHashiWithConfig(WELL_FORMED_CONFIG); + + const snap = await client.hashi.view.all(); + + expect(getSpy).toHaveBeenCalledTimes(1); + expect(snap).toEqual({ + paused: false, + bitcoinChainId: `0x${'a'.repeat(64)}`, + bitcoinDepositMinimum: 30_000n, + bitcoinWithdrawalMinimum: 30_000n, + bitcoinConfirmationThreshold: 6n, + withdrawalCancellationCooldownMs: 3_600_000n, + bitcoinDepositTimeDelayMs: 600_000n, + depositMinimum: 30_000n, + worstCaseNetworkFee: 30_000n - 546n, + // WELL_FORMED_CONFIG has no guardian entries — pre-feature shape. + guardianUrl: null, + guardianPublicKey: null, + guardianBtcPublicKey: null, + }); + }); + + it('all() surfaces guardian config keys when set on-chain', async () => { + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG, + { key: 'guardian_url', value: { $kind: 'String', String: 'https://g.example' } }, + { + key: 'guardian_public_key', + value: { $kind: 'Bytes', Bytes: Array.from(TEST_GUARDIAN_ED25519_KEY) }, + }, + guardianBtcConfigEntry(), + ]); + + const snap = await client.hashi.view.all(); + expect(snap.guardianUrl).toBe('https://g.example'); + expect(snap.guardianPublicKey).toEqual(TEST_GUARDIAN_ED25519_KEY); + expect(snap.guardianBtcPublicKey).toEqual(TEST_GUARDIAN_BTC_X_ONLY); + }); + + it('all() throws when guardian_public_key has wrong length', async () => { + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG, + { + key: 'guardian_public_key', + value: { $kind: 'Bytes', Bytes: Array.from(new Uint8Array(33)) }, + }, + ]); + + const err = await client.hashi.view.all().catch((e) => e); + expect(err).toBeInstanceOf(HashiConfigError); + expect((err as HashiConfigError).key).toBe('guardian_public_key'); + expect(err.message).toMatch(/expected 32 bytes, got 33/); + }); + + it('floors bitcoin_deposit_minimum to DUST_RELAY_MIN_VALUE (546)', async () => { + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG.filter((e) => e.key !== 'bitcoin_deposit_minimum'), + { key: 'bitcoin_deposit_minimum', value: { $kind: 'U64', U64: '100' } }, + ]); + + const snap = await client.hashi.view.all(); + + expect(snap.bitcoinDepositMinimum).toBe(546n); + expect(snap.depositMinimum).toBe(546n); + }); + + it('floors bitcoin_withdrawal_minimum to DUST_RELAY_MIN_VALUE + 1 (547)', async () => { + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG.filter((e) => e.key !== 'bitcoin_withdrawal_minimum'), + { key: 'bitcoin_withdrawal_minimum', value: { $kind: 'U64', U64: '200' } }, + ]); + + const snap = await client.hashi.view.all(); + + expect(snap.bitcoinWithdrawalMinimum).toBe(547n); + expect(snap.worstCaseNetworkFee).toBe(1n); // 547 - 546 + }); + + it('throws HashiConfigError naming the missing key', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG.filter((e) => e.key !== 'paused')); + + await expect(client.hashi.view.all()).rejects.toMatchObject({ + name: 'HashiConfigError', + key: 'paused', + expectedVariant: 'Bool', + message: expect.stringContaining('"paused" not found'), + }); + }); + + it('throws HashiConfigError when variant is wrong', async () => { + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG.filter((e) => e.key !== 'paused'), + { key: 'paused', value: { $kind: 'U64', U64: '1' } }, + ]); + + await expect(client.hashi.view.all()).rejects.toMatchObject({ + name: 'HashiConfigError', + key: 'paused', + expectedVariant: 'Bool', + actualVariant: 'U64', + }); + }); + + it('each individual view method fetches via all() (one Hashi.get per call)', async () => { + const getSpy = vi.spyOn(Hashi, 'get'); + mockHashiWithConfig(WELL_FORMED_CONFIG); + + expect(await client.hashi.view.paused()).toBe(false); + expect(getSpy).toHaveBeenCalledTimes(1); + }); + + it('HashiConfigError is instanceof Error and carries structured fields', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG.filter((e) => e.key !== 'paused')); + + try { + await client.hashi.view.all(); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(HashiConfigError); + } + }); + }); + }); + + describe('view.findUsedUtxos', () => { + const ACTIVE_POOL_ID = '0x' + 'a1'.repeat(32); + const SPENT_POOL_ID = '0x' + 'a2'.repeat(32); + const TABLE_ID = '0x' + 'a3'.repeat(32); + + /** JSON-RPC `ObjectError` not-found shape: `.code === "notExists"`. */ + const jsonRpcNotFoundError = () => Object.assign(new Error('not found'), { code: 'notExists' }); + /** gRPC not-found shape: plain `Error` with `Object not found` message. */ + const grpcNotFoundError = (id = '0x' + '00'.repeat(32)) => new Error(`Object ${id} not found`); + const notFoundError = jsonRpcNotFoundError; + + /** BCS-encoded BitcoinState with known Bag/Table IDs. */ + function mockBitcoinStateContent() { + const bagFields = (id: string) => ({ id, size: '0' }); + const objectBagFields = (id: string) => ({ id, size: '0' }); + return BitcoinState.serialize({ + id: '0x' + 'b0'.repeat(32), + deposit_queue: { + requests: objectBagFields('0x' + 'd1'.repeat(32)), + processed: objectBagFields('0x' + 'd2'.repeat(32)), + }, + withdrawal_queue: { + requests: objectBagFields('0x' + 'c1'.repeat(32)), + processed: objectBagFields('0x' + 'c2'.repeat(32)), + withdrawal_txns: objectBagFields('0x' + 'c3'.repeat(32)), + confirmed_txns: objectBagFields('0x' + 'c4'.repeat(32)), + }, + utxo_pool: { + utxo_records: bagFields(ACTIVE_POOL_ID), + spent_utxos: bagFields(SPENT_POOL_ID), + }, + user_requests: bagFields(TABLE_ID), + }).toBytes(); + } + + function mockFetchBitcoinState() { + vi.spyOn(client.core, 'getDynamicField').mockResolvedValueOnce({ + dynamicField: { + $kind: 'DynamicField', + fieldId: '0x' + 'aa'.repeat(32), + type: `0x2::dynamic_field::Field<${PACKAGE_ID}::bitcoin_state::BitcoinStateKey, ${PACKAGE_ID}::bitcoin_state::BitcoinState>`, + name: { + type: `${PACKAGE_ID}::bitcoin_state::BitcoinStateKey`, + bcs: BitcoinStateKey.serialize({ dummy_field: false }).toBytes(), + }, + valueType: `${PACKAGE_ID}::bitcoin_state::BitcoinState`, + value: { + type: `${PACKAGE_ID}::bitcoin_state::BitcoinState`, + bcs: mockBitcoinStateContent(), + }, + version: '1', + digest: 'mock', + previousTransaction: null, + }, + } as never); + } + + it('returns empty array for empty input', async () => { + const result = await client.hashi.view.findUsedUtxos([]); + expect(result).toEqual([]); + }); + + it('marks a UTXO as used when it exists in the active pool', async () => { + mockFetchBitcoinState(); + const getObjectsSpy = vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [ + // active pool — found + { objectId: '0x01', type: 'Field', version: '1', digest: 'd', owner: {} }, + // spent pool — not found + notFoundError(), + ], + } as never); + + const result = await client.hashi.view.findUsedUtxos([ + { txid: '0x' + 'ab'.repeat(32), vout: 0 }, + ]); + + expect(result).toHaveLength(1); + expect(result[0].inActivePool).toBe(true); + expect(result[0].inSpentPool).toBe(false); + expect(result[0].isUsed).toBe(true); + expect(result[0].utxoId).toEqual({ txid: '0x' + 'ab'.repeat(32), vout: 0 }); + expect(getObjectsSpy).toHaveBeenCalledTimes(1); + }); + + it('marks a UTXO as used when it exists in the spent pool', async () => { + mockFetchBitcoinState(); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [ + notFoundError(), // active pool + { objectId: '0x01', type: 'Field', version: '1', digest: 'd', owner: {} }, // spent pool + ], + } as never); + + const result = await client.hashi.view.findUsedUtxos([ + { txid: '0x' + 'cd'.repeat(32), vout: 1 }, + ]); + + expect(result[0].inActivePool).toBe(false); + expect(result[0].inSpentPool).toBe(true); + expect(result[0].isUsed).toBe(true); + }); + + it('marks a UTXO as not used when absent from both pools', async () => { + mockFetchBitcoinState(); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [notFoundError(), notFoundError()], + } as never); + + const result = await client.hashi.view.findUsedUtxos([ + { txid: '0x' + 'ff'.repeat(32), vout: 99 }, + ]); + + expect(result[0].inActivePool).toBe(false); + expect(result[0].inSpentPool).toBe(false); + expect(result[0].isUsed).toBe(false); + }); + + it('handles multiple UTXOs in a single batch', async () => { + mockFetchBitcoinState(); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [ + // UTXO 0: active=found, spent=not found + { objectId: '0x01', type: 'F', version: '1', digest: 'd', owner: {} }, + notFoundError(), + // UTXO 1: active=not found, spent=not found + notFoundError(), + notFoundError(), + // UTXO 2: active=found, spent=found (both) + { objectId: '0x02', type: 'F', version: '1', digest: 'd', owner: {} }, + { objectId: '0x03', type: 'F', version: '1', digest: 'd', owner: {} }, + ], + } as never); + + const result = await client.hashi.view.findUsedUtxos([ + { txid: '0x' + '01'.repeat(32), vout: 0 }, + { txid: '0x' + '02'.repeat(32), vout: 1 }, + { txid: '0x' + '03'.repeat(32), vout: 2 }, + ]); + + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ isUsed: true, inActivePool: true, inSpentPool: false }); + expect(result[1]).toMatchObject({ + isUsed: false, + inActivePool: false, + inSpentPool: false, + }); + expect(result[2]).toMatchObject({ isUsed: true, inActivePool: true, inSpentPool: true }); + }); + + it("rethrows an RPC error that isn't a 'not found' code", async () => { + mockFetchBitcoinState(); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [ + notFoundError(), + Object.assign(new Error('internal server error'), { code: 'unknown' }), + ], + } as never); + + await expect( + client.hashi.view.findUsedUtxos([{ txid: '0x' + 'ab'.repeat(32), vout: 0 }]), + ).rejects.toThrow('internal server error'); + }); + + it("treats gRPC 'Object not found' plain Errors as misses", async () => { + mockFetchBitcoinState(); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [grpcNotFoundError(), grpcNotFoundError()], + } as never); + + const result = await client.hashi.view.findUsedUtxos([ + { txid: '0x' + 'ab'.repeat(32), vout: 0 }, + ]); + expect(result[0]).toMatchObject({ + inActivePool: false, + inSpentPool: false, + isUsed: false, + }); + }); + + it("rethrows a plain Error whose message isn't the gRPC not-found pattern", async () => { + mockFetchBitcoinState(); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [new Error('internal server error'), notFoundError()], + } as never); + + await expect( + client.hashi.view.findUsedUtxos([{ txid: '0x' + 'ab'.repeat(32), vout: 0 }]), + ).rejects.toThrow('internal server error'); + }); + + it('throws HashiFetchError when getObjects returns a wrong-length response', async () => { + mockFetchBitcoinState(); + // Expecting 2 results (one UTXO × 2 pools), mock only returns 1. + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [notFoundError()], + } as never); + + await expect( + client.hashi.view.findUsedUtxos([{ txid: '0x' + 'ab'.repeat(32), vout: 0 }]), + ).rejects.toThrow(/expected 2/); + }); + }); + + describe('view.transactionHistory', () => { + const ACTIVE_POOL_ID = '0x' + 'a1'.repeat(32); + const SPENT_POOL_ID = '0x' + 'a2'.repeat(32); + const TABLE_ID = '0x' + 'a3'.repeat(32); + const USER_BAG_ID = '0x' + 'b1'.repeat(32); + + const DEPOSIT_REQUEST_ID = '0x' + 'd0'.repeat(32); + const WITHDRAWAL_REQUEST_ID = '0x' + 'e0'.repeat(32); + const WITHDRAWAL_TXN_ID = '0x' + 'f0'.repeat(32); + + // A display-order txid (plain hex, no 0x) and its internal (reversed, 0x-prefixed) form. + const DISPLAY_TXID = 'ab'.repeat(32); + const INTERNAL_TXID = `0x${reverseTxidBytes('0x' + DISPLAY_TXID)}`; + + function mockFetchBitcoinState() { + const bagFields = (id: string) => ({ id, size: '0' }); + const objectBagFields = (id: string) => ({ id, size: '0' }); + vi.spyOn(client.core, 'getDynamicField').mockResolvedValueOnce({ + dynamicField: { + $kind: 'DynamicField', + fieldId: '0x' + 'aa'.repeat(32), + type: `0x2::dynamic_field::Field<${PACKAGE_ID}::bitcoin_state::BitcoinStateKey, ${PACKAGE_ID}::bitcoin_state::BitcoinState>`, + name: { + type: `${PACKAGE_ID}::bitcoin_state::BitcoinStateKey`, + bcs: BitcoinStateKey.serialize({ dummy_field: false }).toBytes(), + }, + valueType: `${PACKAGE_ID}::bitcoin_state::BitcoinState`, + value: { + type: `${PACKAGE_ID}::bitcoin_state::BitcoinState`, + bcs: BitcoinState.serialize({ + id: '0x' + 'b0'.repeat(32), + deposit_queue: { + requests: objectBagFields('0x' + 'd1'.repeat(32)), + processed: objectBagFields('0x' + 'd2'.repeat(32)), + }, + withdrawal_queue: { + requests: objectBagFields('0x' + 'c1'.repeat(32)), + processed: objectBagFields('0x' + 'c2'.repeat(32)), + withdrawal_txns: objectBagFields('0x' + 'c3'.repeat(32)), + confirmed_txns: objectBagFields('0x' + 'c4'.repeat(32)), + }, + utxo_pool: { + utxo_records: bagFields(ACTIVE_POOL_ID), + spent_utxos: bagFields(SPENT_POOL_ID), + }, + user_requests: bagFields(TABLE_ID), + }).toBytes(), + }, + version: '1', + digest: 'mock', + previousTransaction: null, + }, + } as never); + } + + function mockUserBagLookup() { + vi.spyOn(client.core, 'getDynamicField').mockResolvedValueOnce({ + dynamicField: { + $kind: 'DynamicField', + fieldId: '0x' + 'cc'.repeat(32), + type: 'Field', + name: { type: 'address', bcs: new Uint8Array(32) }, + valueType: '0x2::bag::Bag', + value: { + type: '0x2::bag::Bag', + bcs: Bag.serialize({ id: USER_BAG_ID, size: '2' }).toBytes(), + }, + version: '1', + digest: 'mock', + previousTransaction: null, + }, + } as never); + } + + function mockListDynamicFields(requestIds: string[]) { + vi.spyOn(client.core, 'listDynamicFields').mockResolvedValueOnce({ + hasNextPage: false, + cursor: null, + dynamicFields: requestIds.map((id) => ({ + $kind: 'DynamicField' as const, + fieldId: '0x' + 'ff'.repeat(32), + type: 'Field', + name: { + type: 'address', + bcs: bcs.Address.serialize(id).toBytes(), + }, + valueType: 'bool', + })), + } as never); + } + + function mockGraphQLDepositEvents(depositIds: string[]) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + events: { + nodes: depositIds.map((id) => ({ + contents: { json: { request_id: id } }, + })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }), + ), + ); + } + + function mockDepositRequestObject() { + return { + objectId: DEPOSIT_REQUEST_ID, + version: '1', + digest: 'mock', + owner: { $kind: 'ObjectOwner', ObjectOwner: '0x00' }, + type: `${PACKAGE_ID}::deposit_queue::DepositRequest`, + content: DepositRequest.serialize({ + id: DEPOSIT_REQUEST_ID, + sender: TEST_SUI_ADDRESS, + created_timestamp_ms: '1000', + sui_tx_digest: Array.from(new Uint8Array(32).fill(0xdd)), + utxo: { + id: { txid: INTERNAL_TXID, vout: 7 }, + amount: '50000', + derivation_path: TEST_SUI_ADDRESS, + }, + approval_cert: null, + approved_timestamp_ms: null, + confirmed_timestamp_ms: null, + }).toBytes(), + previousTransaction: undefined, + objectBcs: undefined, + json: undefined, + display: undefined, + }; + } + + function mockWithdrawalRequestObject(opts?: { withdrawalTxnId?: string | null }) { + return { + objectId: WITHDRAWAL_REQUEST_ID, + version: '1', + digest: 'mock', + owner: { $kind: 'ObjectOwner', ObjectOwner: '0x00' }, + type: `${PACKAGE_ID}::withdrawal_queue::WithdrawalRequest`, + content: WithdrawalRequest.serialize({ + id: WITHDRAWAL_REQUEST_ID, + sender: TEST_SUI_ADDRESS, + btc_amount: '30000', + bitcoin_address: Array.from(new Uint8Array(32).fill(0xcc)), + created_timestamp_ms: '2000', + status: { Requested: true }, + approval_cert: null, + approved_timestamp_ms: null, + withdrawal_txn_id: opts?.withdrawalTxnId ?? null, + sui_tx_digest: Array.from(new Uint8Array(32).fill(0xee)), + btc: { value: '30000' }, + }).toBytes(), + previousTransaction: undefined, + objectBcs: undefined, + json: undefined, + display: undefined, + }; + } + + it('returns empty array when user has no confirmed or pending requests', async () => { + mockFetchBitcoinState(); + vi.spyOn(client.core, 'getDynamicField').mockRejectedValueOnce(new Error('not found')); + mockGraphQLDepositEvents([]); + + const items = await client.hashi.view.transactionHistory(TEST_SUI_ADDRESS); + expect(items).toEqual([]); + }); + + it('maps a DepositRequest with btcTxid in display order and btcVout', async () => { + mockFetchBitcoinState(); + mockUserBagLookup(); + mockListDynamicFields([DEPOSIT_REQUEST_ID]); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [mockDepositRequestObject()], + } as never); + mockGraphQLDepositEvents([]); + + const items = await client.hashi.view.transactionHistory(TEST_SUI_ADDRESS); + + expect(items).toHaveLength(1); + const dep = items[0]; + expect(dep.kind).toBe('deposit'); + if (dep.kind !== 'deposit') throw new Error('expected deposit'); + + expect(dep.requestId).toBe(DEPOSIT_REQUEST_ID); + expect(dep.sender).toBe(TEST_SUI_ADDRESS); + expect(dep.btcTxid).toBe(DISPLAY_TXID); + expect(dep.btcVout).toBe(7); + expect(dep.amountSats).toBe(50_000n); + expect(dep.approved).toBe(false); + expect(dep.approvalTimestampMs).toBeNull(); + expect(dep.confirmableAtMs).toBeNull(); + expect(dep.timestampMs).toBe(1_000n); + }); + + it('maps a WithdrawalRequest with status and null btcTxid', async () => { + mockFetchBitcoinState(); + mockUserBagLookup(); + mockListDynamicFields([WITHDRAWAL_REQUEST_ID]); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [mockWithdrawalRequestObject()], + } as never); + mockGraphQLDepositEvents([]); + + const items = await client.hashi.view.transactionHistory(TEST_SUI_ADDRESS); + + expect(items).toHaveLength(1); + const wd = items[0]; + expect(wd.kind).toBe('withdrawal'); + if (wd.kind !== 'withdrawal') throw new Error('expected withdrawal'); + + expect(wd.requestId).toBe(WITHDRAWAL_REQUEST_ID); + expect(wd.sender).toBe(TEST_SUI_ADDRESS); + expect(wd.status).toBe('Requested'); + expect(wd.btcAmountSats).toBe(30_000n); + expect(wd.btcTxid).toBeNull(); + expect(wd.withdrawalTxnId).toBeNull(); + }); + + it('fetches WithdrawalTransaction to populate btcTxid when withdrawal_txn_id is set', async () => { + mockFetchBitcoinState(); + mockUserBagLookup(); + mockListDynamicFields([WITHDRAWAL_REQUEST_ID]); + + const BTC_TXID_INTERNAL = '0x' + '99'.repeat(32); + const BTC_TXID_DISPLAY = reverseTxidBytes(BTC_TXID_INTERNAL); + + const getObjectsSpy = vi.spyOn(client.core, 'getObjects'); + + // First call: fetch request objects + getObjectsSpy.mockResolvedValueOnce({ + objects: [mockWithdrawalRequestObject({ withdrawalTxnId: WITHDRAWAL_TXN_ID })], + } as never); + + // Second call: fetch WithdrawalTransaction objects + getObjectsSpy.mockResolvedValueOnce({ + objects: [ + { + objectId: WITHDRAWAL_TXN_ID, + version: '1', + digest: 'mock', + owner: { $kind: 'ObjectOwner', ObjectOwner: '0x00' }, + type: `${PACKAGE_ID}::withdrawal_queue::WithdrawalTransaction`, + content: WithdrawalTransaction.serialize({ + id: WITHDRAWAL_TXN_ID, + txid: BTC_TXID_INTERNAL, + request_ids: [WITHDRAWAL_REQUEST_ID], + inputs: [], + withdrawal_outputs: [], + change_outputs: [], + created_timestamp_ms: '3000', + signed_timestamp_ms: null, + confirmed_timestamp_ms: null, + randomness: [], + signing: { signatures: [], epoch: '1' }, + guardian_signatures: null, + }).toBytes(), + previousTransaction: undefined, + objectBcs: undefined, + json: undefined, + display: undefined, + }, + ], + } as never); + + mockGraphQLDepositEvents([]); + + const items = await client.hashi.view.transactionHistory(TEST_SUI_ADDRESS); + + expect(items).toHaveLength(1); + const wd = items[0]; + if (wd.kind !== 'withdrawal') throw new Error('expected withdrawal'); + expect(wd.btcTxid).toBe(BTC_TXID_DISPLAY); + expect(wd.withdrawalTxnId).toBe(WITHDRAWAL_TXN_ID); + expect(getObjectsSpy).toHaveBeenCalledTimes(2); + }); + + it('returns mixed deposit and withdrawal items sorted by timestamp descending', async () => { + mockFetchBitcoinState(); + mockUserBagLookup(); + mockListDynamicFields([DEPOSIT_REQUEST_ID, WITHDRAWAL_REQUEST_ID]); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [mockDepositRequestObject(), mockWithdrawalRequestObject()], + } as never); + mockGraphQLDepositEvents([]); + + const items = await client.hashi.view.transactionHistory(TEST_SUI_ADDRESS); + + expect(items).toHaveLength(2); + expect(items[0].kind).toBe('withdrawal'); + expect(items[1].kind).toBe('deposit'); + }); + + it('skips Error objects in the batch response', async () => { + mockFetchBitcoinState(); + mockUserBagLookup(); + mockListDynamicFields([DEPOSIT_REQUEST_ID, '0x' + '00'.repeat(32)]); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [mockDepositRequestObject(), new Error('deleted')], + } as never); + mockGraphQLDepositEvents([]); + + const items = await client.hashi.view.transactionHistory(TEST_SUI_ADDRESS); + expect(items).toHaveLength(1); + expect(items[0].kind).toBe('deposit'); + }); + + it('ignores objects whose type matches the module name but a different package', async () => { + mockFetchBitcoinState(); + mockUserBagLookup(); + mockListDynamicFields([DEPOSIT_REQUEST_ID]); + const FOREIGN_PKG = '0x' + 'ee'.repeat(32); + vi.spyOn(client.core, 'getObjects').mockResolvedValueOnce({ + objects: [ + { + ...mockDepositRequestObject(), + type: `${FOREIGN_PKG}::deposit_queue::DepositRequest`, + }, + ], + } as never); + mockGraphQLDepositEvents([]); + + const items = await client.hashi.view.transactionHistory(TEST_SUI_ADDRESS); + expect(items).toEqual([]); + }); + }); + + describe('view.balance', () => { + it('returns total balance and coin object count', async () => { + vi.spyOn(client.core, 'getBalance').mockResolvedValueOnce({ + balance: { + balance: '150000', + coinType: `${PACKAGE_ID}::btc::BTC`, + coinBalance: '150000', + addressBalance: '150000', + }, + } as never); + vi.spyOn(client.core, 'listCoins').mockResolvedValueOnce({ + objects: [{}, {}], + cursor: null, + hasNextPage: false, + } as never); + + const result = await client.hashi.view.balance(TEST_SUI_ADDRESS); + expect(result.totalBalance).toBe(150_000n); + expect(result.coinObjectCount).toBe(2); + }); + + it('returns zero balance when no coins exist', async () => { + vi.spyOn(client.core, 'getBalance').mockResolvedValueOnce({ + balance: { + balance: '0', + coinType: `${PACKAGE_ID}::btc::BTC`, + coinBalance: '0', + addressBalance: '0', + }, + } as never); + vi.spyOn(client.core, 'listCoins').mockResolvedValueOnce({ + objects: [], + cursor: null, + hasNextPage: false, + } as never); + + const result = await client.hashi.view.balance(TEST_SUI_ADDRESS); + expect(result.totalBalance).toBe(0n); + expect(result.coinObjectCount).toBe(0); + }); + + it('paginates through multiple pages of coins', async () => { + vi.spyOn(client.core, 'getBalance').mockResolvedValueOnce({ + balance: { + balance: '300000', + coinType: `${PACKAGE_ID}::btc::BTC`, + coinBalance: '300000', + addressBalance: '300000', + }, + } as never); + const listCoinsSpy = vi.spyOn(client.core, 'listCoins'); + listCoinsSpy.mockResolvedValueOnce({ + objects: [{}, {}], + cursor: 'page1', + hasNextPage: true, + } as never); + listCoinsSpy.mockResolvedValueOnce({ + objects: [{}], + cursor: null, + hasNextPage: false, + } as never); + + const result = await client.hashi.view.balance(TEST_SUI_ADDRESS); + expect(result.coinObjectCount).toBe(3); + expect(listCoinsSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('view.depositStatus', () => { + it('returns null when no deposit event is found in the transaction', async () => { + vi.spyOn(client.core, 'getTransaction').mockResolvedValueOnce({ + Transaction: { events: [] }, + } as never); + + const result = await client.hashi.view.depositStatus('test-digest'); + expect(result).toBeNull(); + }); + + it('returns pending status when request exists in the requests bag', async () => { + vi.spyOn(client.core, 'getTransaction').mockResolvedValueOnce({ + Transaction: { + events: [ + { + eventType: `${PACKAGE_ID}::deposit::DepositRequested`, + json: { + request_id: REQUEST_ID, + utxo_id: { txid: '0x' + 'ab'.repeat(32), vout: 0 }, + amount: '50000', + derivation_path: TEST_SUI_ADDRESS, + timestamp_ms: '1000', + }, + }, + ], + }, + } as never); + + vi.spyOn(DepositRequest, 'get').mockResolvedValueOnce({ + json: { approved_timestamp_ms: null }, + } as never); + + const getDfSpy = vi.spyOn(client.core, 'getDynamicField'); + // First call: fetchBitcoinState + getDfSpy.mockResolvedValueOnce({ + dynamicField: { + value: { + bcs: BitcoinState.serialize({ + id: '0x' + 'b0'.repeat(32), + deposit_queue: { + requests: { id: '0x' + 'd1'.repeat(32), size: '0' }, + processed: { id: '0x' + 'd2'.repeat(32), size: '0' }, + }, + withdrawal_queue: { + requests: { id: '0x' + 'c1'.repeat(32), size: '0' }, + processed: { id: '0x' + 'c2'.repeat(32), size: '0' }, + withdrawal_txns: { id: '0x' + 'c3'.repeat(32), size: '0' }, + confirmed_txns: { id: '0x' + 'c4'.repeat(32), size: '0' }, + }, + utxo_pool: { + utxo_records: { id: '0x' + 'a1'.repeat(32), size: '0' }, + spent_utxos: { id: '0x' + 'a2'.repeat(32), size: '0' }, + }, + user_requests: { id: '0x' + 'a3'.repeat(32), size: '0' }, + }).toBytes(), + }, + }, + } as never); + // Second call: check requests bag — found means pending + getDfSpy.mockResolvedValueOnce({ + dynamicField: { objectId: REQUEST_ID }, + } as never); + + const result = await client.hashi.view.depositStatus('test-digest'); + expect(result).not.toBeNull(); + expect(result!.status).toBe('pending'); + expect(result!.amountSats).toBe(50_000n); + expect(result!.btcVout).toBe(0); + expect(result!.approvalTimestampMs).toBeNull(); + expect(result!.confirmableAtMs).toBeNull(); + }); + + it('returns confirmed status when request exists but is not in the requests bag', async () => { + vi.spyOn(client.core, 'getTransaction').mockResolvedValueOnce({ + Transaction: { + events: [ + { + eventType: `${PACKAGE_ID}::deposit::DepositRequested`, + json: { + request_id: REQUEST_ID, + utxo_id: { txid: '0x' + 'ab'.repeat(32), vout: 0 }, + amount: '50000', + derivation_path: TEST_SUI_ADDRESS, + timestamp_ms: '1000', + }, + }, + ], + }, + } as never); + + vi.spyOn(DepositRequest, 'get').mockResolvedValueOnce({ + json: { approved_timestamp_ms: '5000' }, + } as never); + + mockHashiWithConfig(WELL_FORMED_CONFIG); + + const getDfSpy = vi.spyOn(client.core, 'getDynamicField'); + // fetchBitcoinState + getDfSpy.mockResolvedValueOnce({ + dynamicField: { + value: { + bcs: BitcoinState.serialize({ + id: '0x' + 'b0'.repeat(32), + deposit_queue: { + requests: { id: '0x' + 'd1'.repeat(32), size: '0' }, + processed: { id: '0x' + 'd2'.repeat(32), size: '0' }, + }, + withdrawal_queue: { + requests: { id: '0x' + 'c1'.repeat(32), size: '0' }, + processed: { id: '0x' + 'c2'.repeat(32), size: '0' }, + withdrawal_txns: { id: '0x' + 'c3'.repeat(32), size: '0' }, + confirmed_txns: { id: '0x' + 'c4'.repeat(32), size: '0' }, + }, + utxo_pool: { + utxo_records: { id: '0x' + 'a1'.repeat(32), size: '0' }, + spent_utxos: { id: '0x' + 'a2'.repeat(32), size: '0' }, + }, + user_requests: { id: '0x' + 'a3'.repeat(32), size: '0' }, + }).toBytes(), + }, + }, + } as never); + // requests bag lookup — not found means confirmed + getDfSpy.mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'dynamicFieldNotFound' }), + ); + + const result = await client.hashi.view.depositStatus('test-digest'); + expect(result).not.toBeNull(); + expect(result!.status).toBe('confirmed'); + expect(result!.approvalTimestampMs).toBe(5_000n); + expect(result!.confirmableAtMs).toBe(5_000n + 600_000n); + }); + + it('returns expired status when request object is not found', async () => { + vi.spyOn(client.core, 'getTransaction').mockResolvedValueOnce({ + Transaction: { + events: [ + { + eventType: `${PACKAGE_ID}::deposit::DepositRequested`, + json: { + request_id: REQUEST_ID, + utxo_id: { txid: '0x' + 'ab'.repeat(32), vout: 0 }, + amount: '50000', + derivation_path: TEST_SUI_ADDRESS, + timestamp_ms: '1000', + }, + }, + ], + }, + } as never); + + vi.spyOn(DepositRequest, 'get').mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'notExists' }), + ); + + const result = await client.hashi.view.depositStatus('test-digest'); + expect(result).not.toBeNull(); + expect(result!.status).toBe('expired'); + }); + }); + + describe('view.withdrawalStatus', () => { + it('returns null when no withdrawal event is found', async () => { + vi.spyOn(client.core, 'getTransaction').mockResolvedValueOnce({ + Transaction: { events: [] }, + } as never); + + const result = await client.hashi.view.withdrawalStatus('test-digest'); + expect(result).toBeNull(); + }); + + it('returns cancelled status when request object is not found', async () => { + vi.spyOn(client.core, 'getTransaction').mockResolvedValueOnce({ + Transaction: { + events: [ + { + eventType: `${PACKAGE_ID}::withdrawal_queue::WithdrawalRequested`, + json: { + request_id: REQUEST_ID, + btc_amount: '30000', + bitcoin_address: Array.from(new Uint8Array(32).fill(0xcc)), + timestamp_ms: '2000', + requester_address: TEST_SUI_ADDRESS, + }, + }, + ], + }, + } as never); + + vi.spyOn(WithdrawalRequest, 'get').mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'notExists' }), + ); + + const result = await client.hashi.view.withdrawalStatus('test-digest'); + expect(result).not.toBeNull(); + expect(result!.status).toBe('cancelled'); + expect(result!.btcTxid).toBeNull(); + }); + + it('returns Requested status with null btcTxid when no withdrawal txn linked', async () => { + vi.spyOn(client.core, 'getTransaction').mockResolvedValueOnce({ + Transaction: { + events: [ + { + eventType: `${PACKAGE_ID}::withdrawal_queue::WithdrawalRequested`, + json: { + request_id: REQUEST_ID, + btc_amount: '30000', + bitcoin_address: Array.from(new Uint8Array(20).fill(0xaa)), + timestamp_ms: '2000', + requester_address: TEST_SUI_ADDRESS, + }, + }, + ], + }, + } as never); + + vi.spyOn(WithdrawalRequest, 'get').mockResolvedValueOnce({ + json: { + status: { $kind: 'Requested' }, + withdrawal_txn_id: null, + }, + } as never); + + const result = await client.hashi.view.withdrawalStatus('test-digest'); + expect(result).not.toBeNull(); + expect(result!.status).toBe('Requested'); + expect(result!.btcTxid).toBeNull(); + expect(result!.btcAmountSats).toBe(30_000n); + }); + + it('returns current status with btcTxid when withdrawal transaction exists', async () => { + const BTC_TXID_INTERNAL = '0x' + '99'.repeat(32); + const BTC_TXID_DISPLAY = reverseTxidBytes(BTC_TXID_INTERNAL); + + vi.spyOn(client.core, 'getTransaction').mockResolvedValueOnce({ + Transaction: { + events: [ + { + eventType: `${PACKAGE_ID}::withdrawal_queue::WithdrawalRequested`, + json: { + request_id: REQUEST_ID, + btc_amount: '30000', + bitcoin_address: Array.from(new Uint8Array(32).fill(0xcc)), + timestamp_ms: '2000', + requester_address: TEST_SUI_ADDRESS, + }, + }, + ], + }, + } as never); + + vi.spyOn(WithdrawalRequest, 'get').mockResolvedValueOnce({ + json: { + status: { $kind: 'Signed' }, + withdrawal_txn_id: '0x' + 'f0'.repeat(32), + }, + } as never); + + vi.spyOn(WithdrawalTransaction, 'get').mockResolvedValueOnce({ + json: { txid: BTC_TXID_INTERNAL }, + } as never); + + const result = await client.hashi.view.withdrawalStatus('test-digest'); + expect(result).not.toBeNull(); + expect(result!.status).toBe('Signed'); + expect(result!.btcTxid).toBe(BTC_TXID_DISPLAY); + }); + }); + + describe('view.depositGasEstimate', () => { + it('returns gas estimate from simulation', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + vi.spyOn(client.core, 'simulateTransaction').mockResolvedValueOnce({ + Transaction: { + effects: { + gasUsed: { + computationCost: '1000', + storageCost: '500', + storageRebate: '200', + }, + }, + }, + } as never); + + const result = await client.hashi.view.depositGasEstimate(TEST_SUI_ADDRESS); + // (1000 + 500 - 200) * 120 / 100 = 1560 + expect(result.gasEstimateMist).toBe(1560n); + }); + + it('returns 0n when simulation fails', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + vi.spyOn(client.core, 'simulateTransaction').mockRejectedValueOnce( + new Error('simulation failed'), + ); + + const result = await client.hashi.view.depositGasEstimate(TEST_SUI_ADDRESS); + expect(result.gasEstimateMist).toBe(0n); + }); + }); + + describe('view.withdrawalFees', () => { + it('returns fees from governance config without gas when no sender', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + + const result = await client.hashi.view.withdrawalFees(); + expect(result.withdrawalMinimumSats).toBe(30_000n); + expect(result.worstCaseNetworkFeeSats).toBe(30_000n - 546n); + expect(result.gasEstimateMist).toBe(0n); + }); + + it('includes gas estimate when sender is provided', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); + vi.spyOn(client.core, 'simulateTransaction').mockResolvedValueOnce({ + Transaction: { + effects: { + gasUsed: { + computationCost: '2000', + storageCost: '1000', + storageRebate: '500', + }, + }, + }, + } as never); + + const result = await client.hashi.view.withdrawalFees(TEST_SUI_ADDRESS); + expect(result.withdrawalMinimumSats).toBe(30_000n); + // (2000 + 1000 - 500) * 120 / 100 = 3000 + expect(result.gasEstimateMist).toBe(3000n); + }); + }); + + describe('waitForDeposit', () => { + it('returns immediately when deposit is already confirmed', async () => { + const statusSpy = vi.spyOn(client.hashi.view, 'depositStatus').mockResolvedValueOnce({ + requestId: REQUEST_ID, + amountSats: 50_000n, + recipient: TEST_SUI_ADDRESS, + btcTxid: '0x' + 'ab'.repeat(32), + btcVout: 0, + timestampMs: 1_000n, + approvalTimestampMs: 2_000n, + confirmableAtMs: 602_000n, + status: 'confirmed', + suiTxDigest: 'test-digest', + }); + + const result = await client.hashi.waitForDeposit('test-digest'); + expect(result.status).toBe('confirmed'); + expect(statusSpy).toHaveBeenCalledTimes(1); + }); + + it('returns when deposit is expired', async () => { + vi.spyOn(client.hashi.view, 'depositStatus').mockResolvedValueOnce({ + requestId: REQUEST_ID, + amountSats: 50_000n, + recipient: TEST_SUI_ADDRESS, + btcTxid: '0x' + 'ab'.repeat(32), + btcVout: 0, + timestampMs: 1_000n, + approvalTimestampMs: null, + confirmableAtMs: null, + status: 'expired', + suiTxDigest: 'test-digest', + }); + + const result = await client.hashi.waitForDeposit('test-digest'); + expect(result.status).toBe('expired'); + }); + + it('throws when deposit is not found', async () => { + vi.spyOn(client.hashi.view, 'depositStatus').mockResolvedValueOnce(null); + + await expect(client.hashi.waitForDeposit('test-digest')).rejects.toThrow('Deposit not found'); + }); + + it('aborts polling when signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + client.hashi.waitForDeposit('test-digest', { signal: controller.signal }), + ).rejects.toThrow('Polling aborted'); + }); + }); + + describe('waitForWithdrawal', () => { + it('returns immediately when withdrawal is already confirmed', async () => { + vi.spyOn(client.hashi.view, 'withdrawalStatus').mockResolvedValueOnce({ + requestId: REQUEST_ID, + btcAmountSats: 30_000n, + bitcoinAddress: new Uint8Array(32), + sender: TEST_SUI_ADDRESS, + timestampMs: 2_000n, + status: 'Confirmed', + suiTxDigest: 'test-digest', + btcTxid: '0x' + '99'.repeat(32), + }); + + const result = await client.hashi.waitForWithdrawal('test-digest'); + expect(result.status).toBe('Confirmed'); + }); + + it('returns when withdrawal is cancelled', async () => { + vi.spyOn(client.hashi.view, 'withdrawalStatus').mockResolvedValueOnce({ + requestId: REQUEST_ID, + btcAmountSats: 30_000n, + bitcoinAddress: new Uint8Array(32), + sender: TEST_SUI_ADDRESS, + timestampMs: 2_000n, + status: 'cancelled', + suiTxDigest: 'test-digest', + btcTxid: null, + }); + + const result = await client.hashi.waitForWithdrawal('test-digest'); + expect(result.status).toBe('cancelled'); + }); + + it('throws when withdrawal is not found', async () => { + vi.spyOn(client.hashi.view, 'withdrawalStatus').mockResolvedValueOnce(null); + + await expect(client.hashi.waitForWithdrawal('test-digest')).rejects.toThrow( + 'Withdrawal not found', + ); + }); + + it('aborts polling when signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + client.hashi.waitForWithdrawal('test-digest', { signal: controller.signal }), + ).rejects.toThrow('Polling aborted'); + }); + }); + + describe('bitcoin (BTC RPC)', () => { + it('throws when btcRpcUrl is not configured', async () => { + await expect(client.hashi.bitcoin.lookupVout('txid', 'addr')).rejects.toThrow( + 'btcRpcUrl is required', + ); + }); + + it('throws for lookupAllVouts without btcRpcUrl', async () => { + await expect(client.hashi.bitcoin.lookupAllVouts('txid', 'addr')).rejects.toThrow( + 'btcRpcUrl is required', + ); + }); + + it('throws for confirmations without btcRpcUrl', async () => { + await expect(client.hashi.bitcoin.confirmations('txid')).rejects.toThrow( + 'btcRpcUrl is required', + ); + }); + }); + + describe('tx', () => { + describe('deposit', () => { + it('composes utxo_id + utxo + deposit for a single UTXO', () => { + const tx = client.hashi.tx.deposit({ + txid: '0x' + 'ab'.repeat(32), + utxos: [{ vout: 0, amountSats: 100_000n }], + recipient: TEST_SUI_ADDRESS, + }); + expect(tx).toBeInstanceOf(Transaction); + + const { commands } = tx.getData(); + expect(commands).toHaveLength(3); + + expect(commands[0].$kind).toBe('MoveCall'); + expect(commands[0].MoveCall?.function).toBe('utxo_id'); + + expect(commands[1].$kind).toBe('MoveCall'); + expect(commands[1].MoveCall?.function).toBe('utxo'); + + expect(commands[2].$kind).toBe('MoveCall'); + expect(commands[2].MoveCall?.function).toBe('deposit'); + }); + + it('batches multiple UTXOs into one PTB (one triple per UTXO)', () => { + const tx = client.hashi.tx.deposit({ + txid: '0x' + 'cd'.repeat(32), + utxos: [ + { vout: 0, amountSats: 100_000n }, + { vout: 2, amountSats: 50_000n }, + ], + recipient: TEST_SUI_ADDRESS, + }); + + const { commands } = tx.getData(); + expect(commands).toHaveLength(6); + + const functions = commands.map((c) => c.MoveCall?.function); + expect(functions).toEqual(['utxo_id', 'utxo', 'deposit', 'utxo_id', 'utxo', 'deposit']); + }); + }); + + describe('cancelWithdrawal', () => { + it('composes cancel + from_balance + transferObjects', () => { + const tx = client.hashi.tx.cancelWithdrawal({ + requestId: REQUEST_ID, + recipient: TEST_SUI_ADDRESS, + }); + expect(tx).toBeInstanceOf(Transaction); + + const { commands } = tx.getData(); + expect(commands).toHaveLength(3); + + expect(commands[0].$kind).toBe('MoveCall'); + expect(commands[0].MoveCall?.function).toBe('cancel_withdrawal'); + + expect(commands[1].$kind).toBe('MoveCall'); + expect(commands[1].MoveCall?.function).toBe('from_balance'); + expect(commands[1].MoveCall?.typeArguments).toEqual([`${PACKAGE_ID}::btc::BTC`]); + + expect(commands[2].$kind).toBe('TransferObjects'); + }); + }); + + describe('requestWithdrawal', () => { + it('composes coinWithBalance + into_balance + request_withdrawal', () => { + const tx = client.hashi.tx.requestWithdrawal({ + amount: 50_000n, + bitcoinAddress: new Uint8Array(32), + }); + expect(tx).toBeInstanceOf(Transaction); + + const { commands } = tx.getData(); + const moveCalls = commands.filter((c) => c.$kind === 'MoveCall'); + + const intoBalance = moveCalls.find((c) => c.MoveCall?.function === 'into_balance'); + expect(intoBalance?.MoveCall?.typeArguments).toEqual([`${PACKAGE_ID}::btc::BTC`]); + + expect(moveCalls.some((c) => c.MoveCall?.function === 'request_withdrawal')).toBe(true); + }); + }); + }); +}); + +describe('HashiClient guardian', () => { + const GUARDIAN_ORIGIN = 'https://guardian.example'; + + /** Curated `/info` body matching the proxy contract (u64s as strings). */ + const INFO_BODY = { + limiter: { + state: { + numTokensAvailableSats: '400000', + lastUpdatedAtSecs: '1720000000', + nextSeq: '7', + }, + config: { refillRateSatsPerSec: '1000', maxBucketCapacitySats: '2000000' }, + }, + gitRevision: 'abc123', + committeeEpoch: '3', + btcPubkey: 'deadbeef', + signingPubKey: 'feedface', + signedAtMs: '1720000000123', + }; + + /** The parsed limiter matching INFO_BODY, for provider-based tests. */ + const LIMITER: NonNullable = { + state: { numTokensAvailableSats: 400_000n, lastUpdatedAtSecs: 1_720_000_000n, nextSeq: 7n }, + config: { refillRateSatsPerSec: 1_000n, maxBucketCapacitySats: 2_000_000n }, + }; + + function rawInfo(limiter: RawGuardianInfo['limiter']): RawGuardianInfo { + return { + limiter, + gitRevision: 'abc123', + committeeEpoch: 3n, + btcPubkey: 'deadbeef', + signingPubKey: 'feedface', + signedAtMs: 1_720_000_000_123n, + }; + } + + function makeClient(opts?: { + guardianUrl?: string; + guardianInfoProvider?: GuardianInfoProvider; + }) { + return new SuiGrpcClient({ + network: 'devnet', + baseUrl: 'https://fullnode.devnet.sui.io:443', + }).$extend( + hashi({ + network: 'devnet', + hashiObjectId: HASHI_OBJECT_ID, + packageId: PACKAGE_ID, + bitcoinNetwork: 'regtest', + ...opts, + }), + ); + } + + function mockGuardianFetch(body: unknown, init?: { ok?: boolean; status?: number }) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: init?.ok ?? true, + status: init?.status ?? 200, + json: async () => body, + } as Response); + } + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('guardianInfoProvider takes precedence over guardianUrl and on-chain', async () => { + const provider = vi.fn(async () => rawInfo(LIMITER)); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const getSpy = vi.spyOn(Hashi, 'get'); + const client = makeClient({ guardianInfoProvider: provider, guardianUrl: GUARDIAN_ORIGIN }); + + const info = await client.hashi.guardian.info(); + + expect(info.limiter).toEqual(LIMITER); + expect(provider).toHaveBeenCalledOnce(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(getSpy).not.toHaveBeenCalled(); + }); + + it('guardianUrl takes precedence over the on-chain config', async () => { + const fetchSpy = mockGuardianFetch(INFO_BODY); + const getSpy = vi.spyOn(Hashi, 'get'); + const client = makeClient({ guardianUrl: GUARDIAN_ORIGIN }); + + const info = await client.hashi.guardian.info(); + + expect(fetchSpy).toHaveBeenCalledWith(`${GUARDIAN_ORIGIN}/info`, { + headers: { Accept: 'application/json' }, + }); + expect(getSpy).not.toHaveBeenCalled(); + expect(info.limiter?.state.numTokensAvailableSats).toBe(400_000n); + }); + + it('resolves the guardian URL from the on-chain guardian_url config', async () => { + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG, + { key: 'guardian_url', value: { $kind: 'String', String: GUARDIAN_ORIGIN } }, + ]); + const fetchSpy = mockGuardianFetch(INFO_BODY); + const client = makeClient(); + + await client.hashi.guardian.info(); + + expect(Hashi.get).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledWith(`${GUARDIAN_ORIGIN}/info`, { + headers: { Accept: 'application/json' }, + }); + }); + + it('throws not-configured when no guardian URL can be resolved', async () => { + mockHashiWithConfig(WELL_FORMED_CONFIG); // no guardian_url entry + const client = makeClient(); + + const err = await client.hashi.guardian.info().catch((e) => e); + expect(err).toBeInstanceOf(HashiGuardianError); + expect(err.code).toBe('not-configured'); + }); + + it('re-reads on-chain while guardian_url is absent, then caches it once launch publishes it', async () => { + // hashi#772 defers `guardian_url` to launch (`finish_publish`), so a client + // created pre-launch must keep re-reading the chain — never cache the + // absence and fail forever once the URL appears. + const client = makeClient(); + + // Pre-launch: absent guardian_url → not-configured, re-read (not cached) each call. + mockHashiWithConfig(WELL_FORMED_CONFIG); + expect((await client.hashi.guardian.info().catch((e) => e)).code).toBe('not-configured'); + mockHashiWithConfig(WELL_FORMED_CONFIG); + expect((await client.hashi.guardian.info().catch((e) => e)).code).toBe('not-configured'); + expect(Hashi.get).toHaveBeenCalledTimes(2); // re-read each time, never cached a null + + // Launch publishes guardian_url → the same client resolves and succeeds. + mockHashiWithConfig([ + ...WELL_FORMED_CONFIG, + { key: 'guardian_url', value: { $kind: 'String', String: GUARDIAN_ORIGIN } }, + ]); + const fetchSpy = mockGuardianFetch(INFO_BODY); + const info = await client.hashi.guardian.info(); + expect(info.limiter?.state.numTokensAvailableSats).toBe(400_000n); + expect(fetchSpy).toHaveBeenCalledWith(`${GUARDIAN_ORIGIN}/info`, { + headers: { Accept: 'application/json' }, + }); + expect(Hashi.get).toHaveBeenCalledTimes(3); // one more read to pick up the URL + + // Now cached: a later call skips the chain read and hits the guardian directly. + mockGuardianFetch(INFO_BODY); + await client.hashi.guardian.info(); + expect(Hashi.get).toHaveBeenCalledTimes(3); // unchanged — URL cached after launch + }); + + it('info() returns limiter: null but limiterStatus()/canWithdraw() throw not-initialized', async () => { + const client = makeClient({ guardianInfoProvider: async () => rawInfo(null) }); + + expect((await client.hashi.guardian.info()).limiter).toBeNull(); + + const statusErr = await client.hashi.guardian.limiterStatus().catch((e) => e); + expect(statusErr).toBeInstanceOf(HashiGuardianError); + expect(statusErr.code).toBe('not-initialized'); + + const withdrawErr = await client.hashi.guardian.canWithdraw(1n).catch((e) => e); + expect(withdrawErr).toBeInstanceOf(HashiGuardianError); + expect(withdrawErr.code).toBe('not-initialized'); + }); + + it('limiterStatus() projects capacity, fill %, and the refill-to-full ETA', async () => { + // 100s after lastUpdatedAt: 400_000 + 100*1_000 = 500_000 of 2_000_000 (25%). + vi.spyOn(Date, 'now').mockReturnValue(1_720_000_100_000); + const client = makeClient({ guardianInfoProvider: async () => rawInfo(LIMITER) }); + + const status = await client.hashi.guardian.limiterStatus(); + + expect(status.availableNowSats).toBe(500_000n); + expect(status.bucketFillPercent).toBe(25); + // Deficit 1_500_000 / 1_000 sat/s = 1_500s until full. + expect(status.fullAtSecs).toBe(1_720_001_600n); + }); + + it('canWithdraw() reports allowed and the estimated wait', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_720_000_100_000); // available = 500_000 + const client = makeClient({ guardianInfoProvider: async () => rawInfo(LIMITER) }); + + expect(await client.hashi.guardian.canWithdraw(500_000n)).toEqual({ + allowed: true, + availableNowSats: 500_000n, + estimatedWaitSecs: 0n, + }); + expect(await client.hashi.guardian.canWithdraw(700_000n)).toEqual({ + allowed: false, + availableNowSats: 500_000n, + estimatedWaitSecs: 200n, // deficit 200_000 / 1_000 sat/s + }); + }); +}); diff --git a/packages/hashi/test/unit/guardian.test.ts b/packages/hashi/test/unit/guardian.test.ts new file mode 100644 index 000000000..cf3397ecd --- /dev/null +++ b/packages/hashi/test/unit/guardian.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { projectCapacity, estimateWaitSecs, fetchGuardianInfo } from '../../src/guardian.js'; +import { HashiGuardianError } from '../../src/errors.js'; +import type { GuardianLimiterConfig, GuardianLimiterState } from '../../src/types.js'; + +const config: GuardianLimiterConfig = { + refillRateSatsPerSec: 1_000n, + maxBucketCapacitySats: 2_000_000n, +}; + +function state(overrides?: Partial): GuardianLimiterState { + return { + numTokensAvailableSats: 0n, + lastUpdatedAtSecs: 0n, + nextSeq: 0n, + ...overrides, + }; +} + +describe('projectCapacity', () => { + it('refills linearly over time', () => { + const s = state(); + expect(projectCapacity(config, s, 100n)).toBe(100_000n); + }); + + it('caps at maxBucketCapacitySats', () => { + const s = state(); + expect(projectCapacity(config, s, 10_000n)).toBe(2_000_000n); + }); + + it('returns existing tokens when no time has elapsed', () => { + const s = state({ numTokensAvailableSats: 500_000n, lastUpdatedAtSecs: 50n }); + expect(projectCapacity(config, s, 50n)).toBe(500_000n); + }); + + it('adds refill to existing tokens', () => { + const s = state({ numTokensAvailableSats: 500_000n, lastUpdatedAtSecs: 50n }); + expect(projectCapacity(config, s, 150n)).toBe(600_000n); + }); + + it('clamps refill + existing to max', () => { + const s = state({ numTokensAvailableSats: 1_999_000n, lastUpdatedAtSecs: 0n }); + expect(projectCapacity(config, s, 100n)).toBe(2_000_000n); + }); + + it('handles timestamp before lastUpdatedAt gracefully (no negative)', () => { + const s = state({ lastUpdatedAtSecs: 100n }); + expect(projectCapacity(config, s, 50n)).toBe(0n); + }); + + it('handles already-full bucket', () => { + const s = state({ numTokensAvailableSats: 2_000_000n, lastUpdatedAtSecs: 0n }); + expect(projectCapacity(config, s, 1_000n)).toBe(2_000_000n); + }); + + it('handles zero refill rate', () => { + const zeroConfig = { ...config, refillRateSatsPerSec: 0n }; + const s = state({ numTokensAvailableSats: 500n }); + expect(projectCapacity(zeroConfig, s, 9999n)).toBe(500n); + }); +}); + +describe('estimateWaitSecs', () => { + it('returns 0n when capacity already available', () => { + const s = state({ numTokensAvailableSats: 1_000_000n }); + expect(estimateWaitSecs(config, s, 500_000n, 0n)).toBe(0n); + }); + + it('returns null when amount exceeds max bucket capacity', () => { + const s = state(); + expect(estimateWaitSecs(config, s, 2_000_001n, 0n)).toBeNull(); + }); + + it('computes wait from empty bucket', () => { + const s = state(); + // Need 1_000_000 sats, refill rate 1_000/sec → 1_000 seconds + expect(estimateWaitSecs(config, s, 1_000_000n, 0n)).toBe(1_000n); + }); + + it('uses ceiling division for fractional seconds', () => { + const s = state(); + // Need 1_001 sats, refill rate 1_000/sec → ceil(1_001/1_000) = 2 seconds + expect(estimateWaitSecs(config, s, 1_001n, 0n)).toBe(2n); + }); + + it('accounts for partial refill via elapsed time', () => { + const s = state({ lastUpdatedAtSecs: 0n }); + // At nowSecs=500, available = 500_000. Need 600_000. Deficit = 100_000. + // Wait = 100_000 / 1_000 = 100 seconds. + expect(estimateWaitSecs(config, s, 600_000n, 500n)).toBe(100n); + }); + + it('returns null when refill rate is zero and deficit exists', () => { + const zeroConfig = { ...config, refillRateSatsPerSec: 0n }; + const s = state({ numTokensAvailableSats: 100n }); + expect(estimateWaitSecs(zeroConfig, s, 200n, 0n)).toBeNull(); + }); + + it('returns 0n when exact amount is available', () => { + const s = state({ numTokensAvailableSats: 1_000_000n }); + expect(estimateWaitSecs(config, s, 1_000_000n, 0n)).toBe(0n); + }); + + it('returns 0n when refill at nowSecs makes amount exactly available', () => { + const s = state({ lastUpdatedAtSecs: 0n }); + // At nowSecs=100, available = 100_000 + expect(estimateWaitSecs(config, s, 100_000n, 100n)).toBe(0n); + }); +}); + +const INFO_BODY = { + limiter: { + state: { numTokensAvailableSats: '500000', lastUpdatedAtSecs: '1720000000', nextSeq: '7' }, + config: { refillRateSatsPerSec: '1000', maxBucketCapacitySats: '2000000' }, + }, + gitRevision: 'abc123', + committeeEpoch: '3', + btcPubkey: 'deadbeef', + signingPubKey: 'feedface', + signedAtMs: '1720000000123', +}; + +function mockFetch(body: unknown, init?: { ok?: boolean; status?: number }) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: init?.ok ?? true, + status: init?.status ?? 200, + json: async () => body, + } as Response); +} + +describe('fetchGuardianInfo', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('parses a fully-initialized /info body (strings → bigint)', async () => { + mockFetch(INFO_BODY); + const info = await fetchGuardianInfo('https://g.example'); + expect(info).toEqual({ + limiter: { + state: { + numTokensAvailableSats: 500_000n, + lastUpdatedAtSecs: 1_720_000_000n, + nextSeq: 7n, + }, + config: { refillRateSatsPerSec: 1_000n, maxBucketCapacitySats: 2_000_000n }, + }, + gitRevision: 'abc123', + committeeEpoch: 3n, + btcPubkey: 'deadbeef', + signingPubKey: 'feedface', + signedAtMs: 1_720_000_000_123n, + }); + }); + + it('appends /info, strips trailing slashes, and sends a preflight-free GET', async () => { + const spy = mockFetch(INFO_BODY); + await fetchGuardianInfo('https://g.example///'); + expect(spy).toHaveBeenCalledWith('https://g.example/info', { + headers: { Accept: 'application/json' }, + }); + }); + + it('returns limiter: null for an uninitialized guardian', async () => { + mockFetch({ ...INFO_BODY, limiter: null, btcPubkey: null, committeeEpoch: null }); + const info = await fetchGuardianInfo('https://g.example'); + expect(info.limiter).toBeNull(); + expect(info.btcPubkey).toBeNull(); + expect(info.committeeEpoch).toBeNull(); + expect(info.gitRevision).toBe('abc123'); + }); + + it('throws http-error on a non-2xx status', async () => { + mockFetch(null, { ok: false, status: 503 }); + const err = await fetchGuardianInfo('https://g.example').catch((e) => e); + expect(err).toBeInstanceOf(HashiGuardianError); + expect(err.code).toBe('http-error'); + expect(err.status).toBe(503); + }); + + it('throws unreachable when fetch rejects', async () => { + const cause = new TypeError('fetch failed'); + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(cause); + const err = await fetchGuardianInfo('https://g.example').catch((e) => e); + expect(err).toBeInstanceOf(HashiGuardianError); + expect(err.code).toBe('unreachable'); + expect(err.cause).toBe(cause); + }); + + it('throws malformed-response on a non-JSON body', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + } as unknown as Response); + const err = await fetchGuardianInfo('https://g.example').catch((e) => e); + expect(err).toBeInstanceOf(HashiGuardianError); + expect(err.code).toBe('malformed-response'); + }); + + it('throws malformed-response on a partial limiter (never masks with 0)', async () => { + mockFetch({ + ...INFO_BODY, + limiter: { state: INFO_BODY.limiter.state, config: { refillRateSatsPerSec: '1000' } }, + }); + const err = await fetchGuardianInfo('https://g.example').catch((e) => e); + expect(err).toBeInstanceOf(HashiGuardianError); + expect(err.code).toBe('malformed-response'); + }); +}); diff --git a/packages/hashi/test/unit/util.test.ts b/packages/hashi/test/unit/util.test.ts new file mode 100644 index 000000000..484b7ba2c --- /dev/null +++ b/packages/hashi/test/unit/util.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { assertHex32, entry, reverseTxidBytes, type ConfigEntry } from '../../src/util.js'; +import { HashiConfigError, InvalidParamsError } from '../../src/errors.js'; + +describe('assertHex32', () => { + it('accepts a 0x-prefixed 64-char lowercase hex string', () => { + expect(() => assertHex32(`0x${'a'.repeat(64)}`, 'txid')).not.toThrow(); + }); + + it('accepts uppercase and mixed-case hex', () => { + expect(() => assertHex32(`0x${'A'.repeat(64)}`, 'txid')).not.toThrow(); + expect(() => assertHex32(`0x${'aB'.repeat(32)}`, 'txid')).not.toThrow(); + }); + + it('rejects a missing 0x prefix', () => { + expect(() => assertHex32('a'.repeat(64), 'txid')).toThrow(InvalidParamsError); + }); + + it('rejects a string that is too short or too long', () => { + expect(() => assertHex32(`0x${'a'.repeat(63)}`, 'txid')).toThrow(InvalidParamsError); + expect(() => assertHex32(`0x${'a'.repeat(65)}`, 'txid')).toThrow(InvalidParamsError); + }); + + it('rejects non-hex characters', () => { + expect(() => assertHex32(`0x${'z'.repeat(64)}`, 'txid')).toThrow(InvalidParamsError); + }); + + it('rejects non-string values', () => { + for (const v of [undefined, null, 123, {}, []]) { + expect(() => assertHex32(v, 'txid')).toThrow(InvalidParamsError); + } + }); + + it('interpolates fieldName into the error message', () => { + try { + assertHex32('not-hex', 'recipient'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidParamsError); + expect((err as InvalidParamsError).reason).toContain('`recipient`'); + expect((err as InvalidParamsError).detail).toContain('"not-hex"'); + } + }); +}); + +describe('reverseTxidBytes', () => { + // Real fixture captured during SEDEFI-190 diagnosis: a UI deposit's + // user-facing (display-order) txid and the bytes that ended up on-chain + // when the frontend recorded it via `utxo::utxo_id`. + const DISPLAY = '0x043f682206d246cffdc23106820dc3aa87985a52cccd2d4275bbc3f492f71c0e'; + const INTERNAL = '0e1cf792f4c3bb75422dcdcc525a9887aac30d820631c2fdcf46d20622683f04'; + + it('reverses display order to internal order using the real-world fixture', () => { + expect(reverseTxidBytes(DISPLAY)).toBe(INTERNAL); + }); + + it('preserves a palindromic txid (sanity: trivial case still works)', () => { + const palindrome = `0x${'ab'.repeat(32)}`; + expect(reverseTxidBytes(palindrome)).toBe('ab'.repeat(32)); + }); + + it('returns plain hex without 0x prefix, 64 chars', () => { + const out = reverseTxidBytes(DISPLAY); + expect(out.startsWith('0x')).toBe(false); + expect(out.length).toBe(64); + }); + + it('rejects malformed input via assertHex32', () => { + expect(() => reverseTxidBytes('not-hex')).toThrow(InvalidParamsError); + expect(() => reverseTxidBytes(`0x${'a'.repeat(63)}`)).toThrow(InvalidParamsError); + expect(() => reverseTxidBytes('a'.repeat(64))).toThrow(InvalidParamsError); + }); +}); + +describe('entry', () => { + const fixture: ConfigEntry[] = [ + { key: 'paused', value: { $kind: 'Bool', Bool: true } }, + { key: 'min', value: { $kind: 'U64', U64: '546' } }, + { key: 'chain', value: { $kind: 'Address', Address: `0x${'a'.repeat(64)}` } }, + ]; + + it('returns the narrowed variant when key and variant match', () => { + const u64 = entry(fixture, 'min', 'U64'); + expect(u64.U64).toBe('546'); + const addr = entry(fixture, 'chain', 'Address'); + expect(addr.Address).toBe(`0x${'a'.repeat(64)}`); + const paused = entry(fixture, 'paused', 'Bool'); + expect(paused.Bool).toBe(true); + }); + + it('throws HashiConfigError.missing when the key is absent', () => { + try { + entry(fixture, 'nonexistent', 'U64'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(HashiConfigError); + const e = err as HashiConfigError; + expect(e.key).toBe('nonexistent'); + expect(e.expectedVariant).toBe('U64'); + expect(e.actualVariant).toBeUndefined(); + expect(e.message).toContain('"nonexistent"'); + } + }); + + it('throws HashiConfigError.wrongVariant when the variant differs', () => { + try { + entry(fixture, 'paused', 'U64'); + expect.fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(HashiConfigError); + const e = err as HashiConfigError; + expect(e.key).toBe('paused'); + expect(e.expectedVariant).toBe('U64'); + expect(e.actualVariant).toBe('Bool'); + } + }); + + it('returns the first match when duplicate keys exist', () => { + const dupes: ConfigEntry[] = [ + { key: 'min', value: { $kind: 'U64', U64: '1' } }, + { key: 'min', value: { $kind: 'U64', U64: '2' } }, + ]; + expect(entry(dupes, 'min', 'U64').U64).toBe('1'); + }); +}); diff --git a/packages/hashi/tsconfig.json b/packages/hashi/tsconfig.json new file mode 100644 index 000000000..676a8abb9 --- /dev/null +++ b/packages/hashi/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.shared.json", + "include": ["src"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/hashi/tsdown.config.ts b/packages/hashi/tsdown.config.ts new file mode 100644 index 000000000..a0a98cb7d --- /dev/null +++ b/packages/hashi/tsdown.config.ts @@ -0,0 +1,11 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: 'esm', + dts: true, + outDir: 'dist', + unbundle: true, +}); diff --git a/packages/hashi/turbo.json b/packages/hashi/turbo.json new file mode 100644 index 000000000..fdae76d8b --- /dev/null +++ b/packages/hashi/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build", "@mysten/sui#build"] + } + } +} diff --git a/packages/hashi/vitest.config.mts b/packages/hashi/vitest.config.mts new file mode 100644 index 000000000..97a4249f8 --- /dev/null +++ b/packages/hashi/vitest.config.mts @@ -0,0 +1,71 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'vitest/config'; + +// vitest/vite doesn't forward `.env` into `process.env` for Node-style +// reads — it only populates `import.meta.env`, filtered to VITE_* keys by +// default. Integration tests use `process.env.HASHI_E2E_SUI_PRIVATE_KEY`, +// so parse `.env` here and inject missing keys via `test.env`. Shell and +// CI-provided env vars (e.g. GitHub Actions secrets) take precedence over +// the local `.env`, which is treated as a fallback for local dev only. +function loadDotEnv(path: string): Record { + const env: Record = {}; + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch { + return env; + } + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + if (key in process.env) continue; + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +const integrationEnv = loadDotEnv('.env'); + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + include: ['test/unit/**/*.test.ts'], + }, + }, + { + test: { + name: 'integration', + include: ['test/integration/**/*.test.ts'], + testTimeout: 30_000, + env: integrationEnv, + // Run integration test files one at a time. Even with + // per-test fresh-signer isolation, the localnet + // committee processes deposits with limited concurrency + // — two parallel `client.hashi.deposit()` calls had one + // settle and the other stall indefinitely (validators + // detected the request but never advanced past + // detection; see PR #11 CI run). Sequential execution + // sidesteps the validator-side race; cost is small + // (4 files, most fast) and reliability dominates. + fileParallelism: false, + }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa6bd37c5..017de70c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,7 +176,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.1 - version: 4.3.1(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.3.1(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -234,7 +234,7 @@ importers: version: link:../../../codegen '@tailwindcss/vite': specifier: ^4.3.1 - version: 4.3.1(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.3.1(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -893,6 +893,34 @@ importers: specifier: ^8.0.16 version: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + packages/hashi: + dependencies: + '@mysten/sui': + specifier: workspace:^ + version: link:../sui + '@noble/curves': + specifier: ^2.2.0 + version: 2.2.0 + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 + '@scure/base': + specifier: ^2.2.0 + version: 2.2.0 + devDependencies: + '@mysten/codegen': + specifier: workspace:^ + version: link:../codegen + '@types/node': + specifier: ^25.9.3 + version: 25.9.3 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.3)(happy-dom@20.10.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jsdom@29.1.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@25.9.3)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/kiosk: dependencies: '@mysten/bcs': @@ -15922,7 +15950,7 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.1 - '@tailwindcss/vite@4.3.1(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.1(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.1 '@tailwindcss/oxide': 4.3.1 From 7f83a6b234260240b79c485e398cc2b15aa0aca1 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Wed, 15 Jul 2026 14:02:24 +0300 Subject: [PATCH 02/13] Add hashi-ci.yml localnet integration workflow Runs the @mysten/hashi integration tests against a localnet, path-filtered to packages/hashi/**. Ported from hashi-ts-sdk's integration.yml with three adaptations for this monorepo: - ts-sdks has no hashi submodule, so clone MystenLabs/hashi at the bindings pin (cd2b81f) into ./hashi explicitly. - @mysten/sui is a workspace:^ dep resolving to dist/, so build it first via `pnpm turbo build --filter @mysten/hashi` (mirrors seal-ci.yml). - Use this repo's pinned action SHAs and node 24. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/hashi-ci.yml | 309 +++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 .github/workflows/hashi-ci.yml diff --git a/.github/workflows/hashi-ci.yml b/.github/workflows/hashi-ci.yml new file mode 100644 index 000000000..a0b5287b6 --- /dev/null +++ b/.github/workflows/hashi-ci.yml @@ -0,0 +1,309 @@ +name: Hashi Integration Tests + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'packages/hashi/**' + - 'pnpm-lock.yaml' + - '.github/workflows/hashi-ci.yml' + pull_request: + paths: + - 'packages/hashi/**' + - 'pnpm-lock.yaml' + - '.github/workflows/hashi-ci.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + localnet-integration: + name: Localnet Integration + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + # Pin the hashi checkout to the commit the committed SDK bindings in + # `packages/hashi/src/contracts` were generated from. Unlike the + # standalone hashi-ts-sdk repo, ts-sdks does NOT vendor hashi as a + # submodule, so we clone it explicitly below. Keep this in sync with + # the codegen revision when the bindings are regenerated. + HASHI_PIN: cd2b81f9a208f5077032110652f1e8bba297278c + LOCALNET_DATA_DIR: ${{ github.workspace }}/.hashi/localnet + LOCALNET_LOG: ${{ github.workspace }}/localnet.log + steps: + - name: Checkout ts-sdks + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # pin@v4 + + # ts-sdks has no hashi submodule (../hashi is only a codegen sibling), + # so pull the matching hashi revision into ./hashi. `pnpm-workspace.yaml` + # only globs `packages/**`, so this checkout is invisible to pnpm. + - name: Checkout hashi (localnet harness + contracts) + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # pin@v4 + with: + repository: MystenLabs/hashi + ref: ${{ env.HASHI_PIN }} + path: hashi + submodules: recursive + + - name: Record hashi pin in step summary + run: | + PIN=$(git -C hashi rev-parse HEAD) + echo "Hashi pin: \`$PIN\`" >> "$GITHUB_STEP_SUMMARY" + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # pin@v4 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # pin@v4 + with: + node-version: '24' + cache: 'pnpm' + + # `hashi/rust-toolchain.toml` pins 1.91 — match it so cargo doesn't + # auto-install a second toolchain inside the build step (slower and + # uncached). + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # pin@stable + with: + toolchain: '1.91' + + - uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # pin@v2.9.1 + with: + workspaces: hashi -> hashi/target + # Bumping the pin should bust the cache. `Swatinem/rust-cache` + # already keys on Cargo.lock; the explicit hashi SHA in the key + # makes the relationship unambiguous in cache hit/miss logs. + shared-key: hashi-${{ env.HASHI_PIN }} + + - name: Install Sui binary + # install-sui.sh queries the GitHub releases API with GITHUB_TOKEN + # (set -u, so it must be set) — mirrors the env in hashi's own ci.yml. + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash hashi/.github/scripts/install-sui.sh + + # Mirrors the "Install Bitcoin Core" step in + # hashi/.github/workflows/ci.yml. Vendored verbatim because hashi-core + # has it inline (no install-bitcoin.sh to reuse). Revisit if hashi + # extracts a script. + - name: Install Bitcoin Core + env: + FALLBACK_VERSION: '29.1' + run: | + echo "Detecting latest Bitcoin Core version..." + BITCOIN_VERSION=$(curl -s https://api.github.com/repos/bitcoin/bitcoin/releases/latest | \ + sed -n 's/.*"tag_name": "v\([^"]*\)".*/\1/p' || echo "$FALLBACK_VERSION") + + if [ -z "$BITCOIN_VERSION" ]; then + echo "Failed to detect version, using fallback: $FALLBACK_VERSION" + BITCOIN_VERSION="$FALLBACK_VERSION" + fi + + echo "Installing Bitcoin Core v${BITCOIN_VERSION}..." + + wget -q https://bitcoincore.org/bin/bitcoin-core-${BITCOIN_VERSION}/bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz || { + echo "Download failed for ${BITCOIN_VERSION}; falling back to ${FALLBACK_VERSION}" + BITCOIN_VERSION="$FALLBACK_VERSION" + wget -q https://bitcoincore.org/bin/bitcoin-core-${BITCOIN_VERSION}/bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz + } + + tar -xzf bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz + sudo mv bitcoin-${BITCOIN_VERSION}/bin/bitcoind /usr/local/bin/ + sudo mv bitcoin-${BITCOIN_VERSION}/bin/bitcoin-cli /usr/local/bin/ + rm -rf bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz bitcoin-${BITCOIN_VERSION}/ + + bitcoind --version | head -n1 + + - name: Build hashi-localnet binary + working-directory: hashi + run: cargo build --release --bin hashi-localnet -p e2e-tests + + - name: Start hashi-localnet (background) + env: + NUM_VALIDATORS: 4 + run: | + mkdir -p "$LOCALNET_DATA_DIR" + # 4 validators is the right floor: on-chain `committee::verify_certificate` + # threshold is 2 (`MpcManager initialized` shows `threshold=2 max_faulty=1`). + # We tried bumping to 6 in 0d3ddf5 expecting 2/6 fault tolerance, but it + # made things worse — every extra validator adds a Kyoto BIP-157 client + # contending for the single regtest bitcoind, which amplified the peer- + # disconnect storm seen in run 25097936525. Recovery here is + # "pre-warm Kyoto in the readiness step" (next), not "more validators". + nohup hashi/target/release/hashi-localnet start \ + --num-validators "$NUM_VALIDATORS" \ + --sui-rpc-port 9000 \ + --btc-rpc-port 18443 \ + --data-dir "$LOCALNET_DATA_DIR" \ + --verbose \ + > "$LOCALNET_LOG" 2>&1 & + echo $! > "$LOCALNET_DATA_DIR/runner.pid" + echo "NUM_VALIDATORS=$NUM_VALIDATORS" >> "$GITHUB_ENV" + echo "hashi-localnet PID: $(cat $LOCALNET_DATA_DIR/runner.pid)" + + - name: Wait for localnet readiness + run: | + set -euo pipefail + echo "Phase 1/3: waiting for state.json (cap 5 min)…" + for i in $(seq 1 60); do + if [[ -f "$LOCALNET_DATA_DIR/state.json" ]]; then + echo "state.json appeared after ~$((i * 5)) s" + break + fi + sleep 5 + done + if [[ ! -f "$LOCALNET_DATA_DIR/state.json" ]]; then + echo "::error::state.json never appeared — localnet failed to bootstrap" + tail -n 200 "$LOCALNET_LOG" + exit 1 + fi + + OBJ=$(jq -r .hashi_object_id "$LOCALNET_DATA_DIR/state.json") + echo "Phase 2/3: polling Hashi object $OBJ for non-empty MPC key (cap 3 min)…" + MPC_OK=0 + for i in $(seq 1 36); do + LEN=$(curl -s -X POST http://127.0.0.1:9000 \ + -H content-type:application/json \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"sui_getObject\",\"params\":[\"$OBJ\",{\"showContent\":true}]}" \ + | jq -r '.result.data.content.fields.committee_set.fields.mpc_public_key | length' 2>/dev/null || echo 0) + if [[ "$LEN" -gt 0 ]]; then + echo "MPC key on chain (length=$LEN) after ~$((i * 5)) s — DKG settled." + MPC_OK=1 + break + fi + sleep 5 + done + if [[ "$MPC_OK" -ne 1 ]]; then + echo "::error::DKG did not settle within budget; MPC key still empty" + tail -n 200 "$LOCALNET_LOG" + exit 1 + fi + + # Phase 3: pre-warm Kyoto BIP-157 sync. PR #11 run 25097936525 + # showed validators racing the in-test 300 s deadline to finish + # compact-filter download — most never made it, so the certificate + # collected only 1 signer (threshold=2) and aborted on-chain with + # `committee::verify_certificate` ENotEnoughStake. Move the wait + # into readiness so it surfaces as a single clear failure here + # instead of an opaque test timeout downstream. + # + # Signal: each validator emits exactly one + # `Synchronized to height $TIP …` log line per new tip — confirmed + # by counting hits in the artifact (6 validators → 6 hits per + # height). We poll until ≥ NUM_VALIDATORS hits exist at the + # current bitcoind tip. + BTC_USER=$(jq -r .btc_rpc_user "$LOCALNET_DATA_DIR/state.json") + BTC_PASS=$(jq -r .btc_rpc_password "$LOCALNET_DATA_DIR/state.json") + BTC_URL=$(jq -r .btc_rpc_url "$LOCALNET_DATA_DIR/state.json") + TIP=$(curl -s --user "$BTC_USER:$BTC_PASS" \ + -H content-type:application/json \ + --data '{"jsonrpc":"1.0","id":1,"method":"getblockcount","params":[]}' \ + "$BTC_URL" | jq -r .result) + if [[ -z "$TIP" || "$TIP" == "null" ]]; then + echo "::error::Could not read regtest tip from bitcoind" + tail -n 200 "$LOCALNET_LOG" + exit 1 + fi + echo "Phase 3/3: waiting for Kyoto sync to tip $TIP on ≥$NUM_VALIDATORS validators (cap 5 min)…" + for i in $(seq 1 60); do + SYNCED=$(grep -Ec "Synchronized to height $TIP\b" "$LOCALNET_LOG" 2>/dev/null || true) + SYNCED=${SYNCED:-0} + if [[ "$SYNCED" -ge "$NUM_VALIDATORS" ]]; then + echo "≥$NUM_VALIDATORS validators reached tip $TIP after ~$((i * 5)) s ($SYNCED hits)" + exit 0 + fi + sleep 5 + done + echo "::error::Only $SYNCED of $NUM_VALIDATORS validators reached Kyoto tip $TIP within budget" + grep -E "Synchronized to height|Kyoto: Percent complete" "$LOCALNET_LOG" | tail -40 || true + tail -n 200 "$LOCALNET_LOG" + exit 1 + + - name: Export integration-test env + run: | + STATE="$LOCALNET_DATA_DIR/state.json" + { + echo "HASHI_E2E_SUI_RPC_URL=$(jq -r .sui_rpc_url $STATE)" + echo "HASHI_E2E_SUI_NETWORK=localnet" + echo "HASHI_E2E_PACKAGE_ID=$(jq -r .package_id $STATE)" + echo "HASHI_E2E_HASHI_OBJECT_ID=$(jq -r .hashi_object_id $STATE)" + echo "HASHI_E2E_BITCOIN_NETWORK=regtest" + echo "HASHI_E2E_FUNDED_KEY_PEM=$(jq -r .funded_sui_keypair_path $STATE)" + echo "HASHI_E2E_LOCALNET_BIN=${{ github.workspace }}/hashi/target/release/hashi-localnet" + echo "HASHI_E2E_LOCALNET_DATA_DIR=$LOCALNET_DATA_DIR" + echo "HASHI_E2E_BTC_RPC_URL=$(jq -r .btc_rpc_url $STATE)" + echo "HASHI_E2E_BTC_RPC_USER=$(jq -r .btc_rpc_user $STATE)" + echo "HASHI_E2E_BTC_RPC_PASS=$(jq -r .btc_rpc_password $STATE)" + } >> "$GITHUB_ENV" + + - name: Install pnpm dependencies + run: pnpm install --frozen-lockfile + + # Unlike the standalone hashi-ts-sdk repo (where @mysten/sui was a + # published tarball), here it is a `workspace:^` peer dep that resolves + # to `dist/`. Build hashi + its workspace deps so the SDK source the + # integration tests import can load @mysten/sui. Mirrors seal-ci.yml. + - name: Build hashi and dependencies + run: pnpm turbo build --filter @mysten/hashi + + # Kyoto BIP-157 polls bitcoind for new headers only every ~5 min when + # idle (no live block production on regtest). PR #11 run 25099701367 + # showed validators stuck at tip 108 for 5 min after the deposit test + # mined to 115, missing the SDK's 300 s budget by 2 s. Mining one + # regtest block every 20 s keeps Kyoto's header stream warm so + # deposits confirm in seconds instead of waiting for the next poll. + # Tests don't depend on absolute block height — only on having + # `confirmation_threshold + 1` blocks past their funding tx, which + # they mine themselves. + - name: Start bitcoind block heartbeat + run: | + ADDR=$(curl -s --user "$HASHI_E2E_BTC_RPC_USER:$HASHI_E2E_BTC_RPC_PASS" \ + -H content-type:application/json \ + --data '{"jsonrpc":"1.0","id":1,"method":"getnewaddress","params":[]}' \ + "$HASHI_E2E_BTC_RPC_URL/wallet/test" | jq -r .result) + if [[ -z "$ADDR" || "$ADDR" == "null" ]]; then + echo "::error::Could not obtain a regtest address for the heartbeat" + exit 1 + fi + nohup bash -c " + while true; do + curl -s --user '$HASHI_E2E_BTC_RPC_USER:$HASHI_E2E_BTC_RPC_PASS' \ + -H content-type:application/json \ + --data '{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"generatetoaddress\",\"params\":[1, \"$ADDR\"]}' \ + '$HASHI_E2E_BTC_RPC_URL' > /dev/null + sleep 20 + done + " > /dev/null 2>&1 & + echo $! > /tmp/btc-heartbeat.pid + echo "btc-heartbeat PID: $(cat /tmp/btc-heartbeat.pid) mining to $ADDR every 20s" + + - name: Run integration tests + run: pnpm --filter @mysten/hashi test:integration + + - name: Stop bitcoind block heartbeat + if: always() + run: | + if [[ -f /tmp/btc-heartbeat.pid ]]; then + kill "$(cat /tmp/btc-heartbeat.pid)" 2>/dev/null || true + fi + + - name: Upload localnet logs and state on failure + if: failure() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # pin@4.4.3 + with: + name: localnet-logs + path: | + ${{ env.LOCALNET_LOG }} + ${{ env.LOCALNET_DATA_DIR }}/state.json + ${{ env.LOCALNET_DATA_DIR }}/hashi-cli.toml + if-no-files-found: ignore + + - name: Stop hashi-localnet + if: always() + run: | + hashi/target/release/hashi-localnet stop \ + --data-dir "$LOCALNET_DATA_DIR" || true From 0876689a66c1f7da892b4464dc11c9f16a47ac20 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 12:02:59 +0300 Subject: [PATCH 03/13] Mark hashi generated contracts as linguist-generated Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 506704ff7..2843bfe7f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,4 @@ packages/deepbook-v3/src/contracts/** linguist-generated=true packages/payment-kit/src/contracts/** linguist-generated=true packages/kiosk/src/contracts/** linguist-generated=true packages/pas/src/contracts/** linguist-generated=true +packages/hashi/src/contracts/** linguist-generated=true From b68aede9729b268794e287cc7f1d858c9b262c5d Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 12:06:04 +0300 Subject: [PATCH 04/13] Restore @mysten-incubation/hashi name in v0.0.2 changelog entry Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/hashi/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hashi/CHANGELOG.md b/packages/hashi/CHANGELOG.md index fb2d6149d..897b118a4 100644 --- a/packages/hashi/CHANGELOG.md +++ b/packages/hashi/CHANGELOG.md @@ -55,4 +55,4 @@ ### Patch Changes -- 9422708: Add a package-level `README.md` so the npm landing page has a real overview (install, one quickstart snippet, link to the repo README for full docs). Also corrects stale `@mysten/hashi` references in the root README to the actual published name `@mysten/hashi`. +- 9422708: Add a package-level `README.md` so the npm landing page has a real overview (install, one quickstart snippet, link to the repo README for full docs). Also corrects stale `@mysten/hashi` references in the root README to the actual published name `@mysten-incubation/hashi`. From 5922f2b99bf9747a03c2413655d29ae2c8769e40 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 12:07:50 +0300 Subject: [PATCH 05/13] Fix stale submodule reference in hashi integration test comment Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/hashi/test/integration/_env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hashi/test/integration/_env.ts b/packages/hashi/test/integration/_env.ts index 55a05a5bb..16fd84cc8 100644 --- a/packages/hashi/test/integration/_env.ts +++ b/packages/hashi/test/integration/_env.ts @@ -108,7 +108,7 @@ export function makeSigner(): Ed25519Keypair { } /** - * Invokes the `hashi-localnet` Rust CLI from the submodule. The CI workflow + * Invokes the `hashi-localnet` Rust CLI from the hashi repo checkout. The CI workflow * sets `HASHI_E2E_LOCALNET_BIN` to its absolute path and `HASHI_E2E_LOCALNET_DATA_DIR` * to the state dir; we always inject `--data-dir` so callers can't forget it. * From 460a075d012729448b616d040de350dd58160151 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 12:15:52 +0300 Subject: [PATCH 06/13] Pin and checksum-verify Bitcoin Core in hashi CI Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/hashi-ci.yml | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/.github/workflows/hashi-ci.yml b/.github/workflows/hashi-ci.yml index a0b5287b6..1a01b40b8 100644 --- a/.github/workflows/hashi-ci.yml +++ b/.github/workflows/hashi-ci.yml @@ -87,29 +87,21 @@ jobs: run: bash hashi/.github/scripts/install-sui.sh # Mirrors the "Install Bitcoin Core" step in - # hashi/.github/workflows/ci.yml. Vendored verbatim because hashi-core - # has it inline (no install-bitcoin.sh to reuse). Revisit if hashi - # extracts a script. + # hashi/.github/workflows/ci.yml, but pinned rather than tracking the + # latest upstream release: an unpinned bitcoind would let a new Bitcoin + # Core release change what these tests run against with no PR, and a + # floating version can't be checksum-verified. Bump both values together + # (checksums: https://bitcoincore.org/bin/bitcoin-core-/SHA256SUMS). - name: Install Bitcoin Core env: - FALLBACK_VERSION: '29.1' + BITCOIN_VERSION: '29.1' + BITCOIN_SHA256: '2dddeaa8c0626ec446b6f21b64c0f3565a1e7e67ff0b586d25043cbd686c9455' run: | - echo "Detecting latest Bitcoin Core version..." - BITCOIN_VERSION=$(curl -s https://api.github.com/repos/bitcoin/bitcoin/releases/latest | \ - sed -n 's/.*"tag_name": "v\([^"]*\)".*/\1/p' || echo "$FALLBACK_VERSION") - - if [ -z "$BITCOIN_VERSION" ]; then - echo "Failed to detect version, using fallback: $FALLBACK_VERSION" - BITCOIN_VERSION="$FALLBACK_VERSION" - fi - echo "Installing Bitcoin Core v${BITCOIN_VERSION}..." - wget -q https://bitcoincore.org/bin/bitcoin-core-${BITCOIN_VERSION}/bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz || { - echo "Download failed for ${BITCOIN_VERSION}; falling back to ${FALLBACK_VERSION}" - BITCOIN_VERSION="$FALLBACK_VERSION" - wget -q https://bitcoincore.org/bin/bitcoin-core-${BITCOIN_VERSION}/bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz - } + wget -q https://bitcoincore.org/bin/bitcoin-core-${BITCOIN_VERSION}/bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz + + echo "${BITCOIN_SHA256} bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz" | sha256sum -c - tar -xzf bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz sudo mv bitcoin-${BITCOIN_VERSION}/bin/bitcoind /usr/local/bin/ From d9bd8688bcafbbb8bd690c796ec17f8935e8cbc7 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 12:36:47 +0300 Subject: [PATCH 07/13] Default HashiClientOptions Name to string instead of a wrong literal Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/hashi/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hashi/src/types.ts b/packages/hashi/src/types.ts index aee336a62..2298bb6aa 100644 --- a/packages/hashi/src/types.ts +++ b/packages/hashi/src/types.ts @@ -11,7 +11,7 @@ export interface NetworkConfig { bitcoinNetwork: BitcoinNetwork; } -export interface HashiClientOptions { +export interface HashiClientOptions { name?: Name; /** Sui network — determines Hashi object IDs and default Bitcoin network. */ network: SuiNetwork; From 38b3d35a1814a7f70837360019ad424ec56cea7d Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 12:44:52 +0300 Subject: [PATCH 08/13] Make rpcCall generic instead of casting at call sites Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/hashi/src/btc-rpc.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/hashi/src/btc-rpc.ts b/packages/hashi/src/btc-rpc.ts index ef6025909..44fb2060d 100644 --- a/packages/hashi/src/btc-rpc.ts +++ b/packages/hashi/src/btc-rpc.ts @@ -3,20 +3,20 @@ import type { UtxoLookupResult } from './types.js'; -async function rpcCall( +async function rpcCall( url: string, method: string, params: unknown[], id: string, -): Promise { +): Promise { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '1.0', id, method, params }), }); - const data = (await res.json()) as { error?: { message: string }; result?: unknown }; + const data = (await res.json()) as { error?: { message: string }; result?: T }; if (data.error) throw new Error(data.error.message); - return data.result; + return data.result as T; } export async function lookupVout( @@ -24,9 +24,9 @@ export async function lookupVout( txid: string, depositAddress: string, ): Promise { - const tx = (await rpcCall(btcRpcUrl, 'getrawtransaction', [txid, true], 'hashi-lookup-vout')) as { + const tx = await rpcCall<{ vout: Array<{ n: number; value: number; scriptPubKey?: { address?: string } }>; - }; + }>(btcRpcUrl, 'getrawtransaction', [txid, true], 'hashi-lookup-vout'); for (const output of tx.vout) { if (output.scriptPubKey?.address === depositAddress) { return { vout: output.n, amountSats: BigInt(Math.round(output.value * 1e8)) }; @@ -40,9 +40,9 @@ export async function lookupAllVouts( txid: string, depositAddress: string, ): Promise { - const tx = (await rpcCall(btcRpcUrl, 'getrawtransaction', [txid, true], 'hashi-lookup-all')) as { + const tx = await rpcCall<{ vout: Array<{ n: number; value: number; scriptPubKey?: { address?: string } }>; - }; + }>(btcRpcUrl, 'getrawtransaction', [txid, true], 'hashi-lookup-all'); const matches: UtxoLookupResult[] = []; for (const output of tx.vout) { if (output.scriptPubKey?.address === depositAddress) { @@ -53,13 +53,11 @@ export async function lookupAllVouts( } export async function getTxConfirmations(btcRpcUrl: string, txid: string): Promise { - const tx = (await rpcCall( + const tx = await rpcCall<{ confirmations?: number }>( btcRpcUrl, 'getrawtransaction', [txid, true], 'hashi-confirmations', - )) as { - confirmations?: number; - }; + ); return Number(tx?.confirmations ?? 0); } From 3bf25ce856f4a89b1dc84ea2de0544ee5f9ae0a1 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 13:13:24 +0300 Subject: [PATCH 09/13] Derive network from the extended client instead of a hashi() option Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/hashi-network-from-client.md | 7 +++++++ packages/hashi/README.md | 12 +++++------- packages/hashi/src/client.ts | 9 ++++----- packages/hashi/src/types.ts | 2 -- packages/hashi/test/integration/_env.ts | 1 - packages/hashi/test/unit/client.test.ts | 9 +++------ 6 files changed, 19 insertions(+), 21 deletions(-) create mode 100644 .changeset/hashi-network-from-client.md diff --git a/.changeset/hashi-network-from-client.md b/.changeset/hashi-network-from-client.md new file mode 100644 index 000000000..fe2cc8318 --- /dev/null +++ b/.changeset/hashi-network-from-client.md @@ -0,0 +1,7 @@ +--- +'@mysten/hashi': major +--- + +`hashi()` no longer accepts a `network` option — it's now derived from the Sui client being +extended (`hashi()` instead of `hashi({ network: "testnet" })`), and throws if that client's +network has no `NETWORK_CONFIG` entry and no custom `hashiObjectId`/`packageId` were provided. diff --git a/packages/hashi/README.md b/packages/hashi/README.md index dd7a72669..2ed6593eb 100644 --- a/packages/hashi/README.md +++ b/packages/hashi/README.md @@ -36,15 +36,15 @@ import { hashi } from '@mysten/hashi'; const client = new SuiGrpcClient({ network: 'testnet', baseUrl: 'https://fullnode.testnet.sui.io:443', -}).$extend(hashi({ network: 'testnet' })); +}).$extend(hashi()); const signer = Ed25519Keypair.fromSecretKey(/* … */); ``` > **Network support.** Sui **testnet** and **devnet** are wired up (Bitcoin **signet** by default). > Prefer testnet — devnet support is temporary and will be deprecated. Mainnet is not yet deployed; -> `hashi({ network: "mainnet" })` will throw until it lands. To target a custom or local deployment, -> pass `hashiObjectId`, `packageId`, and `bitcoinNetwork` explicitly. +> `hashi()` will throw until it lands, based on the network of the client it's extending. To target +> a custom or local deployment, pass `hashiObjectId`, `packageId`, and `bitcoinNetwork` explicitly. > **Optional client options.** `hashi({ ... })` also accepts `btcRpcUrl` — a Bitcoin Core JSON-RPC > URL, required for the [`client.hashi.bitcoin.*`](#bitcoin-rpc-optional) lookups — and @@ -269,7 +269,7 @@ and for checking confirmations before submitting a deposit. const client = new SuiGrpcClient({ network: 'testnet', baseUrl: 'https://fullnode.testnet.sui.io:443', -}).$extend(hashi({ network: 'testnet', btcRpcUrl: 'http://user:pass@127.0.0.1:8332' })); +}).$extend(hashi({ btcRpcUrl: 'http://user:pass@127.0.0.1:8332' })); // Which output(s) of the funding tx paid the deposit address? const output = await client.hashi.bitcoin.lookupVout(btcTxid, btcAddress); @@ -294,9 +294,7 @@ headroom. const client = new SuiGrpcClient({ network: 'devnet', baseUrl: 'https://fullnode.devnet.sui.io:443', -}).$extend( - hashi({ network: 'devnet', guardianUrl: 'https://hashi-guardian-devnet.mystenlabs.com' }), -); +}).$extend(hashi({ guardianUrl: 'https://hashi-guardian-devnet.mystenlabs.com' })); // Guardian identity + limiter. `limiter` is null before the guardian is provisioned. const info = await client.hashi.guardian.info(); diff --git a/packages/hashi/src/client.ts b/packages/hashi/src/client.ts index 6871ab3f2..77fecd9d7 100644 --- a/packages/hashi/src/client.ts +++ b/packages/hashi/src/client.ts @@ -120,7 +120,7 @@ function isObjectNotFoundError(err: Error): boolean { export function hashi({ name = 'hashi' as Name, ...options -}: HashiClientOptions) { +}: HashiClientOptions = {}) { return { name, register: (client: ClientWithCoreApi) => { @@ -134,7 +134,7 @@ export function hashi({ * `hashi({...})` factory and attached to any Sui client via `$extend`: * * ```ts - * const client = new SuiGrpcClient({ ... }).$extend(hashi({ network: "devnet" })); + * const client = new SuiGrpcClient({ network: "devnet", ... }).$extend(hashi()); * const result = await client.hashi.deposit({ signer, ... }); * ``` * @@ -160,7 +160,6 @@ export class HashiClient { constructor({ client, - network, hashiObjectId, packageId, bitcoinNetwork, @@ -170,7 +169,6 @@ export class HashiClient { guardianInfoProvider, }: { client: ClientWithCoreApi; - network: SuiNetwork; hashiObjectId?: string; packageId?: string; bitcoinNetwork?: BitcoinNetwork; @@ -179,7 +177,8 @@ export class HashiClient { guardianUrl?: string; guardianInfoProvider?: GuardianInfoProvider; }) { - const config = NETWORK_CONFIG[network]; + const network = client.network; + const config = NETWORK_CONFIG[network as SuiNetwork]; const resolvedObjectId = hashiObjectId ?? config?.hashiObjectId; const resolvedPackageId = packageId ?? config?.packageId; if (!resolvedObjectId || !resolvedPackageId) { diff --git a/packages/hashi/src/types.ts b/packages/hashi/src/types.ts index 2298bb6aa..b925d99a1 100644 --- a/packages/hashi/src/types.ts +++ b/packages/hashi/src/types.ts @@ -13,8 +13,6 @@ export interface NetworkConfig { export interface HashiClientOptions { name?: Name; - /** Sui network — determines Hashi object IDs and default Bitcoin network. */ - network: SuiNetwork; /** Override the auto-resolved Hashi shared object ID (for custom/local deployments). */ hashiObjectId?: string; /** Override the auto-resolved Hashi package ID (for custom/local deployments). */ diff --git a/packages/hashi/test/integration/_env.ts b/packages/hashi/test/integration/_env.ts index 16fd84cc8..2e299a2e9 100644 --- a/packages/hashi/test/integration/_env.ts +++ b/packages/hashi/test/integration/_env.ts @@ -65,7 +65,6 @@ export function makeClient(): ExtendedHashiClient { const cfg = resolveClientConfig(); return new SuiGrpcClient({ network: cfg.network, baseUrl: cfg.rpcUrl }).$extend( hashi({ - network: cfg.network, packageId: cfg.packageId, hashiObjectId: cfg.hashiObjectId, bitcoinNetwork: cfg.bitcoinNetwork, diff --git a/packages/hashi/test/unit/client.test.ts b/packages/hashi/test/unit/client.test.ts index 1a71a1a1e..565bec936 100644 --- a/packages/hashi/test/unit/client.test.ts +++ b/packages/hashi/test/unit/client.test.ts @@ -130,7 +130,6 @@ describe('HashiClient', () => { baseUrl: 'https://fullnode.devnet.sui.io:443', }).$extend( hashi({ - network: 'devnet', hashiObjectId: HASHI_OBJECT_ID, packageId: PACKAGE_ID, bitcoinNetwork: 'regtest', @@ -287,7 +286,7 @@ describe('HashiClient', () => { const devnetClient = new SuiGrpcClient({ network: 'devnet', baseUrl: 'https://fullnode.devnet.sui.io:443', - }).$extend(hashi({ network: 'devnet' })); + }).$extend(hashi()); const suiAddress = '0xe40c8cf8b53822829b3a6dc9aea84b62653f60b771e9da4bd4e214cae851b87b'; @@ -757,7 +756,7 @@ describe('HashiClient', () => { new SuiGrpcClient({ network: 'testnet', baseUrl: 'https://fullnode.testnet.sui.io:443', - }).$extend(hashi({ network: 'testnet' })), + }).$extend(hashi()), ).not.toThrow(); }); @@ -766,7 +765,7 @@ describe('HashiClient', () => { new SuiGrpcClient({ network: 'mainnet', baseUrl: 'https://fullnode.mainnet.sui.io:443', - }).$extend(hashi({ network: 'mainnet' })), + }).$extend(hashi()), ).toThrow('not yet supported on Sui mainnet'); }); @@ -777,7 +776,6 @@ describe('HashiClient', () => { baseUrl: 'https://fullnode.mainnet.sui.io:443', }).$extend( hashi({ - network: 'mainnet', hashiObjectId: HASHI_OBJECT_ID, packageId: PACKAGE_ID, }), @@ -2165,7 +2163,6 @@ describe('HashiClient guardian', () => { baseUrl: 'https://fullnode.devnet.sui.io:443', }).$extend( hashi({ - network: 'devnet', hashiObjectId: HASHI_OBJECT_ID, packageId: PACKAGE_ID, bitcoinNetwork: 'regtest', From 82cd7467946601e72a6fde57eba61fee8bebdd93 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 13:27:57 +0300 Subject: [PATCH 10/13] Use tx.balance instead of hand-composed coinWithBalance + into_balance Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/hashi/src/client.ts | 40 +++++++------------------ packages/hashi/test/unit/client.test.ts | 16 ++++++---- 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/packages/hashi/src/client.ts b/packages/hashi/src/client.ts index 77fecd9d7..33928d66d 100644 --- a/packages/hashi/src/client.ts +++ b/packages/hashi/src/client.ts @@ -6,7 +6,7 @@ import type { Signer } from '@mysten/sui/cryptography'; import { bcs, TypeTagSerializer } from '@mysten/sui/bcs'; import { fromHex, deriveDynamicFieldID, normalizeSuiAddress } from '@mysten/sui/utils'; import { base58 } from '@scure/base'; -import { Transaction, coinWithBalance } from '@mysten/sui/transactions'; +import { Transaction } from '@mysten/sui/transactions'; import { Hashi } from './contracts/hashi/hashi.js'; import { BitcoinState, BitcoinStateKey } from './contracts/hashi/bitcoin_state.js'; import { DepositRequest } from './contracts/hashi/deposit_queue.js'; @@ -510,10 +510,10 @@ export class HashiClient { }, /** - * Build a transaction that submits a BTC withdrawal request. Sources the - * BTC via `coinWithBalance`, unwraps it into a `Balance`, and passes - * it to `withdraw::request_withdrawal` along with the target Bitcoin - * output address. + * Build a transaction that submits a BTC withdrawal request. Sources a + * `Balance` via `tx.balance` and passes it to + * `withdraw::request_withdrawal` along with the target Bitcoin output + * address. */ requestWithdrawal: (options: { /** Amount in sats to withdraw. Must be ≥ the on-chain withdrawal minimum. */ @@ -526,20 +526,9 @@ export class HashiClient { bitcoinAddress: Uint8Array; }): Transaction => { const tx = new Transaction(); - const btcType = `${this.#packageId}::btc::BTC`; - const coin = tx.add( - coinWithBalance({ - type: btcType, - balance: options.amount, - useGasCoin: false, - }), - ); - const [balance] = tx.moveCall({ - package: '0x2', - module: 'coin', - function: 'into_balance', - typeArguments: [btcType], - arguments: [coin], + const balance = tx.balance({ + type: `${this.#packageId}::btc::BTC`, + balance: options.amount, }); tx.add( this.call.requestWithdrawal({ @@ -1040,17 +1029,10 @@ export class HashiClient { let gasEstimateMist = 0n; if (sender) { const dummyAmount = snap.bitcoinWithdrawalMinimum + 1n; - const btcType = `${this.#packageId}::btc::BTC`; const tx = new Transaction(); - const coin = tx.add( - coinWithBalance({ type: btcType, balance: dummyAmount, useGasCoin: false }), - ); - const [balance] = tx.moveCall({ - package: '0x2', - module: 'coin', - function: 'into_balance', - typeArguments: [btcType], - arguments: [coin], + const balance = tx.balance({ + type: `${this.#packageId}::btc::BTC`, + balance: dummyAmount, }); tx.add( this.call.requestWithdrawal({ diff --git a/packages/hashi/test/unit/client.test.ts b/packages/hashi/test/unit/client.test.ts index 565bec936..680cae138 100644 --- a/packages/hashi/test/unit/client.test.ts +++ b/packages/hashi/test/unit/client.test.ts @@ -2098,19 +2098,25 @@ describe('HashiClient', () => { }); describe('requestWithdrawal', () => { - it('composes coinWithBalance + into_balance + request_withdrawal', () => { + it('composes a Balance intent + request_withdrawal', () => { const tx = client.hashi.tx.requestWithdrawal({ amount: 50_000n, bitcoinAddress: new Uint8Array(32), }); expect(tx).toBeInstanceOf(Transaction); + // `tx.balance` emits a CoinWithBalance intent with a `balance` + // output kind; the `0x2::coin::into_balance` conversion is only + // inserted when the intent is resolved at build time. const { commands } = tx.getData(); - const moveCalls = commands.filter((c) => c.$kind === 'MoveCall'); - - const intoBalance = moveCalls.find((c) => c.MoveCall?.function === 'into_balance'); - expect(intoBalance?.MoveCall?.typeArguments).toEqual([`${PACKAGE_ID}::btc::BTC`]); + const intent = commands.find((c) => c.$kind === '$Intent'); + expect(intent?.$Intent?.name).toBe('CoinWithBalance'); + expect(intent?.$Intent?.data).toMatchObject({ + type: `${PACKAGE_ID}::btc::BTC`, + outputKind: 'balance', + }); + const moveCalls = commands.filter((c) => c.$kind === 'MoveCall'); expect(moveCalls.some((c) => c.MoveCall?.function === 'request_withdrawal')).toBe(true); }); }); From 0942662b0ce97519116a56c98e5067bc4ff6fc70 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 14:02:32 +0300 Subject: [PATCH 11/13] Drop manual getObjects batching and use SuiGraphQLClient for event queries Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/hashi/src/client.ts | 110 ++++++++++++------------ packages/hashi/test/unit/client.test.ts | 28 +++--- 2 files changed, 67 insertions(+), 71 deletions(-) diff --git a/packages/hashi/src/client.ts b/packages/hashi/src/client.ts index 33928d66d..618e76873 100644 --- a/packages/hashi/src/client.ts +++ b/packages/hashi/src/client.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; +import type { GraphQLQueryResult } from '@mysten/sui/graphql'; +import { SuiGraphQLClient } from '@mysten/sui/graphql'; import type { Signer } from '@mysten/sui/cryptography'; import { bcs, TypeTagSerializer } from '@mysten/sui/bcs'; import { fromHex, deriveDynamicFieldID, normalizeSuiAddress } from '@mysten/sui/utils'; @@ -76,8 +78,30 @@ const OBJECT_BAG_ADDRESS_TYPE = /** Max value of an unsigned 32-bit integer; vout is a u32 on the Bitcoin side. */ const U32_MAX = 0xffffffff; -/** Max objects per `getObjects` call. */ -const GET_OBJECTS_BATCH = 500; +/** Events of a type emitted by a sender, paginated; used by `#queryEventRequestIds`. */ +const EVENT_REQUEST_IDS_QUERY = ` + query EventRequestIds($sender: SuiAddress!, $type: String!, $after: String) { + events(filter: { sender: $sender, type: $type }, first: 50, after: $after) { + nodes { + contents { + json + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +/** Result shape of {@link EVENT_REQUEST_IDS_QUERY}. */ +interface EventRequestIdsResult { + events?: { + nodes: { contents: { json: { request_id: string } } }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + } | null; +} const GRAPHQL_URLS: Record = { devnet: 'https://fullnode.devnet.sui.io:443/graphql', @@ -150,7 +174,7 @@ export class HashiClient { #packageId: string; #bitcoinNetwork: BitcoinNetwork; #btcRpcUrl: string | undefined; - #graphqlUrl: string; + #graphql: SuiGraphQLClient; #guardianUrl: string | undefined; #guardianInfoProvider: GuardianInfoProvider | undefined; // A URL resolved from the on-chain `guardian_url`, cached once found. Stays @@ -191,7 +215,10 @@ export class HashiClient { this.#packageId = resolvedPackageId; this.#bitcoinNetwork = bitcoinNetwork ?? config?.bitcoinNetwork ?? 'testnet'; this.#btcRpcUrl = btcRpcUrl; - this.#graphqlUrl = graphqlUrl ?? defaultGraphqlUrl(network); + this.#graphql = new SuiGraphQLClient({ + url: graphqlUrl ?? defaultGraphqlUrl(network), + network, + }); this.#guardianUrl = guardianUrl; this.#guardianInfoProvider = guardianInfoProvider; } @@ -766,8 +793,9 @@ export class HashiClient { ); } - // Batch-fetch — existence test only, no content needed. - const objects = await this.#batchGetObjects(fieldIds); + // Existence test only, no content needed. `core.getObjects` + // batches internally, so any number of ids is fine. + const { objects } = await this.#client.core.getObjects({ objectIds: fieldIds }); if (objects.length !== fieldIds.length) { throw new HashiFetchError( @@ -1074,7 +1102,10 @@ export class HashiClient { if (userBagId !== null) { const requestIds = await this.#listAllDynamicFieldAddressKeys(userBagId); if (requestIds.length > 0) { - const objects = await this.#batchGetObjects(requestIds, { content: true }); + const { objects } = await this.#client.core.getObjects({ + objectIds: requestIds, + include: { content: true }, + }); const classified = this.#classifyRequestObjects(objects, timeDelayMs); items.push(...classified.items); await this.#populateWithdrawalBtcTxids(items, classified.withdrawalTxnLookups); @@ -1091,7 +1122,10 @@ export class HashiClient { const pendingIds = allDepositIds.filter((id) => !confirmedIds.has(id)); if (pendingIds.length > 0) { - const objects = await this.#batchGetObjects(pendingIds, { content: true }); + const { objects } = await this.#client.core.getObjects({ + objectIds: pendingIds, + include: { content: true }, + }); const classified = this.#classifyRequestObjects(objects, timeDelayMs); items.push(...classified.items); } @@ -1411,24 +1445,6 @@ export class HashiClient { return keys; } - /** - * Batch-fetches objects in chunks of `GET_OBJECTS_BATCH`, concatenating - * the results. Each element is either an object or an `Error` (for - * missing / deleted objects). - */ - async #batchGetObjects(objectIds: string[], include?: { content?: boolean }) { - const results: (SuiClientTypes.Object<{ content: true }> | Error)[] = []; - for (let i = 0; i < objectIds.length; i += GET_OBJECTS_BATCH) { - const batch = objectIds.slice(i, i + GET_OBJECTS_BATCH); - const { objects } = await this.#client.core.getObjects({ - objectIds: batch, - include: include as { content: true }, - }); - results.push(...objects); - } - return results; - } - /** * Query the Sui GraphQL endpoint for events of a given type emitted by * a sender. Returns the `request_id` from each event's JSON payload, @@ -1440,37 +1456,15 @@ export class HashiClient { let hasMore = true; while (hasMore) { - const afterClause = cursor ? `, after: "${cursor}"` : ''; - const query = `{ - events( - filter: { sender: "${sender}", type: "${eventType}" } - first: 50${afterClause} - ) { - nodes { contents { json } } - pageInfo { hasNextPage endCursor } - } - }`; - - const res = await fetch(this.#graphqlUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), + const result: GraphQLQueryResult = await this.#graphql.query({ + query: EVENT_REQUEST_IDS_QUERY, + variables: { sender, type: eventType, after: cursor }, }); - if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`); - - const body = (await res.json()) as { - data?: { - events?: { - nodes: { contents: { json: { request_id: string } } }[]; - pageInfo: { hasNextPage: boolean; endCursor: string | null }; - }; - }; - errors?: { message: string }[]; - }; - if (body.errors?.length) { - throw new Error(`GraphQL error: ${body.errors[0].message}`); + const { data, errors } = result; + if (errors?.length) { + throw new Error(`GraphQL error: ${errors[0].message}`); } - const events = body.data?.events; + const events = data?.events; if (!events) break; for (const node of events.nodes) { @@ -1531,8 +1525,10 @@ export class HashiClient { lookups: readonly { itemIndex: number; txnId: string }[], ): Promise { if (lookups.length === 0) return; - const txnIds = lookups.map((l) => l.txnId); - const txnObjects = await this.#batchGetObjects(txnIds, { content: true }); + const { objects: txnObjects } = await this.#client.core.getObjects({ + objectIds: lookups.map((l) => l.txnId), + include: { content: true }, + }); for (let i = 0; i < lookups.length; i++) { const txnObj = txnObjects[i]; if (txnObj instanceof Error) continue; diff --git a/packages/hashi/test/unit/client.test.ts b/packages/hashi/test/unit/client.test.ts index 680cae138..5e7a26cfd 100644 --- a/packages/hashi/test/unit/client.test.ts +++ b/packages/hashi/test/unit/client.test.ts @@ -23,6 +23,7 @@ import { Bag } from '../../src/contracts/hashi/deps/sui/bag.js'; import { generateDepositAddress } from '../../src/bitcoin.js'; import { reverseTxidBytes } from '../../src/util.js'; import { SuiGrpcClient } from '@mysten/sui/grpc'; +import { SuiGraphQLClient } from '@mysten/sui/graphql'; import { bcs } from '@mysten/sui/bcs'; import { Transaction } from '@mysten/sui/transactions'; import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'; @@ -1269,20 +1270,19 @@ describe('HashiClient', () => { } function mockGraphQLDepositEvents(depositIds: string[]) { - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - events: { - nodes: depositIds.map((id) => ({ - contents: { json: { request_id: id } }, - })), - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - ), - ); + // The events lookup goes through the HashiClient's internal + // SuiGraphQLClient, which captures `fetch` at construction — so mock + // at the query boundary rather than on `globalThis.fetch`. + vi.spyOn(SuiGraphQLClient.prototype, 'query').mockResolvedValueOnce({ + data: { + events: { + nodes: depositIds.map((id) => ({ + contents: { json: { request_id: id } }, + })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + } as never); } function mockDepositRequestObject() { From a01838b6a76e1d650da0ece6da2f2fd9fbed3357 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 14:46:16 +0300 Subject: [PATCH 12/13] Reuse tx builders in gas estimators instead of duplicating PTBs Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/hashi/src/client.ts | 37 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/hashi/src/client.ts b/packages/hashi/src/client.ts index 618e76873..1ce380ee3 100644 --- a/packages/hashi/src/client.ts +++ b/packages/hashi/src/client.ts @@ -1027,21 +1027,13 @@ export class HashiClient { depositGasEstimate: async (sender: string): Promise => { const snap = await this.view.all(); const dummyAmount = snap.bitcoinDepositMinimum + 1n; - const dummyTxid = '0x' + '01'.repeat(32); - const tx = new Transaction(); - const utxoId = tx.add( - utxoModule.utxoId({ - package: this.#packageId, - arguments: { txid: dummyTxid, vout: 0 }, - }), - ); - const utxo = tx.add( - utxoModule.utxo({ - package: this.#packageId, - arguments: { utxoId, amount: dummyAmount, derivationPath: sender }, - }), - ); - tx.add(this.call.deposit({ utxo })); + // Reuse the real builder with dummy values so the dry-run transaction + // can never drift out of sync with what users actually sign. + const tx = this.tx.deposit({ + txid: '0x' + '01'.repeat(32), + utxos: [{ vout: 0, amountSats: dummyAmount }], + recipient: sender, + }); tx.setSender(sender); return { gasEstimateMist: await this.#estimateGas(tx) }; }, @@ -1057,17 +1049,12 @@ export class HashiClient { let gasEstimateMist = 0n; if (sender) { const dummyAmount = snap.bitcoinWithdrawalMinimum + 1n; - const tx = new Transaction(); - const balance = tx.balance({ - type: `${this.#packageId}::btc::BTC`, - balance: dummyAmount, + // Reuse the real builder with dummy values so the dry-run + // transaction can never drift out of sync with what users sign. + const tx = this.tx.requestWithdrawal({ + amount: dummyAmount, + bitcoinAddress: new Uint8Array(20), }); - tx.add( - this.call.requestWithdrawal({ - btc: balance, - bitcoinAddress: Array(20).fill(0), - }), - ); tx.setSender(sender); gasEstimateMist = await this.#estimateGas(tx); } From 89d17d8881779df5f803f5afc03841528a7d6748 Mon Sep 17 00:00:00 2001 From: Alexandros Tzimas Date: Mon, 20 Jul 2026 14:52:57 +0300 Subject: [PATCH 13/13] Downgrade network-from-client changeset to minor for a 0.6.0 first release Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/hashi-network-from-client.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/hashi-network-from-client.md b/.changeset/hashi-network-from-client.md index fe2cc8318..bd6408994 100644 --- a/.changeset/hashi-network-from-client.md +++ b/.changeset/hashi-network-from-client.md @@ -1,7 +1,7 @@ --- -'@mysten/hashi': major +'@mysten/hashi': minor --- -`hashi()` no longer accepts a `network` option — it's now derived from the Sui client being +**Breaking:** `hashi()` no longer accepts a `network` option — it's now derived from the Sui client being extended (`hashi()` instead of `hashi({ network: "testnet" })`), and throws if that client's network has no `NETWORK_CONFIG` entry and no custom `hashiObjectId`/`packageId` were provided.