Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions packages/hashi/sources/btc/withdraw.move
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const EUnauthorizedCancellation: vector<u8> = b"Only the original requester can
#[error]
const ECooldownNotElapsed: vector<u8> = b"Cancellation cooldown has not elapsed";
#[error]
const ECannotCancelAfterApproval: vector<u8> = b"Cannot cancel a withdrawal that has been approved";
const ECannotCancelProcessingWithdrawal: vector<u8> =
b"Cannot cancel a withdrawal that is already being processed";

// MESSAGE STEP 1
public struct RequestApprovalMessage has copy, drop, store {
Expand Down Expand Up @@ -302,6 +303,11 @@ entry fun confirm_withdrawal(hashi: &mut Hashi, withdrawal_id: address, cert: Co
}

/// 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.
public fun cancel_withdrawal(
hashi: &mut Hashi,
request_id: address,
Expand All @@ -310,13 +316,13 @@ public fun cancel_withdrawal(
): Balance<BTC> {
hashi.config().assert_version_enabled();

let request = hashi.bitcoin().withdrawal_queue().borrow_request(request_id);

assert!(
!hashi.bitcoin().withdrawal_queue().is_request_approved(request_id),
ECannotCancelAfterApproval,
!hashi.bitcoin().withdrawal_queue().is_request_processing(request_id),
ECannotCancelProcessingWithdrawal,
);

let request = hashi.bitcoin().withdrawal_queue().borrow_request(request_id);

// Only the original requester can cancel.
assert!(request.request_sender() == ctx.sender(), EUnauthorizedCancellation);

Expand Down
11 changes: 7 additions & 4 deletions packages/hashi/sources/btc/withdrawal_queue.move
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,13 @@ public(package) fun borrow_request(
self.requests.borrow(request_id)
}

/// Check if an active request is approved (for cancel guard).
public(package) fun is_request_approved(self: &WithdrawalRequestQueue, request_id: address): bool {
let request: &WithdrawalRequest = self.requests.borrow(request_id);
request.status == WithdrawalStatus::Approved
/// Check if a request has already been committed to a WithdrawalTransaction
/// (i.e. is in the processed bag as Processing/Signed/Confirmed).
public(package) fun is_request_processing(
self: &WithdrawalRequestQueue,
request_id: address,
): bool {
self.processed.contains(request_id)
}

// ======== Committed Request Info ========
Expand Down
51 changes: 49 additions & 2 deletions packages/hashi/tests/withdraw_tests.move
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ fun test_approve_request_bad_signature() {
}

#[test]
#[expected_failure(abort_code = hashi::withdraw::ECannotCancelAfterApproval)]
/// Cancelling an approved (but not yet processing) request should succeed
/// and return the full BTC balance to the requester.
fun test_approve_then_cancel() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add a test for trying to cancel a request that was entered into the processed state?

let epoch = 0u64;
let ctx = &mut test_utils::new_tx_context(REQUESTER, epoch);
Expand All @@ -196,12 +197,58 @@ fun test_approve_then_cancel() {
let cert = test_utils::sign_certificate(epoch, &message_bytes, 3);
hashi::withdraw::approve_request(&mut hashi, id1, cert);

// Cancelling an approved request should fail
// Cancelling an approved request should succeed — BTC hasn't been burned yet
let one_hour_ms = 1000 * 60 * 60;
clock.set_for_testing(one_hour_ms);
let btc = hashi::withdraw::cancel_withdrawal(&mut hashi, id1, &clock, ctx);
assert!(btc.value() == 10_000);
btc.destroy_for_testing();

clock.destroy_for_testing();
std::unit_test::destroy(hashi);
}

#[test]
#[expected_failure(abort_code = hashi::withdraw::ECannotCancelProcessingWithdrawal)]
/// Once a request has been committed to a WithdrawalTransaction it is in the
/// Processing state and its BTC has been burned — cancellation must be rejected.
fun test_cancel_processing_request() {
let epoch = 0u64;
let ctx = &mut test_utils::new_tx_context(REQUESTER, epoch);
let voters = vector[VOTER1, VOTER2, VOTER3];
let mut hashi = test_utils::create_hashi_with_committee(voters, ctx);
let mut clock = clock::create_for_testing(ctx);

let id1 = setup_withdrawal_request(&mut hashi, &clock, 10_000, ctx);

// Approve the request.
let approval = hashi::withdraw::new_request_approval_message(id1);
let message_bytes = build_cert_message(epoch, &approval);
let cert = test_utils::sign_certificate(epoch, &message_bytes, 3);
hashi::withdraw::approve_request(&mut hashi, id1, cert);

// Commit the request into a WithdrawalTransaction — this moves it to Processing.
let test_utxo = utxo::utxo(utxo::utxo_id(@0xBEEF, 0), 1_000_000, option::none());
let txn = withdrawal_queue::new_withdrawal_txn_for_testing(
vector[id1],
vector[test_utxo],
vector[withdrawal_queue::output_utxo(1, x"00")],
option::none(),
@0xBEEF,
&clock,
ctx,
);
let btc_balance = hashi.bitcoin_mut().withdrawal_queue_mut().commit_requests(&txn);

// Advance past cooldown and attempt cancellation — should abort.
let one_hour_ms = 1000 * 60 * 60;
clock.set_for_testing(one_hour_ms);
let btc = hashi::withdraw::cancel_withdrawal(&mut hashi, id1, &clock, ctx);

// Cleanup — not reached.
btc.destroy_for_testing();
btc_balance.destroy_for_testing();
std::unit_test::destroy(txn);
clock.destroy_for_testing();
std::unit_test::destroy(hashi);
}
Loading