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); +}