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