From 9514d84364bf906efa60d14d6f52187f8c9cf45e Mon Sep 17 00:00:00 2001 From: Bridgerz Date: Mon, 13 Apr 2026 14:06:36 -0700 Subject: [PATCH] Allow cancellation of approved withdrawal requests Withdrawal requests can now be cancelled while in the Approved state, not just Requested. The BTC balance is still intact until the committee commits the request to a WithdrawalTransaction (Processing), so cancellation is safe in both states. --- packages/hashi/sources/btc/withdraw.move | 16 ++++-- .../hashi/sources/btc/withdrawal_queue.move | 11 ++-- packages/hashi/tests/withdraw_tests.move | 51 ++++++++++++++++++- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/packages/hashi/sources/btc/withdraw.move b/packages/hashi/sources/btc/withdraw.move index d7119137d..f36e12fb8 100644 --- a/packages/hashi/sources/btc/withdraw.move +++ b/packages/hashi/sources/btc/withdraw.move @@ -29,7 +29,8 @@ const EUnauthorizedCancellation: vector = b"Only the original requester can #[error] const ECooldownNotElapsed: vector = b"Cancellation cooldown has not elapsed"; #[error] -const ECannotCancelAfterApproval: vector = b"Cannot cancel a withdrawal that has been approved"; +const ECannotCancelProcessingWithdrawal: vector = + b"Cannot cancel a withdrawal that is already being processed"; // MESSAGE STEP 1 public struct RequestApprovalMessage has copy, drop, store { @@ -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, @@ -310,13 +316,13 @@ public fun cancel_withdrawal( ): Balance { 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); diff --git a/packages/hashi/sources/btc/withdrawal_queue.move b/packages/hashi/sources/btc/withdrawal_queue.move index 07d8ce82d..81482d761 100644 --- a/packages/hashi/sources/btc/withdrawal_queue.move +++ b/packages/hashi/sources/btc/withdrawal_queue.move @@ -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 ======== diff --git a/packages/hashi/tests/withdraw_tests.move b/packages/hashi/tests/withdraw_tests.move index 2d85a61b1..c8476ec38 100644 --- a/packages/hashi/tests/withdraw_tests.move +++ b/packages/hashi/tests/withdraw_tests.move @@ -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() { let epoch = 0u64; let ctx = &mut test_utils::new_tx_context(REQUESTER, epoch); @@ -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); +}