diff --git a/contracts/satoshi-bridge/src/api/bridge.rs b/contracts/satoshi-bridge/src/api/bridge.rs index 1e0dc9f..22b599f 100644 --- a/contracts/satoshi-bridge/src/api/bridge.rs +++ b/contracts/satoshi-bridge/src/api/bridge.rs @@ -426,6 +426,134 @@ impl Contract { } } +#[cfg(not(feature = "zcash"))] +#[near] +impl Contract { + // ── Refund API (Bitcoin only) ── + + /// Submit a refund request for a deposit that was never finalized via `verify_deposit` or `safe_verify_deposit`. + /// The BTC transaction is verified through the Light Client to prove the deposit exists. + /// After the timelock period, anyone can call `execute_refund` to initiate the return. + /// + /// # Arguments + /// + /// * `deposit_msg` - The original deposit message. If `deposit_msg.refund_address` is set, + /// it must match the provided `refund_address`. + /// * `refund_address` - BTC address to send the refund to. If `deposit_msg.refund_address` + /// is `None`, this value is used directly. + /// * `tx_bytes` - BTC transaction bytes proving the deposit. + /// * `vout` - Output index of the deposit in the transaction. + /// * `tx_block_blockhash` - Block hash containing the transaction. + /// * `tx_index` - Transaction index within the block. + /// * `merkle_proof` - Merkle proof for Light Client verification. + /// * `gas_fee` - Optional custom gas fee. Only DAO or Operator can set this. + /// If `None`, the default `config.max_btc_gas_fee` is used during `execute_refund`. + #[allow(clippy::too_many_arguments)] + #[pause(except(roles(Role::DAO)))] + pub fn request_refund( + &mut self, + deposit_msg: DepositMsg, + refund_address: String, + tx_bytes: Vec, + vout: usize, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + gas_fee: Option, + ) -> Promise { + if gas_fee.is_some() { + let caller = env::predecessor_account_id(); + require!( + self.acl_has_role(Role::DAO.into(), caller.clone()) + || self.acl_has_role(Role::Operator.into(), caller), + "Only DAO or Operator can specify custom gas_fee" + ); + } + self.internal_request_refund( + deposit_msg, + refund_address, + tx_bytes, + vout, + tx_block_blockhash, + tx_index, + merkle_proof, + gas_fee.map(|v| v.0), + ) + } + + /// Reject a pending refund request. + /// - DAO or Operator can reject any request. + /// - Anyone can reject a request if the UTXO has already been verified via `verify_deposit`. + /// + /// # Arguments + /// + /// * `utxo_storage_key` - The UTXO key identifying the refund request (`{tx_id}@{vout}`). + pub fn reject_refund(&mut self, utxo_storage_key: String) { + let caller = env::predecessor_account_id(); + let is_privileged = self.acl_has_role(Role::DAO.into(), caller.clone()) + || self.acl_has_role(Role::Operator.into(), caller); + let is_already_deposited = self + .data() + .verified_deposit_utxo + .contains(&utxo_storage_key); + require!( + is_privileged || is_already_deposited, + "Only DAO/Operator can reject, or UTXO must be already verified via deposit" + ); + self.internal_reject_refund(utxo_storage_key); + } + + /// Execute a refund after the timelock has passed. Builds a BTC transaction + /// that sends the deposit UTXO back to the `refund_address` specified in the original + /// `DepositMsg`. Creates a `BTCPendingInfo` entry for the MPC sign pipeline. + /// Marks the UTXO in `verified_deposit_utxo` to prevent future `verify_deposit`. + /// + /// # Arguments + /// + /// * `utxo_storage_key` - The UTXO key identifying the refund request (`{tx_id}@{vout}`). + #[payable] + #[pause(except(roles(Role::DAO)))] + pub fn execute_refund(&mut self, utxo_storage_key: String) { + require!( + env::attached_deposit() >= self.required_balance_for_execute_refund(), + "Insufficient deposit for storage" + ); + self.internal_execute_refund(utxo_storage_key); + } + + /// Verify that the refund BTC transaction has been confirmed on the Bitcoin network. + /// Cleans up the `BTCPendingInfo` after successful verification. + /// + /// # Arguments + /// + /// * `tx_id` - Transaction ID of the confirmed refund transaction. + /// * `tx_block_blockhash` - Block hash containing the transaction. + /// * `tx_index` - Transaction index within the block. + /// * `merkle_proof` - Merkle proof for Light Client verification. + #[pause(except(roles(Role::DAO)))] + pub fn verify_refund_finalize( + &mut self, + tx_id: String, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + ) -> Promise { + let btc_pending_info = self.internal_unwrap_btc_pending_info(&tx_id); + btc_pending_info.assert_refund_pending_verify_tx(); + require!( + btc_pending_info.tx_bytes_with_sign.is_some(), + "Missing tx_bytes_with_sign" + ); + self.internal_verify_refund_finalize( + tx_id, + tx_block_blockhash, + tx_index, + merkle_proof, + btc_pending_info, + ) + } +} + impl Contract { pub fn create_active_utxo_management_pending_info( &mut self, diff --git a/contracts/satoshi-bridge/src/api/management.rs b/contracts/satoshi-bridge/src/api/management.rs index 41a5b3d..fd607d5 100644 --- a/contracts/satoshi-bridge/src/api/management.rs +++ b/contracts/satoshi-bridge/src/api/management.rs @@ -459,4 +459,12 @@ impl Contract { ); self.internal_mut_config().unhealthy_utxo_amount = unhealthy_utxo_amount.0; } + + #[cfg(not(feature = "zcash"))] + #[payable] + #[access_control_any(roles(Role::DAO))] + pub fn set_refund_timelock_sec(&mut self, refund_timelock_sec: u64) { + assert_one_yocto(); + self.internal_mut_config().refund_timelock_sec = refund_timelock_sec; + } } diff --git a/contracts/satoshi-bridge/src/api/mod.rs b/contracts/satoshi-bridge/src/api/mod.rs index ade09f9..01292e2 100644 --- a/contracts/satoshi-bridge/src/api/mod.rs +++ b/contracts/satoshi-bridge/src/api/mod.rs @@ -1,3 +1,4 @@ +#[allow(clippy::too_many_arguments)] mod bridge; mod chain_signatures; mod management; diff --git a/contracts/satoshi-bridge/src/api/view.rs b/contracts/satoshi-bridge/src/api/view.rs index b98f3cb..e6a2e3a 100644 --- a/contracts/satoshi-bridge/src/api/view.rs +++ b/contracts/satoshi-bridge/src/api/view.rs @@ -284,4 +284,11 @@ impl Contract { pub fn required_balance_for_safe_deposit(&self) -> NearToken { REQUIRED_BALANCE_FOR_DEPOSIT } + + #[cfg(not(feature = "zcash"))] + pub fn required_balance_for_execute_refund(&self) -> NearToken { + // execute_refund uses ~700 bytes of storage (BTCPendingInfo + Account + verified_deposit_utxo) + // At 0.00001 NEAR/byte, that's ~0.007 NEAR. We use 0.01 NEAR as a safe margin. + NearToken::from_millinear(10) + } } diff --git a/contracts/satoshi-bridge/src/btc_pending_info.rs b/contracts/satoshi-bridge/src/btc_pending_info.rs index cdf15dd..e7d14cc 100644 --- a/contracts/satoshi-bridge/src/btc_pending_info.rs +++ b/contracts/satoshi-bridge/src/btc_pending_info.rs @@ -73,6 +73,7 @@ pub enum PendingInfoState { ActiveUtxoManagementOriginal(OriginalState), ActiveUtxoManagementRbf(RbfState), ActiveUtxoManagementCancelRbf(RbfState), + Refund(OriginalState), } impl PendingInfoState { @@ -84,6 +85,7 @@ impl PendingInfoState { PendingInfoState::ActiveUtxoManagementOriginal(state) => state.assert_pending_sign(), PendingInfoState::ActiveUtxoManagementRbf(state) => state.assert_pending_sign(), PendingInfoState::ActiveUtxoManagementCancelRbf(state) => state.assert_pending_sign(), + PendingInfoState::Refund(state) => state.assert_pending_sign(), } } pub fn assert_pending_verify(&self) { @@ -94,6 +96,7 @@ impl PendingInfoState { PendingInfoState::ActiveUtxoManagementOriginal(state) => state.assert_pending_verify(), PendingInfoState::ActiveUtxoManagementRbf(state) => state.assert_pending_verify(), PendingInfoState::ActiveUtxoManagementCancelRbf(state) => state.assert_pending_verify(), + PendingInfoState::Refund(state) => state.assert_pending_verify(), } } } @@ -157,6 +160,13 @@ impl BTCPendingInfo { } } + pub fn assert_refund_pending_verify_tx(&self) { + match self.state.borrow() { + PendingInfoState::Refund(state) => state.assert_pending_verify(), + _ => env::panic_str("Not refund related tx"), + } + } + pub fn assert_active_utxo_management_related_pending_verify_tx(&self) { match self.state.borrow() { PendingInfoState::ActiveUtxoManagementOriginal(state) => state.assert_pending_verify(), @@ -222,6 +232,9 @@ impl BTCPendingInfo { PendingInfoState::ActiveUtxoManagementCancelRbf(state) => { state.stage = PendingInfoStage::PendingVerify; } + PendingInfoState::Refund(state) => { + state.stage = PendingInfoStage::PendingVerify; + } } } @@ -243,6 +256,9 @@ impl BTCPendingInfo { PendingInfoState::ActiveUtxoManagementCancelRbf(state) => { state.stage = PendingInfoStage::PendingBurn; } + PendingInfoState::Refund(state) => { + state.stage = PendingInfoStage::PendingBurn; + } } } @@ -393,7 +409,7 @@ impl Contract { pub fn generate_btc_pending_sign_id(payload_preimages: &[Vec]) -> String { let hash_bytes = env::sha256_array( - &payload_preimages + payload_preimages .iter() .flatten() .copied() @@ -457,6 +473,7 @@ mod tests { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }; let path = get_deposit_path(&deposit_msg); diff --git a/contracts/satoshi-bridge/src/config.rs b/contracts/satoshi-bridge/src/config.rs index 1614189..5fd08e0 100644 --- a/contracts/satoshi-bridge/src/config.rs +++ b/contracts/satoshi-bridge/src/config.rs @@ -110,6 +110,9 @@ pub struct Config { pub unhealthy_utxo_amount: u64, #[cfg(feature = "zcash")] pub expiry_height_gap: u32, + // Timelock in seconds before a refund request can be executed. + #[cfg(not(feature = "zcash"))] + pub refund_timelock_sec: u64, } impl Config { diff --git a/contracts/satoshi-bridge/src/deposit_msg.rs b/contracts/satoshi-bridge/src/deposit_msg.rs index 629e6f2..d51243d 100644 --- a/contracts/satoshi-bridge/src/deposit_msg.rs +++ b/contracts/satoshi-bridge/src/deposit_msg.rs @@ -22,6 +22,9 @@ pub struct DepositMsg { // If this field is present, the legacy post_actions field must be None #[serde(skip_serializing_if = "Option::is_none")] pub safe_deposit: Option, + // BTC address for refund if deposit is never finalized. + #[serde(skip_serializing_if = "Option::is_none")] + pub refund_address: Option, } #[near(serializers = [json])] diff --git a/contracts/satoshi-bridge/src/event.rs b/contracts/satoshi-bridge/src/event.rs index eeca095..b30c792 100644 --- a/contracts/satoshi-bridge/src/event.rs +++ b/contracts/satoshi-bridge/src/event.rs @@ -92,6 +92,21 @@ pub enum Event<'a> { index: Option, err_msg: String, }, + RefundRequested { + deposit_msg: DepositMsg, + utxo_storage_key: String, + amount: U128, + refund_address: String, + gas_fee: U128, + }, + RefundRejected { + utxo_storage_key: String, + }, + RefundExecuted { + utxo_storage_key: String, + amount: U128, + refund_address: String, + }, } impl Event<'_> { diff --git a/contracts/satoshi-bridge/src/legacy.rs b/contracts/satoshi-bridge/src/legacy.rs index f891f7e..4b1b56a 100644 --- a/contracts/satoshi-bridge/src/legacy.rs +++ b/contracts/satoshi-bridge/src/legacy.rs @@ -60,6 +60,8 @@ impl From for ContractData { acc_claimed_protocol_fee, cur_reserved_protocol_fee, acc_protocol_fee_for_gas, + #[cfg(not(feature = "zcash"))] + refund_requests: IterableMap::new(StorageKey::RefundRequests), } } } @@ -190,6 +192,8 @@ impl From for Config { unhealthy_utxo_amount: 1000, #[cfg(feature = "zcash")] expiry_height_gap: 1000, + #[cfg(not(feature = "zcash"))] + refund_timelock_sec: 14 * 24 * 3600, // 2 weeks } } } @@ -330,6 +334,8 @@ impl From for Config { unhealthy_utxo_amount, #[cfg(feature = "zcash")] expiry_height_gap, + #[cfg(not(feature = "zcash"))] + refund_timelock_sec: 14 * 24 * 3600, // 2 weeks } } } @@ -393,6 +399,8 @@ impl From for ContractData { acc_claimed_protocol_fee, cur_reserved_protocol_fee, acc_protocol_fee_for_gas, + #[cfg(not(feature = "zcash"))] + refund_requests: IterableMap::new(StorageKey::RefundRequests), } } } @@ -467,6 +475,8 @@ impl From for ContractData { acc_claimed_protocol_fee, cur_reserved_protocol_fee, acc_protocol_fee_for_gas, + #[cfg(not(feature = "zcash"))] + refund_requests: IterableMap::new(StorageKey::RefundRequests), } } } diff --git a/contracts/satoshi-bridge/src/lib.rs b/contracts/satoshi-bridge/src/lib.rs index a0e00d8..fc9f4aa 100644 --- a/contracts/satoshi-bridge/src/lib.rs +++ b/contracts/satoshi-bridge/src/lib.rs @@ -36,6 +36,8 @@ pub mod nbtc; pub mod network; pub mod psbt; pub mod rbf; +#[cfg(not(feature = "zcash"))] +pub mod refund; pub mod token_transfer; #[cfg(test)] mod unit; @@ -54,6 +56,8 @@ pub use crate::json_utils::*; pub use crate::legacy::*; pub use crate::nbtc::*; pub use crate::rbf::*; +#[cfg(not(feature = "zcash"))] +pub use crate::refund::*; pub use crate::token_transfer::*; pub use crate::utils::*; pub use crate::utxo::*; @@ -92,6 +96,8 @@ enum StorageKey { LostFound, PostActionMsgTemplates, ExtraMsgRelayerWhiteList, + #[cfg(not(feature = "zcash"))] + RefundRequests, } #[derive(AccessControlRole, Deserialize, Serialize, Copy, Clone)] @@ -125,6 +131,8 @@ pub struct ContractData { pub acc_claimed_protocol_fee: u128, pub cur_reserved_protocol_fee: u128, pub acc_protocol_fee_for_gas: u128, + #[cfg(not(feature = "zcash"))] + pub refund_requests: IterableMap, } #[near(serializers = [borsh])] @@ -186,6 +194,8 @@ impl Contract { ), post_action_msg_templates: IterableMap::new(StorageKey::PostActionMsgTemplates), lost_found: IterableMap::new(StorageKey::LostFound), + #[cfg(not(feature = "zcash"))] + refund_requests: IterableMap::new(StorageKey::RefundRequests), acc_collected_protocol_fee: 0, cur_available_protocol_fee: 0, acc_claimed_protocol_fee: 0, diff --git a/contracts/satoshi-bridge/src/refund.rs b/contracts/satoshi-bridge/src/refund.rs new file mode 100644 index 0000000..d7336c3 --- /dev/null +++ b/contracts/satoshi-bridge/src/refund.rs @@ -0,0 +1,418 @@ +use bitcoin::{Amount, OutPoint, TxOut}; + +use crate::{ + env, near, require, serde_json, BTCPendingInfo, Contract, ContractExt, DepositMsg, Event, Gas, + OriginalState, PendingInfoStage, PendingInfoState, Promise, MAX_BOOL_RESULT, UTXO, VUTXO, +}; + +use crate::deposit_msg::get_deposit_path; +use crate::psbt_wrapper::PsbtWrapper; +use crate::utils::{generate_utxo_storage_key, nano_to_sec}; + +pub const GAS_FOR_REQUEST_REFUND_CALLBACK: Gas = Gas::from_tgas(20); +pub const GAS_FOR_VERIFY_REFUND_CALLBACK: Gas = Gas::from_tgas(20); + +/// Stored refund request. `deposit_msg` is kept as JSON string +/// because `DepositMsg` does not implement Borsh serialization. +#[near(serializers = [borsh, json])] +#[derive(Clone)] +pub struct RefundRequest { + pub deposit_msg_json: String, + pub utxo_storage_key: String, + pub tx_bytes: Vec, + pub vout: usize, + pub amount: u128, + pub refund_address: String, + pub gas_fee: u128, + pub created_at_sec: u32, +} + +impl RefundRequest { + pub fn deposit_msg(&self) -> DepositMsg { + serde_json::from_str(&self.deposit_msg_json).expect("Invalid deposit_msg_json") + } +} + +#[near(serializers = [borsh, json])] +#[derive(Clone)] +pub enum VRefundRequest { + Current(RefundRequest), +} + +impl From for RefundRequest { + fn from(v: VRefundRequest) -> Self { + match v { + VRefundRequest::Current(c) => c, + } + } +} + +impl From<&VRefundRequest> for RefundRequest { + fn from(v: &VRefundRequest) -> Self { + match v { + VRefundRequest::Current(c) => c.clone(), + } + } +} + +impl From for VRefundRequest { + fn from(c: RefundRequest) -> Self { + VRefundRequest::Current(c) + } +} + +impl Contract { + /// Submit a refund request. Verifies the BTC transaction via Light Client first. + /// If `deposit_msg.refund_address` is set, it must match the provided `refund_address`. + /// If `deposit_msg.refund_address` is None, the provided `refund_address` is used. + #[allow(clippy::too_many_arguments)] + pub fn internal_request_refund( + &self, + deposit_msg: DepositMsg, + refund_address: String, + tx_bytes: Vec, + vout: usize, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + gas_fee: Option, + ) -> Promise { + if let Some(msg_refund_address) = &deposit_msg.refund_address { + require!( + msg_refund_address == &refund_address, + "refund_address does not match deposit_msg.refund_address" + ); + } + + let transaction = + crate::WrappedTransaction::decode(&tx_bytes, &self.internal_config().chain) + .expect("Deserialization tx_bytes failed"); + let tx_id = transaction.compute_txid().to_string(); + let utxo_storage_key = generate_utxo_storage_key( + tx_id.clone(), + u32::try_from(vout).unwrap_or_else(|_| env::panic_str("vout overflow")), + ); + + // Must not be already verified/finalized + require!( + !self + .data() + .verified_deposit_utxo + .contains(&utxo_storage_key), + "UTXO already verified via deposit" + ); + + // Must not have a pending refund request already + require!( + !self.data().refund_requests.contains_key(&utxo_storage_key), + "Refund request already exists for this UTXO" + ); + + let config = self.internal_config(); + let deposit_amount = u128::from(transaction.output()[vout].value.to_sat()); + let confirmations = self.get_confirmations(config, deposit_amount); + + self.verify_transaction_inclusion_promise( + config.btc_light_client_account_id.clone(), + tx_id, + tx_block_blockhash, + tx_index, + merkle_proof, + confirmations, + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_FOR_REQUEST_REFUND_CALLBACK) + .request_refund_callback(deposit_msg, refund_address, tx_bytes, vout, gas_fee), + ) + } + + /// Reject a pending refund request. + pub fn internal_reject_refund(&mut self, utxo_storage_key: String) { + require!( + self.data_mut() + .refund_requests + .remove(&utxo_storage_key) + .is_some(), + "Refund request not found" + ); + Event::RefundRejected { utxo_storage_key }.emit(); + } + + /// Execute an approved refund request after timelock has passed. + pub fn internal_execute_refund(&mut self, utxo_storage_key: String) { + let refund_request: RefundRequest = self + .data() + .refund_requests + .get(&utxo_storage_key) + .expect("Refund request not found") + .into(); + + let config = self.internal_config(); + + // Check timelock + let now = nano_to_sec(env::block_timestamp()); + require!( + u64::from(now) >= u64::from(refund_request.created_at_sec) + config.refund_timelock_sec, + "Refund timelock has not passed yet" + ); + + // Must still not be finalized + require!( + !self + .data() + .verified_deposit_utxo + .contains(&utxo_storage_key), + "UTXO already verified via deposit, cannot refund" + ); + + let refund_address = refund_request.refund_address.clone(); + + // Parse the original deposit transaction to get OutPoint + let transaction = + crate::WrappedTransaction::decode(&refund_request.tx_bytes, &config.chain) + .expect("Deserialization tx_bytes failed"); + let txid = transaction.compute_txid(); + let outpoint = OutPoint { + txid, + vout: u32::try_from(refund_request.vout) + .unwrap_or_else(|_| env::panic_str("vout overflow")), + }; + + // The deposit UTXO output (for witness) + let deposit_output = transaction.output()[refund_request.vout].clone(); + + // Parse refund address + let refund_addr = crate::network::Address::parse(&refund_address, config.chain.clone()) + .expect("Invalid refund address"); + let refund_script_pubkey = refund_addr + .script_pubkey() + .expect("Invalid refund script_pubkey"); + + // Calculate gas fee: entire remainder goes to gas + let gas_fee = refund_request.gas_fee; + let refund_amount = refund_request + .amount + .checked_sub(gas_fee) + .expect("Deposit amount too small to cover gas fee"); + require!(refund_amount > 0, "Refund amount is zero after gas fee"); + + // Build refund output + let refund_output = TxOut { + value: Amount::from_sat( + u64::try_from(refund_amount) + .unwrap_or_else(|_| env::panic_str("Refund amount overflow")), + ), + script_pubkey: refund_script_pubkey, + }; + + // Build PSBT: 1 input (deposit UTXO), 1 output (refund address) + let mut psbt = PsbtWrapper::new(vec![outpoint], vec![refund_output]); + psbt.set_input_utxo(vec![deposit_output]); + + // Build VUTXO for signing (path derived from deposit_msg) + let deposit_msg = refund_request.deposit_msg(); + let path = get_deposit_path(&deposit_msg); + let vutxo = VUTXO::Current(UTXO { + path, + tx_bytes: refund_request.tx_bytes.clone(), + vout: refund_request.vout, + balance: u64::try_from(refund_request.amount) + .unwrap_or_else(|_| env::panic_str("Amount overflow")), + }); + + // Create BTCPendingInfo + let psbt_hex = psbt.serialize(); + let btc_pending_id = psbt.get_pending_id(); + let caller = env::predecessor_account_id(); + + if !self.check_account_exists(&caller) { + self.internal_set_account(&caller, crate::Account::new(&caller)); + } + require!( + self.internal_unwrap_account(&caller) + .btc_pending_sign_id + .is_none(), + "Previous btc tx has not been signed" + ); + + let btc_pending_info = BTCPendingInfo { + account_id: caller.clone(), + btc_pending_id: btc_pending_id.clone(), + transfer_amount: 0, + actual_received_amount: refund_amount, + withdraw_fee: 0, + gas_fee, + burn_amount: 0, + psbt_hex, + vutxos: vec![vutxo], + signatures: vec![None; 1], + tx_bytes_with_sign: None, + create_time_sec: nano_to_sec(env::block_timestamp()), + last_sign_time_sec: 0, + state: PendingInfoState::Refund(OriginalState { + stage: PendingInfoStage::PendingSign, + max_gas_fee: gas_fee, + last_rbf_time_sec: None, + cancel_rbf_reserved: None, + }), + }; + + require!( + self.data_mut() + .btc_pending_infos + .insert(btc_pending_id.clone(), btc_pending_info.into()) + .is_none(), + "pending info already exist" + ); + self.internal_unwrap_mut_account(&caller) + .btc_pending_sign_id = Some(btc_pending_id.clone()); + + // Mark UTXO as verified to prevent verify_deposit later + self.data_mut() + .verified_deposit_utxo + .insert(utxo_storage_key.clone()); + + Event::RefundExecuted { + utxo_storage_key: utxo_storage_key.clone(), + amount: refund_request.amount.into(), + refund_address, + } + .emit(); + + Event::GenerateBtcPendingInfo { + account_id: &caller, + btc_pending_id: &btc_pending_id, + } + .emit(); + + self.data_mut().refund_requests.remove(&utxo_storage_key); + } + + /// Verify refund transaction was included in Bitcoin blockchain. + pub fn internal_verify_refund_finalize( + &self, + tx_id: String, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + btc_pending_info: &BTCPendingInfo, + ) -> Promise { + let config = self.internal_config(); + let confirmations = self.get_confirmations(config, btc_pending_info.actual_received_amount); + self.verify_transaction_inclusion_promise( + config.btc_light_client_account_id.clone(), + tx_id.clone(), + tx_block_blockhash, + tx_index, + merkle_proof, + confirmations, + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_FOR_VERIFY_REFUND_CALLBACK) + .verify_refund_finalize_callback(tx_id), + ) + } +} + +#[near] +impl Contract { + #[private] + pub fn verify_refund_finalize_callback(&mut self, tx_id: String) -> bool { + let result_bytes = env::promise_result_checked(0, MAX_BOOL_RESULT) + .expect("Call verify_transaction_inclusion failed"); + let is_valid = serde_json::from_slice::(&result_bytes) + .expect("verify_transaction_inclusion return not bool"); + require!(is_valid, "verify_transaction_inclusion return false"); + + let btc_pending_info = self.internal_unwrap_btc_pending_info(&tx_id); + btc_pending_info.assert_refund_pending_verify_tx(); + + let account_id = btc_pending_info.account_id.clone(); + + // Clean up: remove pending info + self.internal_remove_btc_pending_info(&tx_id); + self.internal_unwrap_mut_account(&account_id) + .btc_pending_verify_list + .remove(&tx_id); + + true + } + + #[private] + pub fn request_refund_callback( + &mut self, + deposit_msg: DepositMsg, + refund_address: String, + tx_bytes: Vec, + vout: usize, + gas_fee: Option, + ) -> bool { + let result_bytes = env::promise_result_checked(0, MAX_BOOL_RESULT) + .expect("Call verify_transaction_inclusion failed"); + let is_valid = serde_json::from_slice::(&result_bytes) + .expect("verify_transaction_inclusion return not bool"); + require!(is_valid, "verify_transaction_inclusion return false"); + + let config = self.internal_config(); + let transaction = crate::WrappedTransaction::decode(&tx_bytes, &config.chain) + .expect("Deserialization tx_bytes failed"); + let output = &transaction.output()[vout]; + + // Verify that the output script matches the deposit address derived from deposit_msg + let path = get_deposit_path(&deposit_msg); + let deposit_address = self.generate_utxo_chain_address(&path); + let deposit_script_pubkey = deposit_address + .script_pubkey() + .expect("Invalid deposit address"); + require!( + deposit_script_pubkey == output.script_pubkey, + "Output script_pubkey does not match deposit address" + ); + + let amount = u128::from(output.value.to_sat()); + let tx_id = transaction.compute_txid().to_string(); + let utxo_storage_key = generate_utxo_storage_key( + tx_id, + u32::try_from(vout).unwrap_or_else(|_| env::panic_str("vout overflow")), + ); + + // Double-check not finalized (could have been verified between request and callback) + require!( + !self + .data() + .verified_deposit_utxo + .contains(&utxo_storage_key), + "UTXO already verified via deposit" + ); + + let resolved_gas_fee = gas_fee.unwrap_or(config.max_btc_gas_fee); + + Event::RefundRequested { + deposit_msg: deposit_msg.clone(), + utxo_storage_key: utxo_storage_key.clone(), + amount: amount.into(), + refund_address: refund_address.clone(), + gas_fee: resolved_gas_fee.into(), + } + .emit(); + + let refund_request = RefundRequest { + deposit_msg_json: serde_json::to_string(&deposit_msg).unwrap(), + utxo_storage_key: utxo_storage_key.clone(), + tx_bytes, + vout, + amount, + refund_address, + gas_fee: resolved_gas_fee, + created_at_sec: nano_to_sec(env::block_timestamp()), + }; + + self.data_mut() + .refund_requests + .insert(utxo_storage_key, refund_request.into()); + + true + } +} diff --git a/contracts/satoshi-bridge/src/unit/mod.rs b/contracts/satoshi-bridge/src/unit/mod.rs index 87b86a3..03a2f2d 100644 --- a/contracts/satoshi-bridge/src/unit/mod.rs +++ b/contracts/satoshi-bridge/src/unit/mod.rs @@ -81,6 +81,8 @@ pub fn init_contract() -> Contract { unhealthy_utxo_amount: 1000, #[cfg(feature = "zcash")] expiry_height_gap: 1000, + #[cfg(not(feature = "zcash"))] + refund_timelock_sec: 3600, }) } diff --git a/contracts/satoshi-bridge/src/unit/post_action.rs b/contracts/satoshi-bridge/src/unit/post_action.rs index fc06a8e..b77168c 100644 --- a/contracts/satoshi-bridge/src/unit/post_action.rs +++ b/contracts/satoshi-bridge/src/unit/post_action.rs @@ -239,7 +239,8 @@ fn test_check_deposit_msg() { recipient_id: recipient_id(), post_actions: None, extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -252,7 +253,8 @@ fn test_check_deposit_msg() { recipient_id: recipient_id(), post_actions: Some(vec![]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -286,7 +288,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -313,7 +316,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -340,7 +344,8 @@ fn test_check_deposit_msg() { gas: Some(Gas::from_tgas(200)) },]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -367,7 +372,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -394,7 +400,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -421,7 +428,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -447,7 +455,8 @@ fn test_check_deposit_msg() { gas: Some(Gas::from_tgas(50)) },]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -465,7 +474,8 @@ fn test_check_deposit_msg() { gas: Some(Gas::from_tgas(50)) },]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -489,7 +499,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -509,7 +520,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -529,7 +541,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) @@ -549,7 +562,8 @@ fn test_check_deposit_msg() { }, ]), extra_msg: None, - safe_deposit: None + safe_deposit: None, + refund_address: None }, 100 ) diff --git a/contracts/satoshi-bridge/src/unit/storage.rs b/contracts/satoshi-bridge/src/unit/storage.rs index 7212716..f0b2034 100644 --- a/contracts/satoshi-bridge/src/unit/storage.rs +++ b/contracts/satoshi-bridge/src/unit/storage.rs @@ -11,6 +11,7 @@ impl Contract { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }); let utxo_storage_key = generate_utxo_storage_key(txid, vout); let tx_bytes = vec![0u8; 300]; // TODO: optimise storage usage diff --git a/contracts/satoshi-bridge/src/zcash_utils/contract_methods.rs b/contracts/satoshi-bridge/src/zcash_utils/contract_methods.rs index 64b0628..43ec55f 100644 --- a/contracts/satoshi-bridge/src/zcash_utils/contract_methods.rs +++ b/contracts/satoshi-bridge/src/zcash_utils/contract_methods.rs @@ -19,7 +19,7 @@ macro_rules! define_rbf_callback { chain_specific_data: Option, ) { let predecessor_account_id = env::predecessor_account_id(); - self.get_last_block_height_promise().then( + let _ = self.get_last_block_height_promise().then( Self::ext(env::current_account_id()) .with_static_gas(GAS_RBF_CALL_BACK) .$callback_name( @@ -100,10 +100,10 @@ define_rbf_callback!( internal_cancel_active_utxo_management ); +#[allow(clippy::too_many_arguments)] #[near] impl Contract { #[private] - #[allow(clippy::too_many_arguments)] pub fn ft_on_transfer_callback( &mut self, sender_id: AccountId, @@ -248,7 +248,7 @@ impl Contract { input: Vec, output: Vec, ) { - self.get_last_block_height_promise().then( + let _ = self.get_last_block_height_promise().then( Self::ext(env::current_account_id()) .with_static_gas(GAS_FOR_ACTIVE_UTXO_MANAGMENT_CALLBACK) .active_utxo_management_callback(account_id, input, output), diff --git a/contracts/satoshi-bridge/src/zcash_utils/mod.rs b/contracts/satoshi-bridge/src/zcash_utils/mod.rs index ea6ecdf..0f54b25 100644 --- a/contracts/satoshi-bridge/src/zcash_utils/mod.rs +++ b/contracts/satoshi-bridge/src/zcash_utils/mod.rs @@ -1,3 +1,4 @@ +#[allow(clippy::too_many_arguments)] pub mod contract_methods; pub mod orchard_policy; pub mod psbt_wrapper; diff --git a/contracts/satoshi-bridge/tests/setup/context.rs b/contracts/satoshi-bridge/tests/setup/context.rs index 5885cd3..1a56c99 100644 --- a/contracts/satoshi-bridge/tests/setup/context.rs +++ b/contracts/satoshi-bridge/tests/setup/context.rs @@ -211,6 +211,7 @@ impl Context { "max_btc_tx_pending_sec": 3600 * 24, "unhealthy_utxo_amount": 1000, "expiry_height_gap": 5000, + "refund_timelock_sec": 3600, } })) .transact() @@ -1155,6 +1156,7 @@ impl UpgradeContext { "max_btc_tx_pending_sec": 3600 * 24, "unhealthy_utxo_amount": 1000, "expiry_height_gap": 1000, + "refund_timelock_sec": 3600, } })) .transact() @@ -1260,3 +1262,124 @@ impl UpgradeContext { .json::() } } + +impl Context { + // ── Refund helpers ── + + pub async fn request_refund( + &self, + user: &str, + deposit_msg: DepositMsg, + refund_address: &str, + tx_bytes: Vec, + vout: u32, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + gas_fee: Option, + ) -> Result { + self.get_account_by_name(user) + .call(self.bridge_contract.id(), "request_refund") + .args_json(json!({ + "deposit_msg": deposit_msg, + "refund_address": refund_address, + "tx_bytes": tx_bytes, + "vout": vout, + "tx_block_blockhash": tx_block_blockhash, + "tx_index": tx_index, + "merkle_proof": merkle_proof, + "gas_fee": gas_fee, + })) + .max_gas() + .transact() + .await + } + + pub async fn required_balance_for_execute_refund(&self) -> Result { + self.bridge_contract + .call("required_balance_for_execute_refund") + .args_json(json!({})) + .view() + .await + .unwrap() + .json::() + } + + pub async fn execute_refund( + &self, + user: &str, + utxo_storage_key: &str, + ) -> Result { + self.get_account_by_name(user) + .call(self.bridge_contract.id(), "execute_refund") + .args_json(json!({ + "utxo_storage_key": utxo_storage_key, + })) + .deposit(NearToken::from_millinear(100)) + .max_gas() + .transact() + .await + } + + pub async fn reject_refund( + &self, + user: &str, + utxo_storage_key: &str, + ) -> Result { + self.get_account_by_name(user) + .call(self.bridge_contract.id(), "reject_refund") + .args_json(json!({ + "utxo_storage_key": utxo_storage_key, + })) + .max_gas() + .transact() + .await + } + + pub async fn verify_refund_finalize( + &self, + user: &str, + tx_id: &str, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + ) -> Result { + self.get_account_by_name(user) + .call(self.bridge_contract.id(), "verify_refund_finalize") + .args_json(json!({ + "tx_id": tx_id, + "tx_block_blockhash": tx_block_blockhash, + "tx_index": tx_index, + "merkle_proof": merkle_proof, + })) + .max_gas() + .transact() + .await + } + + pub async fn safe_verify_deposit( + &self, + user: &str, + deposit_msg: DepositMsg, + tx_bytes: Vec, + vout: u32, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + ) -> Result { + self.get_account_by_name(user) + .call(self.bridge_contract.id(), "safe_verify_deposit") + .args_json(json!({ + "deposit_msg": deposit_msg, + "tx_bytes": tx_bytes, + "vout": vout, + "tx_block_blockhash": tx_block_blockhash, + "tx_index": tx_index, + "merkle_proof": merkle_proof, + })) + .deposit(NearToken::from_millinear(2)) + .max_gas() + .transact() + .await + } +} diff --git a/contracts/satoshi-bridge/tests/test_orchard_validation.rs b/contracts/satoshi-bridge/tests/test_orchard_validation.rs index fbd115d..edb37b1 100644 --- a/contracts/satoshi-bridge/tests/test_orchard_validation.rs +++ b/contracts/satoshi-bridge/tests/test_orchard_validation.rs @@ -31,6 +31,7 @@ async fn test_orchard_wrong_recipient() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -42,6 +43,7 @@ async fn test_orchard_wrong_recipient() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -156,6 +158,7 @@ async fn test_orchard_missing_bundle() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -167,6 +170,7 @@ async fn test_orchard_missing_bundle() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -251,6 +255,7 @@ async fn test_orchard_bundle_in_zcash_tx() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -262,6 +267,7 @@ async fn test_orchard_bundle_in_zcash_tx() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( diff --git a/contracts/satoshi-bridge/tests/test_orchard_withdrawal.rs b/contracts/satoshi-bridge/tests/test_orchard_withdrawal.rs index 618c29a..184a1bd 100644 --- a/contracts/satoshi-bridge/tests/test_orchard_withdrawal.rs +++ b/contracts/satoshi-bridge/tests/test_orchard_withdrawal.rs @@ -32,6 +32,7 @@ async fn test_orchard_withdrawal_with_ovk_validation() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -64,6 +65,7 @@ async fn test_orchard_withdrawal_with_ovk_validation() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, zcash_tx_bytes, 1, @@ -164,6 +166,7 @@ async fn test_orchard_withdrawal_amount_mismatch() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -188,6 +191,7 @@ async fn test_orchard_withdrawal_amount_mismatch() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, zcash_tx_bytes, 1, diff --git a/contracts/satoshi-bridge/tests/test_refund.rs b/contracts/satoshi-bridge/tests/test_refund.rs new file mode 100644 index 0000000..7d83b8b --- /dev/null +++ b/contracts/satoshi-bridge/tests/test_refund.rs @@ -0,0 +1,1313 @@ +mod setup; +use bitcoin::{Transaction as BtcTransaction, TxOut}; +use near_sdk::serde_json::json; +use satoshi_bridge::DepositMsg; +use setup::*; + +#[cfg(not(feature = "zcash"))] +const CHAIN: &str = "BitcoinMainnet"; + +#[cfg(not(feature = "zcash"))] +const TARGET_ADDRESS: &str = "1PAGsaT5vDz6hjzvuenSw33hWzESTR3ZHQ"; + +/// Helper: compute tx_id from tx_bytes (same as contract does) +fn compute_tx_id(tx_bytes: &[u8]) -> String { + let tx: BtcTransaction = bitcoin::consensus::deserialize(tx_bytes).unwrap(); + tx.compute_txid().to_string() +} + +/// Helper: build utxo_storage_key = "{tx_id}@{vout}" +fn utxo_storage_key(tx_bytes: &[u8], vout: u32) -> String { + format!("{}@{}", compute_tx_id(tx_bytes), vout) +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_basic_flow() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let refund_btc_address = TARGET_ADDRESS; + + // 1. Get deposit address with refund_address set + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(refund_btc_address.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + assert!(!deposit_address.is_empty()); + + // 2. Build a BTC transaction that sends to the deposit address + let tx_bytes = generate_transaction_bytes( + vec![( + "a2a5069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f19", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + + // 3. Verify that UTXO is not yet known to the bridge + assert_eq!(context.get_utxos_paged().await.unwrap().len(), 0); + + // 4. Request refund (anyone can call, proves tx via Light Client) + check!( + print "request_refund" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d" + .to_string(), + 1, + vec![], + None + ) + ); + + let key = utxo_storage_key(&tx_bytes, vout); + + // 5. Set timelock to 200 seconds + context + .get_account_by_name("root") + .call(context.bridge_contract.id(), "set_refund_timelock_sec") + .args_json(json!({"refund_timelock_sec": 200})) + .deposit(near_sdk::NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await + .unwrap() + .unwrap(); + + // 6. Execute immediately — should fail (timelock not passed) + check!( + context.execute_refund("alice", &key), + "Refund timelock has not passed yet" + ); + + // 7. Fast-forward past timelock (3600 seconds = ~3600 blocks) + worker.fast_forward(4000).await.unwrap(); + + // 8. Execute refund — timelock passed, should succeed + + let storage_before = context + .bridge_contract + .view_account() + .await + .unwrap() + .storage_usage; + + check!( + print "execute_refund" + context.execute_refund("alice", &key) + ); + + let storage_after = context + .bridge_contract + .view_account() + .await + .unwrap() + .storage_usage; + + let storage_used = storage_after - storage_before; + let cost_per_byte = 10u128.pow(19); // 0.00001 NEAR per byte + let storage_cost_yocto = storage_used as u128 * cost_per_byte; + println!("==> Storage used by execute_refund: {} bytes", storage_used); + println!("==> Storage cost: {} yoctoNEAR", storage_cost_yocto); + println!( + "==> Storage cost: {:.4} NEAR", + storage_cost_yocto as f64 / 1e24 + ); + + // Verify that required_balance_for_execute_refund covers actual storage cost + let required_balance = context.required_balance_for_execute_refund().await.unwrap(); + println!( + "==> required_balance_for_execute_refund: {} yoctoNEAR ({:.4} NEAR)", + required_balance.as_yoctonear(), + required_balance.as_yoctonear() as f64 / 1e24 + ); + assert!( + required_balance.as_yoctonear() >= storage_cost_yocto, + "required_balance_for_execute_refund ({}) is less than actual storage cost ({})", + required_balance.as_yoctonear(), + storage_cost_yocto, + ); + + // 7. BTCPendingInfo should exist, pending sign + let pending_infos = context.get_btc_pending_infos_paged().await.unwrap(); + assert_eq!(pending_infos.len(), 1); + let pending_keys = pending_infos.keys().cloned().collect::>(); + let pending_values = pending_infos.values().cloned().collect::>(); + pending_values[0].assert_pending_sign(); + + // 8. Sign the refund transaction (1 input) + check!( + print "sign_btc_transaction" + context.sign_btc_transaction("alice", &pending_keys[0], 0, 0) + ); + + // 9. After signing all inputs, should transition to pending_verify + let pending_infos = context.get_btc_pending_infos_paged().await.unwrap(); + let pending_values = pending_infos.values().cloned().collect::>(); + pending_values[0].assert_pending_verify(); + + // 10. Verify refund transaction on-chain (like verify_withdraw but no burn) + let pending_infos = context.get_btc_pending_infos_paged().await.unwrap(); + let pending_keys = pending_infos.keys().cloned().collect::>(); + check!( + print "verify_refund_finalize" + context.verify_refund_finalize( + "relayer", + &pending_keys[0], + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(), + 1, + vec![] + ) + ); + + // 11. Pending info cleaned up + assert!(context + .get_btc_pending_infos_paged() + .await + .unwrap() + .is_empty()); + + // 12. Refund request is gone (can't execute twice) + check!( + context.execute_refund("alice", &key), + "Refund request not found" + ); + + // 13. No nBTC was minted + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 0); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_reject() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "b3b5069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f20", + 0, + None, + )], + vec![(deposit_address.as_str(), 50_000)], + ); + let vout: u32 = 0; + + // Request refund + check!( + print "request_refund" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d" + .to_string(), + 1, + vec![], + None + ) + ); + + let key = utxo_storage_key(&tx_bytes, vout); + + // DAO rejects the refund + check!( + print "reject_refund" + context.reject_refund("root", &key) + ); + + // Can't execute after rejection + check!( + context.execute_refund("alice", &key), + "Refund request not found" + ); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_no_refund_address() { + // refund_address in DepositMsg is optional — refund_address is now a separate parameter. + // This test verifies that request_refund succeeds even when DepositMsg has no refund_address. + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: None, + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "c4c5069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f21", + 0, + None, + )], + vec![(deposit_address.as_str(), 50_000)], + ); + + // Should succeed — refund_address provided as separate parameter + check!( + print "request_refund_no_addr_in_msg" + context.request_refund( + "alice", + deposit_msg, + TARGET_ADDRESS, + tx_bytes, + 0, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(), + 1, + vec![], + None + ) + ); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_duplicate_request() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "d5d5069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f22", + 0, + None, + )], + vec![(deposit_address.as_str(), 50_000)], + ); + + // First request — should succeed + check!( + print "first request" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + 0, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d" + .to_string(), + 1, + vec![], + None + ) + ); + + // Second request for same UTXO — should fail + check!( + context.request_refund( + "alice", + deposit_msg, + TARGET_ADDRESS, + tx_bytes, + 0, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(), + 1, + vec![], + None + ), + "Refund request already exists for this UTXO" + ); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_then_deposit_fails() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "e6e6069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f23", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // 1. Request refund + check!( + print "request_refund" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![], + None + ) + ); + + // 2. Set timelock to 0 and execute refund + context + .get_account_by_name("root") + .call(context.bridge_contract.id(), "set_refund_timelock_sec") + .args_json(json!({"refund_timelock_sec": 0})) + .deposit(near_sdk::NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await + .unwrap() + .unwrap(); + + let key = utxo_storage_key(&tx_bytes, vout); + check!( + print "execute_refund" + context.execute_refund("alice", &key) + ); + + // 3. verify_deposit blocked RIGHT AFTER execute_refund (before sign) + check!( + context.verify_deposit( + "relayer", + deposit_msg.clone(), + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![] + ), + "Already deposit utxo" + ); + + // 4. Sign the refund transaction + let pending_infos = context.get_btc_pending_infos_paged().await.unwrap(); + let pending_keys = pending_infos.keys().cloned().collect::>(); + check!( + print "sign_btc_transaction" + context.sign_btc_transaction("alice", &pending_keys[0], 0, 0) + ); + + // 5. verify_deposit STILL blocked after sign (after broadcast) + check!( + context.verify_deposit( + "relayer", + deposit_msg.clone(), + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![] + ), + "Already deposit utxo" + ); + + // 6. verify_refund_finalize — finalize the refund + check!( + print "verify_refund_finalize" + context.verify_refund_finalize( + "relayer", + &pending_keys[0], + blockhash.clone(), + 1, + vec![] + ) + ); + + // 7. Pending info cleaned up + assert!(context + .get_btc_pending_infos_paged() + .await + .unwrap() + .is_empty()); + + // 8. verify_deposit STILL blocked after verify_refund_finalize + check!( + context.verify_deposit("relayer", deposit_msg, tx_bytes, vout, blockhash, 1, vec![]), + "Already deposit utxo" + ); + + // 9. No nBTC was minted + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 0); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_race_deposit_wins() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "f7f7069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f24", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // 1. Request refund + check!( + print "request_refund" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![], + None + ) + ); + + // 2. During timelock, Relayer calls verify_deposit — deposit succeeds + check!( + print "verify_deposit" + context.verify_deposit( + "relayer", + deposit_msg.clone(), + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![] + ) + ); + + // 3. nBTC minted to alice + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 100_000); + + // 4. Set timelock to 0 + context + .get_account_by_name("root") + .call(context.bridge_contract.id(), "set_refund_timelock_sec") + .args_json(json!({"refund_timelock_sec": 0})) + .deposit(near_sdk::NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await + .unwrap() + .unwrap(); + + // 5. execute_refund fails — UTXO already verified via deposit + let key = utxo_storage_key(&tx_bytes, vout); + check!( + context.execute_refund("alice", &key), + "UTXO already verified via deposit, cannot refund" + ); + + // 6. nBTC still there — deposit was the winner + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 100_000); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_after_deposit_fails() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "a8a8069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f25", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // 1. verify_deposit — Relayer finalizes deposit first + check!( + print "verify_deposit" + context.verify_deposit( + "relayer", + deposit_msg.clone(), + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![] + ) + ); + + // 2. nBTC minted to alice + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 100_000); + + // 3. request_refund fails — UTXO already verified via deposit + check!( + context.request_refund( + "alice", + deposit_msg, + TARGET_ADDRESS, + tx_bytes, + vout, + blockhash, + 1, + vec![], + None + ), + "UTXO already verified via deposit" + ); + + // 4. nBTC still there + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 100_000); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_reject_then_deposit_succeeds() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "b9b9069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f26", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // 1. Request refund + check!( + print "request_refund" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![], + None + ) + ); + + let key = utxo_storage_key(&tx_bytes, vout); + + // 2. DAO rejects the refund + check!( + print "reject_refund" + context.reject_refund("root", &key) + ); + + // 3. execute_refund fails — request was rejected + check!( + context.execute_refund("alice", &key), + "Refund request not found" + ); + + // 4. verify_deposit works normally — UTXO was not marked + check!( + print "verify_deposit" + context.verify_deposit( + "relayer", + deposit_msg, + tx_bytes, + vout, + blockhash, + 1, + vec![] + ) + ); + + // 5. nBTC minted to alice + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 100_000); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_double_request_after_execute() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "caca069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f27", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // 1. Request refund + check!( + print "request_refund" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![], + None + ) + ); + + // 2. Set timelock to 0 and execute refund + context + .get_account_by_name("root") + .call(context.bridge_contract.id(), "set_refund_timelock_sec") + .args_json(json!({"refund_timelock_sec": 0})) + .deposit(near_sdk::NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await + .unwrap() + .unwrap(); + + let key = utxo_storage_key(&tx_bytes, vout); + check!( + print "execute_refund" + context.execute_refund("alice", &key) + ); + + // 3. Second request_refund — should fail (UTXO marked in verified_deposit_utxo) + check!( + context.request_refund( + "alice", + deposit_msg, + TARGET_ADDRESS, + tx_bytes, + vout, + blockhash, + 1, + vec![], + None + ), + "UTXO already verified via deposit" + ); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_spoofed_refund_address() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + // Alice creates a real deposit with her refund address + let real_deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(real_deposit_msg.clone()) + .await + .unwrap(); + + // BTC transaction sends to the real deposit address + let tx_bytes = generate_transaction_bytes( + vec![( + "dbdb069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f28", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // Attacker creates a spoofed deposit_msg with a DIFFERENT refund_address + // but same recipient_id — this changes the hash, so script_pubkey won't match + let spoofed_deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa".to_string()), // attacker's address + }; + + // request_refund with spoofed deposit_msg — callback should panic + // because script_pubkey derived from spoofed msg won't match tx output + check!( + context.request_refund( + "bob", + spoofed_deposit_msg, + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![], + None + ), + "refund_address does not match deposit_msg.refund_address" + ); + + // Real request_refund still works + check!( + print "real request_refund" + context.request_refund( + "alice", + real_deposit_msg, + TARGET_ADDRESS, + tx_bytes, + vout, + blockhash, + 1, + vec![], + None + ) + ); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_race_safe_deposit_wins() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: Some(satoshi_bridge::SafeDepositMsg { + msg: "".to_string(), + }), + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "ecec069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f29", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // 1. Request refund + check!( + print "request_refund" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![], + None + ) + ); + + // 2. During timelock, Relayer calls safe_verify_deposit — succeeds + // Register alice in nBTC first (safe_mint requires it) + check!(context.storage_deposit("nbtc", "alice")); + check!( + print "safe_verify_deposit" + context.safe_verify_deposit( + "relayer", + deposit_msg.clone(), + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![] + ) + ); + + // 3. nBTC minted to alice + assert!(context.ft_balance_of("alice").await.unwrap().0 > 0); + + // 4. Set timelock to 0 + context + .get_account_by_name("root") + .call(context.bridge_contract.id(), "set_refund_timelock_sec") + .args_json(json!({"refund_timelock_sec": 0})) + .deposit(near_sdk::NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await + .unwrap() + .unwrap(); + + // 5. execute_refund fails — UTXO already verified via safe deposit + let key = utxo_storage_key(&tx_bytes, vout); + check!( + context.execute_refund("alice", &key), + "UTXO already verified via deposit, cannot refund" + ); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_after_safe_deposit_fails() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: Some(satoshi_bridge::SafeDepositMsg { + msg: "".to_string(), + }), + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "fdfd069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f30", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // 1. Register alice in nBTC and do safe_verify_deposit + check!(context.storage_deposit("nbtc", "alice")); + check!( + print "safe_verify_deposit" + context.safe_verify_deposit( + "relayer", + deposit_msg.clone(), + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![] + ) + ); + + // 2. nBTC minted + assert!(context.ft_balance_of("alice").await.unwrap().0 > 0); + + // 3. request_refund fails — UTXO already verified + check!( + context.request_refund( + "alice", + deposit_msg, + TARGET_ADDRESS, + tx_bytes, + vout, + blockhash, + 1, + vec![], + None + ), + "UTXO already verified via deposit" + ); +} + +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_then_safe_deposit_fails() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: Some(satoshi_bridge::SafeDepositMsg { + msg: "".to_string(), + }), + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "abab069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f31", + 0, + None, + )], + vec![(deposit_address.as_str(), 100_000)], + ); + let vout: u32 = 0; + let blockhash = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(); + + // Register alice in nBTC (needed for safe_verify_deposit attempts) + check!(context.storage_deposit("nbtc", "alice")); + + // 1. Request refund + check!( + print "request_refund" + context.request_refund( + "alice", + deposit_msg.clone(), + TARGET_ADDRESS, + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![], + None + ) + ); + + // 2. Set timelock to 0 and execute refund + context + .get_account_by_name("root") + .call(context.bridge_contract.id(), "set_refund_timelock_sec") + .args_json(json!({"refund_timelock_sec": 0})) + .deposit(near_sdk::NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await + .unwrap() + .unwrap(); + + let key = utxo_storage_key(&tx_bytes, vout); + check!( + print "execute_refund" + context.execute_refund("alice", &key) + ); + + // 3. safe_verify_deposit blocked RIGHT AFTER execute_refund (before sign) + check!( + context.safe_verify_deposit( + "relayer", + deposit_msg.clone(), + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![] + ), + "Already deposit utxo" + ); + + // 4. Sign the refund transaction + let pending_infos = context.get_btc_pending_infos_paged().await.unwrap(); + let pending_keys = pending_infos.keys().cloned().collect::>(); + check!( + print "sign_btc_transaction" + context.sign_btc_transaction("alice", &pending_keys[0], 0, 0) + ); + + // 5. safe_verify_deposit STILL blocked after sign (after broadcast) + check!( + context.safe_verify_deposit( + "relayer", + deposit_msg.clone(), + tx_bytes.clone(), + vout, + blockhash.clone(), + 1, + vec![] + ), + "Already deposit utxo" + ); + + // 6. verify_refund_finalize — finalize the refund + check!( + print "verify_refund_finalize" + context.verify_refund_finalize( + "relayer", + &pending_keys[0], + blockhash.clone(), + 1, + vec![] + ) + ); + + // 7. Cleaned up + assert!(context + .get_btc_pending_infos_paged() + .await + .unwrap() + .is_empty()); + + // 8. safe_verify_deposit STILL blocked after verify_refund_finalize + check!( + context.safe_verify_deposit("relayer", deposit_msg, tx_bytes, vout, blockhash, 1, vec![]), + "Already deposit utxo" + ); + + // 9. No nBTC minted + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 0); +} + +// ── refund_address matching tests ── + +/// deposit_msg.refund_address is set and matches the provided refund_address — works +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_address_matches_deposit_msg() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "e1e1069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f30", + 0, + None, + )], + vec![(deposit_address.as_str(), 50_000)], + ); + + // refund_address matches deposit_msg.refund_address — should succeed + check!( + print "request_refund matching address" + context.request_refund( + "alice", + deposit_msg, + TARGET_ADDRESS, + tx_bytes, + 0, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d" + .to_string(), + 1, + vec![], + None + ) + ); +} + +/// deposit_msg.refund_address is None — provided refund_address is used +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_address_none_in_deposit_msg() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + // No refund_address in deposit_msg + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: None, + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "f2f2069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f31", + 0, + None, + )], + vec![(deposit_address.as_str(), 50_000)], + ); + + // refund_address provided externally — should succeed + check!( + print "request_refund with external address" + context.request_refund( + "alice", + deposit_msg, + TARGET_ADDRESS, + tx_bytes, + 0, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d" + .to_string(), + 1, + vec![], + None + ) + ); +} + +/// deposit_msg.refund_address is set but doesn't match — should fail +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_refund_address_mismatch() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + let deposit_msg = DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: Some(TARGET_ADDRESS.to_string()), + }; + + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + let tx_bytes = generate_transaction_bytes( + vec![( + "a3a3069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f32", + 0, + None, + )], + vec![(deposit_address.as_str(), 50_000)], + ); + + let wrong_address = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + + // refund_address doesn't match deposit_msg.refund_address — should fail + check!( + context.request_refund( + "alice", + deposit_msg, + wrong_address, + tx_bytes, + 0, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(), + 1, + vec![], + None + ), + "refund_address does not match deposit_msg.refund_address" + ); +} diff --git a/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs b/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs index 256ab58..ce67bc5 100644 --- a/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs +++ b/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs @@ -114,6 +114,7 @@ async fn test_base() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -123,6 +124,7 @@ async fn test_base() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -134,6 +136,7 @@ async fn test_base() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -182,6 +185,7 @@ async fn test_base() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -231,6 +235,7 @@ async fn test_base() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -264,6 +269,7 @@ async fn test_base() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -483,6 +489,7 @@ async fn test_fix_bridge_fee_and_relayer() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -494,6 +501,7 @@ async fn test_fix_bridge_fee_and_relayer() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -623,6 +631,7 @@ async fn test_ratio_bridge_fee_and_relayer() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -634,6 +643,7 @@ async fn test_ratio_bridge_fee_and_relayer() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -766,6 +776,7 @@ async fn test_directly_withdraw() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -777,6 +788,7 @@ async fn test_directly_withdraw() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -895,6 +907,7 @@ async fn test_one_click() { }]), extra_msg: None, safe_deposit: None, + refund_address: None, }; let alice_btc_deposit_address = context .get_user_deposit_address(deposit_msg.clone()) @@ -955,6 +968,7 @@ async fn test_one_click() { }]), extra_msg: None, safe_deposit: None, + refund_address: None, }; let alice_btc_deposit_address = context .get_user_deposit_address(deposit_msg.clone()) @@ -1012,6 +1026,7 @@ async fn test_one_click() { }]), extra_msg: None, safe_deposit: None, + refund_address: None, }; let alice_btc_deposit_address = context .get_user_deposit_address(deposit_msg.clone()) @@ -1078,6 +1093,7 @@ async fn test_one_click() { ]), extra_msg: None, safe_deposit: None, + refund_address: None, }; let alice_btc_deposit_address = context .get_user_deposit_address(deposit_msg.clone()) @@ -1151,6 +1167,7 @@ async fn test_one_click() { ]), extra_msg: None, safe_deposit: None, + refund_address: None, }; let alice_btc_deposit_address = context .get_user_deposit_address(deposit_msg.clone()) @@ -1208,6 +1225,7 @@ async fn test_one_click() { }]), extra_msg: None, safe_deposit: None, + refund_address: None, }; let alice_btc_deposit_address = context .get_user_deposit_address(deposit_msg.clone()) @@ -1274,6 +1292,7 @@ async fn test_one_click() { ]), extra_msg: None, safe_deposit: None, + refund_address: None, }; let alice_btc_deposit_address = context .get_user_deposit_address(deposit_msg.clone()) @@ -1341,6 +1360,7 @@ async fn test_one_click() { ]), extra_msg: None, safe_deposit: None, + refund_address: None, }; let alice_btc_deposit_address = context .get_user_deposit_address(deposit_msg.clone()) @@ -1403,6 +1423,7 @@ async fn test_utxo_passive_management() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -1415,6 +1436,7 @@ async fn test_utxo_passive_management() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -1439,6 +1461,7 @@ async fn test_utxo_passive_management() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -1625,6 +1648,7 @@ async fn test_cancel_withdraw() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -1636,6 +1660,7 @@ async fn test_cancel_withdraw() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -1860,6 +1885,7 @@ async fn test_cancel_withdraw2() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -1871,6 +1897,7 @@ async fn test_cancel_withdraw2() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -2044,6 +2071,7 @@ async fn test_utxo_active_management() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -2056,6 +2084,7 @@ async fn test_utxo_active_management() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -2080,6 +2109,7 @@ async fn test_utxo_active_management() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -2413,6 +2443,7 @@ async fn test_utxo_active_management2() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -2425,6 +2456,7 @@ async fn test_utxo_active_management2() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -2449,6 +2481,7 @@ async fn test_utxo_active_management2() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, generate_transaction_bytes( vec![( @@ -2618,6 +2651,7 @@ async fn test_unauthorized_account_cannot_call_trusted_relayer_methods() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }) .await .unwrap(); @@ -2631,6 +2665,7 @@ async fn test_unauthorized_account_cannot_call_trusted_relayer_methods() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, "tx_bytes": generate_transaction_bytes( vec![( @@ -2665,6 +2700,7 @@ async fn test_unauthorized_account_cannot_call_trusted_relayer_methods() { post_actions: None, extra_msg: None, safe_deposit: None, + refund_address: None, }, "tx_bytes": generate_transaction_bytes( vec![( diff --git a/doc/refund-after-deposit-fails-flow.md b/doc/refund-after-deposit-fails-flow.md new file mode 100644 index 0000000..59ad4f4 --- /dev/null +++ b/doc/refund-after-deposit-fails-flow.md @@ -0,0 +1,71 @@ +# Refund after verify_deposit — request_refund blocked + +Relayer already called `verify_deposit`, UTXO is in `verified_deposit_utxo`, +nBTC minted. User tries `request_refund` for the same UTXO — fails immediately +because UTXO is already finalized. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `verify_deposit` | `contracts/satoshi-bridge/src/api/bridge.rs:22` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `verify_deposit_callback` | `contracts/satoshi-bridge/src/btc_light_client/deposit.rs:147` | +| 5 | `mint` | `contracts/nbtc/src/lib.rs:118` | +| 6 | `mint_callback` | `contracts/satoshi-bridge/src/nbtc/mint.rs:46` | +| 7 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant N as nBTC Contract + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + R->>B: verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:22 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: verify_deposit_callback
📄 btc_light_client/deposit.rs:147 + + B->>N: mint(recipient_id, mint_amount)
📄 nbtc/src/lib.rs:118 + N-->>B: OK + + Note over B: mint_callback
📄 nbtc/mint.rs:46 + B-->>U: nBTC credited to recipient + + Note over U,B: User tries to request refund + + rect rgb(255, 200, 200) + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + Note over B: PANIC: "UTXO already verified via deposit" + end + + Note over B: Deposit completed, nBTC minted,
refund impossible +``` diff --git a/doc/refund-after-safe-deposit-fails-flow.md b/doc/refund-after-safe-deposit-fails-flow.md new file mode 100644 index 0000000..6e25769 --- /dev/null +++ b/doc/refund-after-safe-deposit-fails-flow.md @@ -0,0 +1,69 @@ +# Refund after safe_verify_deposit — request_refund blocked + +Relayer already called `safe_verify_deposit`, UTXO is in `verified_deposit_utxo`, +nBTC minted via OmniBridge. User tries `request_refund` for the same UTXO — +fails immediately because UTXO is already finalized. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `safe_verify_deposit` | `contracts/satoshi-bridge/src/api/bridge.rs:95` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `verify_safe_deposit_callback` | `contracts/satoshi-bridge/src/btc_light_client/deposit.rs:179` | +| 5 | `safe_mint` | `contracts/nbtc/src/lib.rs:96` | +| 6 | `safe_mint_callback` | `contracts/satoshi-bridge/src/btc_light_client/deposit.rs:214` | +| 7 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant N as nBTC Contract + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, safe_deposit: {msg},
refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + R->>B: safe_verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:95 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + + Note over B: verify_safe_deposit_callback + + B->>N: safe_mint(recipient, amount, msg)
📄 nbtc/src/lib.rs:96 + N-->>B: OK + + Note over B: safe_mint_callback + B-->>U: nBTC credited to recipient + + Note over U,B: User tries to request refund + + rect rgb(255, 200, 200) + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + Note over B: PANIC: "UTXO already verified via deposit" + end +``` diff --git a/doc/refund-double-request-after-execute-flow.md b/doc/refund-double-request-after-execute-flow.md new file mode 100644 index 0000000..ba49404 --- /dev/null +++ b/doc/refund-double-request-after-execute-flow.md @@ -0,0 +1,66 @@ +# Refund — second request_refund after execute blocked + +User requests refund, executes it. Then tries `request_refund` again +for the same UTXO — fails because `execute_refund` marked the UTXO +in `verified_deposit_utxo`. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `request_refund_callback` | `contracts/satoshi-bridge/src/refund.rs` | +| 5 | `execute_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:454` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + + Note over B: request_refund_callback + B->>B: Save RefundRequest + + Note over B: Timelock passes + + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + Note over B: UTXO added to verified_deposit_utxo + + Note over U,B: User tries request_refund again + + rect rgb(255, 200, 200) + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + Note over B: PANIC: "UTXO already verified via deposit" + end +``` diff --git a/doc/refund-flow.md b/doc/refund-flow.md new file mode 100644 index 0000000..788fd51 --- /dev/null +++ b/doc/refund-flow.md @@ -0,0 +1,91 @@ +# Refund Flow (BTC → BTC, deposit never finalized) + +User deposited BTC with `refund_address` in DepositMsg, but `verify_deposit` was never called. +Anyone who knows the deposit_msg can request a refund. After timelock passes, DAO/Operator +executes the refund — BTC is sent back to the refund address. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `DepositMsg.refund_address` | `contracts/satoshi-bridge/src/deposit_msg.rs:27` | +| 3 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | +| 4 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 5 | `request_refund_callback` | `contracts/satoshi-bridge/src/refund.rs:170` | +| 6 | `execute_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:454` | +| 7 | `sign_btc_transaction` | `contracts/satoshi-bridge/src/api/chain_signatures.rs:21` | +| 8 | `sign` (MPC) | `contracts/satoshi-bridge/src/chain_signature.rs:57` | +| 9 | `sign_btc_transaction_callback` | `contracts/satoshi-bridge/src/chain_signature.rs:135` | +| 10 | `verify_refund_finalize` | `contracts/satoshi-bridge/src/api/bridge.rs` | +| 11 | `verify_refund_finalize_callback` | `contracts/satoshi-bridge/src/refund.rs` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant MPC as Chain Signatures
(MPC) + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + Note over B: verify_deposit never called
(relayer down, user changed mind, etc.) + + U->>B: request_refund(deposit_msg,
tx_bytes, vout, tx_block_blockhash,
tx_index, merkle_proof)
📄 api/bridge.rs:426 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid / invalid + + Note over B: request_refund_callback
📄 refund.rs:170 + B->>B: Save RefundRequest {
utxo_storage_key, amount, created_at} + + rect rgb(255, 200, 200) + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + Note over B: PANIC: "Refund timelock has not passed yet" + end + + Note over B: Timelock period passes
(refund_timelock_sec) + + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + Note over B: Check: timelock passed?
Check: UTXO not in verified_deposit_utxo? + Note over B: Build PSBT:
input = deposit UTXO
output = refund_address
remainder = gas fee + + U->>B: sign_btc_transaction(sign_index)
📄 api/chain_signatures.rs:21 + + B->>MPC: sign(payload, path, key_version)
📄 chain_signature.rs:57 + MPC-->>B: signature + + Note over B: sign_btc_transaction_callback
📄 chain_signature.rs:135 + + U->>BTC: Broadcast refund transaction + BTC-->>U: BTC returned to refund_address + + R->>B: verify_refund_finalize(tx_id, tx_block_blockhash,
tx_index, merkle_proof)
📄 api/bridge.rs + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: verify_refund_finalize_callback
📄 refund.rs + B->>B: Remove BTCPendingInfo +``` diff --git a/doc/refund-no-withdraw-flow.md b/doc/refund-no-withdraw-flow.md new file mode 100644 index 0000000..077de6e --- /dev/null +++ b/doc/refund-no-withdraw-flow.md @@ -0,0 +1,96 @@ +# Refund Flow — verify_withdraw / withdraw_rbf / cancel_withdraw blocked + +After refund is executed and signed, the refund BTCPendingInfo has state `Refund`. +`verify_withdraw`, `withdraw_rbf`, and `cancel_withdraw` all reject it — +they only accept Withdraw-related states. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `request_refund_callback` | `contracts/satoshi-bridge/src/refund.rs:170` | +| 5 | `execute_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:454` | +| 6 | `sign_btc_transaction` | `contracts/satoshi-bridge/src/api/chain_signatures.rs:21` | +| 7 | `sign` (MPC) | `contracts/satoshi-bridge/src/chain_signature.rs:57` | +| 8 | `sign_btc_transaction_callback` | `contracts/satoshi-bridge/src/chain_signature.rs:135` | +| 9 | `verify_withdraw` | `contracts/satoshi-bridge/src/api/bridge.rs:168` | +| 10 | `withdraw_rbf` | `contracts/satoshi-bridge/src/api/bridge.rs:203` | +| 11 | `cancel_withdraw` | `contracts/satoshi-bridge/src/api/bridge.rs:234` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant MPC as Chain Signatures
(MPC) + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: request_refund_callback
📄 refund.rs:170 + B->>B: Save RefundRequest + + Note over B: Timelock passes + + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + + U->>B: sign_btc_transaction(sign_index)
📄 api/chain_signatures.rs:21 + + B->>MPC: sign(payload, path, key_version)
📄 chain_signature.rs:57 + MPC-->>B: signature + + Note over B: sign_btc_transaction_callback
📄 chain_signature.rs:135 + + U->>BTC: Broadcast refund transaction + BTC-->>U: BTC returned to refund_address + + Note over R,B: Attempts to use withdraw operations on refund tx + + rect rgb(255, 200, 200) + R->>B: verify_withdraw(tx_id)
📄 api/bridge.rs:168 + Note over B: PANIC: "Not withdraw related tx" + + U->>B: withdraw_rbf(btc_pending_verify_id)
📄 api/bridge.rs:203 + Note over B: PANIC: "Not original tx" + + R->>B: cancel_withdraw(btc_pending_verify_id)
📄 api/bridge.rs:234 + Note over B: PANIC: "Not original tx" + end + + R->>B: verify_refund_finalize(tx_id, tx_proof)
📄 api/bridge.rs + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: verify_refund_finalize_callback
📄 refund.rs + B->>B: Remove BTCPendingInfo +``` diff --git a/doc/refund-race-deposit-wins-flow.md b/doc/refund-race-deposit-wins-flow.md new file mode 100644 index 0000000..43ff9b2 --- /dev/null +++ b/doc/refund-race-deposit-wins-flow.md @@ -0,0 +1,86 @@ +# Refund Race — verify_deposit wins before execute_refund + +User requests refund, but during timelock period the Relayer calls `verify_deposit`. +Deposit succeeds, UTXO is marked in `verified_deposit_utxo`. +When user tries `execute_refund` — it fails because UTXO is already finalized. +nBTC is minted normally. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `request_refund_callback` | `contracts/satoshi-bridge/src/refund.rs` | +| 5 | `verify_deposit` | `contracts/satoshi-bridge/src/api/bridge.rs:22` | +| 6 | `verify_deposit_callback` | `contracts/satoshi-bridge/src/btc_light_client/deposit.rs:147` | +| 7 | `mint` | `contracts/nbtc/src/lib.rs:118` | +| 8 | `mint_callback` | `contracts/satoshi-bridge/src/nbtc/mint.rs:46` | +| 9 | `execute_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:454` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant N as nBTC Contract + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: request_refund_callback + B->>B: Save RefundRequest + + Note over B: Timelock period (waiting...) + + Note over R,B: Relayer calls verify_deposit during timelock + + R->>B: verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:22 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: verify_deposit_callback
📄 btc_light_client/deposit.rs:147 + + B->>N: mint(recipient_id, mint_amount)
📄 nbtc/src/lib.rs:118 + N-->>B: OK + + Note over B: mint_callback
📄 nbtc/mint.rs:46 + B-->>U: nBTC credited to recipient + + Note over U,B: User tries execute_refund after timelock + + rect rgb(255, 200, 200) + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + Note over B: PANIC: "UTXO already verified via deposit,
cannot refund" + end + + Note over B: Deposit completed normally,
nBTC minted, refund impossible +``` diff --git a/doc/refund-race-safe-deposit-wins-flow.md b/doc/refund-race-safe-deposit-wins-flow.md new file mode 100644 index 0000000..f08b4fe --- /dev/null +++ b/doc/refund-race-safe-deposit-wins-flow.md @@ -0,0 +1,83 @@ +# Refund Race — safe_verify_deposit wins before execute_refund + +User requests refund, but during timelock period the Relayer calls `safe_verify_deposit` +(OmniBridge integration). Safe deposit succeeds, UTXO is marked in `verified_deposit_utxo`. +When user tries `execute_refund` — it fails because UTXO is already finalized. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `request_refund_callback` | `contracts/satoshi-bridge/src/refund.rs` | +| 5 | `safe_verify_deposit` | `contracts/satoshi-bridge/src/api/bridge.rs:95` | +| 6 | `verify_safe_deposit_callback` | `contracts/satoshi-bridge/src/btc_light_client/deposit.rs:179` | +| 7 | `safe_mint` | `contracts/nbtc/src/lib.rs:96` | +| 8 | `safe_mint_callback` | `contracts/satoshi-bridge/src/btc_light_client/deposit.rs:214` | +| 9 | `execute_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:454` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant N as nBTC Contract + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, safe_deposit: {msg},
refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + + Note over B: request_refund_callback + B->>B: Save RefundRequest + + Note over B: Timelock period (waiting...) + + Note over R,B: Relayer calls safe_verify_deposit during timelock + + R->>B: safe_verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:95 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + + Note over B: verify_safe_deposit_callback + + B->>N: safe_mint(recipient, amount, msg)
📄 nbtc/src/lib.rs:96 + N-->>B: OK + + Note over B: safe_mint_callback + B-->>U: nBTC credited to recipient + + Note over U,B: User tries execute_refund after timelock + + rect rgb(255, 200, 200) + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + Note over B: PANIC: "UTXO already verified via deposit,
cannot refund" + end +``` diff --git a/doc/refund-reject-then-deposit-flow.md b/doc/refund-reject-then-deposit-flow.md new file mode 100644 index 0000000..36df450 --- /dev/null +++ b/doc/refund-reject-then-deposit-flow.md @@ -0,0 +1,81 @@ +# Refund Reject → Deposit succeeds + +User requests refund, DAO rejects it. After rejection the UTXO is NOT marked +in `verified_deposit_utxo`, so normal `verify_deposit` works and nBTC is minted. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `request_refund_callback` | `contracts/satoshi-bridge/src/refund.rs` | +| 5 | `reject_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:447` | +| 6 | `verify_deposit` | `contracts/satoshi-bridge/src/api/bridge.rs:22` | +| 7 | `verify_deposit_callback` | `contracts/satoshi-bridge/src/btc_light_client/deposit.rs:147` | +| 8 | `mint` | `contracts/nbtc/src/lib.rs:118` | +| 9 | `mint_callback` | `contracts/satoshi-bridge/src/nbtc/mint.rs:46` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant N as nBTC Contract + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: request_refund_callback + B->>B: Save RefundRequest + + R->>B: reject_refund(utxo_storage_key)
📄 api/bridge.rs:447 + Note over B: RefundRequest removed,
UTXO NOT marked in verified_deposit_utxo + + rect rgb(255, 200, 200) + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + Note over B: PANIC: "Refund request not found" + end + + Note over R,B: Normal deposit flow proceeds + + R->>B: verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:22 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: verify_deposit_callback
📄 btc_light_client/deposit.rs:147 + + B->>N: mint(recipient_id, mint_amount)
📄 nbtc/src/lib.rs:118 + N-->>B: OK + + Note over B: mint_callback
📄 nbtc/mint.rs:46 + B-->>U: nBTC credited to recipient +``` diff --git a/doc/refund-then-deposit-fails-flow.md b/doc/refund-then-deposit-fails-flow.md new file mode 100644 index 0000000..cfd01e8 --- /dev/null +++ b/doc/refund-then-deposit-fails-flow.md @@ -0,0 +1,105 @@ +# Refund Flow — verify_deposit after refund fails + +User deposits BTC, requests and executes refund. Later, Relayer tries +to call `verify_deposit` for the same UTXO — it fails because the UTXO +was already spent on Bitcoin by the refund transaction. + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `request_refund_callback` | `contracts/satoshi-bridge/src/refund.rs:170` | +| 5 | `execute_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:454` | +| 6 | `sign_btc_transaction` | `contracts/satoshi-bridge/src/api/chain_signatures.rs:21` | +| 7 | `sign` (MPC) | `contracts/satoshi-bridge/src/chain_signature.rs:57` | +| 8 | `sign_btc_transaction_callback` | `contracts/satoshi-bridge/src/chain_signature.rs:135` | +| 9 | `verify_deposit` | `contracts/satoshi-bridge/src/api/bridge.rs:22` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant MPC as Chain Signatures
(MPC) + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: request_refund_callback
📄 refund.rs:170 + B->>B: Save RefundRequest + + Note over B: Timelock passes + + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + + rect rgb(255, 200, 200) + Note over R,B: verify_deposit blocked after execute_refund + R->>B: verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:22 + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + Note over B: PANIC: "Already deposit utxo" + end + + U->>B: sign_btc_transaction(sign_index)
📄 api/chain_signatures.rs:21 + + B->>MPC: sign(payload, path, key_version)
📄 chain_signature.rs:57 + MPC-->>B: signature + + Note over B: sign_btc_transaction_callback
📄 chain_signature.rs:135 + + U->>BTC: Broadcast refund transaction + BTC-->>U: BTC returned to refund_address + + rect rgb(255, 200, 200) + Note over R,B: verify_deposit still blocked after broadcast + R->>B: verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:22 + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + Note over B: PANIC: "Already deposit utxo" + end + + R->>B: verify_refund_finalize(tx_id, tx_proof)
📄 api/bridge.rs + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof)
📄 btc_light_client/mod.rs:113 + LC-->>B: valid + + Note over B: verify_refund_finalize_callback
📄 refund.rs + B->>B: Remove BTCPendingInfo + + rect rgb(255, 200, 200) + Note over R,B: verify_deposit still blocked after verify_refund_finalize + R->>B: verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:22 + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + Note over B: PANIC: "Already deposit utxo" + end +``` diff --git a/doc/refund-then-safe-deposit-fails-flow.md b/doc/refund-then-safe-deposit-fails-flow.md new file mode 100644 index 0000000..2abad68 --- /dev/null +++ b/doc/refund-then-safe-deposit-fails-flow.md @@ -0,0 +1,109 @@ +# Refund executed — safe_verify_deposit blocked + +User requests and executes refund. `execute_refund` marks UTXO in `verified_deposit_utxo`. +Later, Relayer tries `safe_verify_deposit` — Light Client returns valid, +but callback panics with "Already deposit utxo". + +## File References + +| # | Method | File | +|---|--------|------| +| 1 | `get_user_deposit_address` | `contracts/satoshi-bridge/src/api/bridge.rs:406` | +| 2 | `request_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:426` | +| 3 | `verify_transaction_inclusion` | `contracts/satoshi-bridge/src/btc_light_client/mod.rs:113` | +| 4 | `request_refund_callback` | `contracts/satoshi-bridge/src/refund.rs` | +| 5 | `execute_refund` | `contracts/satoshi-bridge/src/api/bridge.rs:454` | +| 6 | `sign_btc_transaction` | `contracts/satoshi-bridge/src/api/chain_signatures.rs:21` | +| 7 | `sign` (MPC) | `contracts/satoshi-bridge/src/chain_signature.rs:57` | +| 8 | `sign_btc_transaction_callback` | `contracts/satoshi-bridge/src/chain_signature.rs:135` | +| 9 | `safe_verify_deposit` | `contracts/satoshi-bridge/src/api/bridge.rs:95` | +| 10 | `verify_refund_finalize` | `contracts/satoshi-bridge/src/api/bridge.rs` | +| 11 | `verify_refund_finalize_callback` | `contracts/satoshi-bridge/src/refund.rs` | + +## Sequence Diagram + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'actorTextColor': '#000000', 'actorLineColor': '#333333', 'signalColor': '#333333', 'signalTextColor': '#000000', 'noteBkgColor': '#fff9c4', 'noteTextColor': '#000000', 'messageFontSize': '14px'}}}%% +sequenceDiagram + autonumber + + box rgb(224, 224, 224) Off-chain + participant U as User + end + + box rgb(255, 224, 178) Bitcoin + participant BTC as Bitcoin Network + end + + box rgb(224, 224, 224) Off-chain + participant R as Relayer + end + + box rgb(187, 222, 251) NEAR Protocol + participant B as btc_connector
(satoshi-bridge) + participant LC as BTC Light Client + participant MPC as Chain Signatures
(MPC) + end + + U->>B: get_user_deposit_address(DepositMsg {
recipient_id, safe_deposit: {msg},
refund_address: "bc1q..."})
📄 api/bridge.rs:406 + B-->>U: BTC deposit address + + U->>BTC: Send BTC to deposit address + Note over BTC: Transaction confirmed + + U->>B: request_refund(deposit_msg, tx_proof)
📄 api/bridge.rs:426 + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + + Note over B: request_refund_callback + B->>B: Save RefundRequest + + Note over B: Timelock passes + + U->>B: execute_refund(utxo_storage_key)
📄 api/bridge.rs:454 + Note over B: UTXO added to verified_deposit_utxo + + rect rgb(255, 200, 200) + Note over R,B: safe_verify_deposit blocked after execute_refund + R->>B: safe_verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:95 + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + Note over B: PANIC: "Already deposit utxo" + end + + U->>B: sign_btc_transaction(sign_index)
📄 api/chain_signatures.rs:21 + + B->>MPC: sign(payload, path, key_version)
📄 chain_signature.rs:57 + MPC-->>B: signature + + Note over B: sign_btc_transaction_callback
📄 chain_signature.rs:135 + + U->>BTC: Broadcast refund transaction + BTC-->>U: BTC returned to refund_address + + rect rgb(255, 200, 200) + Note over R,B: safe_verify_deposit still blocked after broadcast + R->>B: safe_verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:95 + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + Note over B: PANIC: "Already deposit utxo" + end + + R->>B: verify_refund_finalize(tx_id, tx_proof)
📄 api/bridge.rs + + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + + Note over B: verify_refund_finalize_callback + B->>B: Remove BTCPendingInfo + + Note over R,B: Relayer tries safe_verify_deposit + + rect rgb(255, 200, 200) + R->>B: safe_verify_deposit(deposit_msg, tx_proof)
📄 api/bridge.rs:95 + B->>LC: verify_transaction_inclusion(tx_id, merkle_proof) + LC-->>B: valid + Note over B: PANIC: "Already deposit utxo" + end +```