Skip to main content

Withdraw

A withdrawal allows a user to redeem their hBTC on Sui for native BTC, sent to a user-specified address on Bitcoin. The process has five phases:

Request​

A user submits an onchain Sui transaction to request a withdrawal by calling hashi::withdraw::request_withdrawal:

entry fun request_withdrawal(
hashi: &mut Hashi,
clock: &Clock,
btc: Balance<BTC>,
bitcoin_address: vector<u8>,
ctx: &mut TxContext,
)

It is a private entry function, so it is called directly from a programmable transaction block (PTB) rather than from other Move packages. The Balance<BTC> can come from earlier commands in the same PTB, such as 0x2::balance::redeem_funds on the sender's address balance.

  • btc: the hBTC to withdraw. Must be at least the bitcoin_withdrawal_minimum.
  • bitcoin_address: the destination witness program, either P2WPKH (20 bytes) or P2TR (32 bytes). A 32-byte program is always paid as P2TR, so P2WSH destinations aren't supported.

The protocol enforces a minimum withdrawal amount to guarantee that every request can produce a valid Bitcoin transaction even under worst-case fee conditions. This minimum covers the worst-case miner fee (which pays for both the user's transaction weight and opportunistic UTXO pool consolidation) and the dust threshold for the user's output. See Fees for the full breakdown.

The full hBTC amount is stored with the request and placed in a queue with a timestamp. The user can cancel their request at any point before it has been committed to a withdrawal transaction, subject to a cooldown period (withdrawal_cancellation_cooldown_ms). To cancel, the requester calls the private entry function hashi::withdraw::cancel_withdrawal, which returns the refunded Balance<BTC> for a later command in the same PTB, such as 0x2::balance::send_funds back to the sender.

Approve​

On Bitcoin mainnet, each committee member with a TRM Labs API key independently screens the Bitcoin destination address and the requesting Sui address (see Handling Sanctioned Addresses); other members approve without screening. A member that rejects the request does not vote to approve it. If a quorum still approves, all members must assist in completing the withdrawal.

Committee members communicate with each other to collect signatures to approve the request. After a quorum is reached, one member submits the certificate onchain by calling hashi::withdraw::approve_request:

entry fun approve_request(
hashi: &mut Hashi,
request_id: address,
cert: CommitteeSignature,
clock: &Clock,
)

The function verifies the committee certificate and marks the request as approved, allowing it to be picked up for transaction construction.

Build TX​

The leader batches multiple approved requests into a single Bitcoin transaction to amortize the fixed transaction overhead across users. The leader waits for approved requests to buffer up before constructing a transaction, until a request has been waiting for roughly 5 minutes since it was submitted, a threshold number of approved requests have accumulated, or the rate limiter can't fit the next request, whichever comes first. This balances latency for individual users against efficiency for the protocol. The leader builds one batch at a time: it commits the next only after the previous one is fully signed.

The leader takes approved requests oldest first and stops at the first one that doesn't fit the current capacity of the Guardian's token-bucket rate limiter, as tracked by the leader's own copy of it. That request and every request behind it wait until capacity is replenished. A request larger than the bucket's maximum capacity is skipped every time, so it is never processed unless the limit is raised; its owner can cancel it.

The leader then constructs an unsigned Bitcoin transaction:

  1. Coin selection: select UTXOs from the pool to cover the total withdrawal amount (miner fees are taken from the user's withdrawn amount). The coin selector might include extra inputs to opportunistically consolidate small UTXOs (see Fees).
  2. Outputs: one output per withdrawal request to each user's destination address, plus, when the inputs exceed the requested total, one change output back to a Hashi-controlled address.
  3. Fee calculation: the miner fee is estimated through estimatesmartfee, floored at 3 sat/vB. Each user's share of the miner fee is deducted from their output. Validators independently verify that the fee is within acceptable bounds (at least 1 sat/vB, at most 3x their own estimate).

The resulting unsigned transaction and its txid are committed onchain by calling hashi::withdraw::commit_withdrawal_tx:

entry fun commit_withdrawal_tx(
hashi: &mut Hashi,
request_ids: vector<address>,
selected_utxos: vector<UtxoId>,
outputs: vector<OutputUtxo>,
txid: address,
cert: CommitteeSignature,
clock: &Clock,
r: &Random,
ctx: &mut TxContext,
)

The function verifies the committee certificate, burns the hBTC from each request, locks the selected UTXOs in the pool, adds the change outputs to the pool as pending UTXOs, and creates a pending withdrawal record that all validators can independently verify.

Sign​

Signing is a two-step process matching the 2-of-2 Taproot script (see Bitcoin Address Scheme):

  1. MPC signature: the committee runs the threshold Schnorr signing protocol (see MPC Protocol) to produce a signature for each input.
  2. Guardian signature: once every input has an MPC signature, the unsigned transaction and metadata are sent, with a committee certificate, to the Guardian enclave, which checks the certificate and the request's timestamp, debits its rate limiter, and produces the second signature for each input.

Both signatures are combined into the taproot script-path witness for each input.

The MPC signatures are committed onchain in chunks, each under its own committee certificate, by calling hashi::withdraw::commit_input_signatures:

entry fun commit_input_signatures(
hashi: &mut Hashi,
withdrawal_id: address,
indices: vector<u64>,
signatures: vector<vector<u8>>,
cert: CommitteeSignature,
)

If a reconfiguration lands while a withdrawal is part-way through signing, its still-unsigned inputs get fresh presignatures from the new committee (hashi::withdraw::reallocate_presigs); signatures already recorded are kept.

The Guardian signatures are then attached by calling hashi::withdraw::finalize_withdrawal:

entry fun finalize_withdrawal(
hashi: &mut Hashi,
withdrawal_id: address,
guardian_signatures: vector<vector<u8>>,
cert: CommitteeSignature,
clock: &Clock,
)

The function verifies a committee certificate over both signature sets and marks the withdrawal fully signed. Each committee member re-checks the withdrawal against its own copy of the rate limiter before signing that certificate.

Broadcast​

The fully signed transaction is broadcast to the Bitcoin network. The committee monitors the transaction until it reaches the configured number of block confirmations (see bitcoin_confirmation_threshold). Once confirmed, a committee member calls hashi::withdraw::confirm_withdrawal:

entry fun confirm_withdrawal(
hashi: &mut Hashi,
withdrawal_id: address,
cert: CommitteeSignature,
clock: &Clock,
)

The function verifies the committee certificate, marks the withdrawal confirmed and its input UTXOs spent, and promotes its change outputs from pending to confirmed UTXOs. The withdrawal record stays in place until the leader's garbage collection archives it with hashi::withdraw::archive_confirmed_withdrawals, or, for a batch too large for one transaction, archive_withdrawal_requests followed by finish_archive_withdrawal_txns.

If a transaction gets stuck, fee bumping is handled through CPFP. Either the withdrawal recipient spends their output with a high-fee child, or a later withdrawal batch spends the stuck transaction's change UTXO and pays its fee shortfall (see Fees: stuck transactions).