diff --git a/Cargo.lock b/Cargo.lock index 9f283ee5..8460d342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "satoshi-bridge" -version = "0.8.4" +version = "0.8.5" dependencies = [ "bitcoin", "bs58 0.5.1", diff --git a/contracts/satoshi-bridge/Cargo.toml b/contracts/satoshi-bridge/Cargo.toml index 8e3ccd41..fab63f7a 100644 --- a/contracts/satoshi-bridge/Cargo.toml +++ b/contracts/satoshi-bridge/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "satoshi-bridge" -version = "0.8.4" +version = "0.8.5" edition.workspace = true publish.workspace = true repository.workspace = true diff --git a/contracts/satoshi-bridge/src/api/bridge.rs b/contracts/satoshi-bridge/src/api/bridge.rs index 2ec3552c..d0bb5f81 100644 --- a/contracts/satoshi-bridge/src/api/bridge.rs +++ b/contracts/satoshi-bridge/src/api/bridge.rs @@ -620,6 +620,7 @@ impl Contract { max_gas_fee: gas_fee, last_rbf_time_sec: None, cancel_rbf_reserved: None, + subsidize_amount: 0, }), }; require!( diff --git a/contracts/satoshi-bridge/src/api/token_receiver.rs b/contracts/satoshi-bridge/src/api/token_receiver.rs index 736de527..966ec498 100644 --- a/contracts/satoshi-bridge/src/api/token_receiver.rs +++ b/contracts/satoshi-bridge/src/api/token_receiver.rs @@ -15,6 +15,10 @@ pub enum TokenReceiverMessage { max_gas_fee: Option, chain_specific_data: Option, }, + Rbf { + pending_tx_id: String, + output: Vec, + }, } #[near] @@ -27,10 +31,6 @@ impl FungibleTokenReceiver for Contract { msg: String, ) -> PromiseOrValue { let amount = amount.into(); - require!( - amount >= self.internal_config().min_withdraw_amount, - "Invalid amount" - ); let message = serde_json::from_str::(&msg).expect("INVALID MSG"); let token_id = env::predecessor_account_id(); require!( @@ -54,15 +54,26 @@ impl FungibleTokenReceiver for Contract { output, max_gas_fee, chain_specific_data, - } => self.ft_on_transfer_withdraw_chain_specific( - sender_id, - amount, - target_btc_address, - input, + } => { + require!( + amount >= self.internal_config().min_withdraw_amount, + "Invalid amount" + ); + + self.ft_on_transfer_withdraw_chain_specific( + sender_id, + amount, + target_btc_address, + input, + output, + max_gas_fee, + chain_specific_data, + ) + } + TokenReceiverMessage::Rbf { + pending_tx_id, output, - max_gas_fee, - chain_specific_data, - ), + } => self.rbf_subsidize_chain_specific(amount, sender_id, pending_tx_id, output), } } } @@ -119,6 +130,7 @@ impl Contract { max_gas_fee: gas_fee, last_rbf_time_sec: None, cancel_rbf_reserved: None, + subsidize_amount: 0, }), }; require!( diff --git a/contracts/satoshi-bridge/src/bitcoin_utils/contract_methods.rs b/contracts/satoshi-bridge/src/bitcoin_utils/contract_methods.rs index 8571f95b..46685dbb 100644 --- a/contracts/satoshi-bridge/src/bitcoin_utils/contract_methods.rs +++ b/contracts/satoshi-bridge/src/bitcoin_utils/contract_methods.rs @@ -14,7 +14,7 @@ macro_rules! define_rbf_method { original_btc_pending_verify_id: String, output: Vec, _chain_specific_data: Option, - ) { + ) -> String { let predecessor_account_id = env::predecessor_account_id(); let original_tx_btc_pending_info = self.internal_unwrap_btc_pending_info(&original_btc_pending_verify_id); @@ -40,6 +40,8 @@ macro_rules! define_rbf_method { btc_pending_id: &btc_pending_id, } .emit(); + + btc_pending_id } }; } @@ -60,7 +62,10 @@ impl Contract { // Ensure that the RBF transaction pays more gas than the previous transaction. let max_gas_fee = original_tx_btc_pending_info.get_max_gas_fee(); let additional_gas_amount = gas_fee.saturating_sub(max_gas_fee); - require!(additional_gas_amount > 0, "No gas increase."); + require!( + additional_gas_amount > 0, + format!("No gas increase. Old gas fee = {max_gas_fee}, new gas fee = {gas_fee}") + ); } #[allow(clippy::too_many_arguments)] @@ -115,4 +120,56 @@ impl Contract { let original_psbt = original_tx_btc_pending_info.get_psbt(); PsbtWrapper::from_original_psbt(original_psbt, output) } + + pub(crate) fn rbf_subsidize_chain_specific( + &mut self, + amount: u128, + sender_id: AccountId, + pending_tx_id: String, + output: Vec, + ) -> PromiseOrValue { + let origin_tx_btc_pending_info = self.internal_unwrap_btc_pending_info(&pending_tx_id); + let user_account_id = origin_tx_btc_pending_info.account_id.clone(); + self.require_pending_sign_capacity(&user_account_id); + let full_subsidy_amount = self + .internal_unwrap_btc_pending_info(&pending_tx_id) + .get_subsidize_amount() + + amount; + self.internal_unwrap_mut_btc_pending_info(&pending_tx_id) + .update_subsidize_amount(full_subsidy_amount); + + let new_pending_info_id = self.withdraw_rbf_chain_specific( + user_account_id.clone(), + pending_tx_id.clone(), + output, + None, + ); + + let origin_tx_btc_pending_info = self.internal_unwrap_btc_pending_info(&pending_tx_id); + let new_tx_btc_pending_info = self.internal_unwrap_btc_pending_info(&new_pending_info_id); + + require!( + new_tx_btc_pending_info.actual_received_amount + == origin_tx_btc_pending_info.actual_received_amount, + "Actual received amount has been changed." + ); + let gas_fee_diff = new_tx_btc_pending_info + .gas_fee + .saturating_sub(origin_tx_btc_pending_info.gas_fee); + require!( + gas_fee_diff == full_subsidy_amount, + "Gas fee diff is not equal to subsidy amount." + ); + + Event::SubsidizeRbf { + origin_btc_pending_id: &pending_tx_id, + subsidy_amount: U128(amount), + full_subsidy_amount: U128(full_subsidy_amount), + subsidizer: &sender_id, + beneficiary: &user_account_id, + } + .emit(); + + PromiseOrValue::Value(U128(0)) + } } diff --git a/contracts/satoshi-bridge/src/btc_pending_info.rs b/contracts/satoshi-bridge/src/btc_pending_info.rs index 374d126e..cc8f7674 100644 --- a/contracts/satoshi-bridge/src/btc_pending_info.rs +++ b/contracts/satoshi-bridge/src/btc_pending_info.rs @@ -1,8 +1,8 @@ use std::borrow::{Borrow, BorrowMut}; use crate::{ - env, nano_to_sec, near, network, psbt_wrapper::PsbtWrapper, require, u128_dec_format, - AccountId, Contract, SignatureResponse, WrappedTransaction, U128, VUTXO, + env, legacy::BTCPendingInfoV0, nano_to_sec, near, network, psbt_wrapper::PsbtWrapper, require, + u128_dec_format, AccountId, Contract, SignatureResponse, WrappedTransaction, U128, VUTXO, }; #[near(serializers = [borsh, json])] @@ -14,6 +14,8 @@ pub struct OriginalState { pub max_gas_fee: u128, pub last_rbf_time_sec: Option, pub cancel_rbf_reserved: Option, + #[serde(default, with = "u128_dec_format")] + pub subsidize_amount: u128, } impl OriginalState { @@ -212,6 +214,28 @@ impl BTCPendingInfo { } } + pub fn get_subsidize_amount(&self) -> u128 { + match self.state.borrow() { + PendingInfoState::WithdrawOriginal(state) => state.subsidize_amount, + PendingInfoState::ActiveUtxoManagementOriginal(state) => state.subsidize_amount, + _ => env::panic_str("Not original tx"), + } + } + + pub fn update_subsidize_amount(&mut self, subsidize_amount: u128) { + match self.state.borrow_mut() { + PendingInfoState::WithdrawOriginal(state) => { + state.subsidize_amount = subsidize_amount; + state.last_rbf_time_sec = Some(nano_to_sec(env::block_timestamp())); + } + PendingInfoState::ActiveUtxoManagementOriginal(state) => { + state.subsidize_amount = subsidize_amount; + state.last_rbf_time_sec = Some(nano_to_sec(env::block_timestamp())); + } + _ => env::panic_str("Not original tx"), + } + } + pub fn to_pending_verify_stage(&mut self) { match self.state.borrow_mut() { PendingInfoState::WithdrawOriginal(state) => { @@ -309,12 +333,14 @@ impl BTCPendingInfo { #[near(serializers = [borsh])] pub enum VBTCPendingInfo { + V0(BTCPendingInfoV0), Current(BTCPendingInfo), } impl From for BTCPendingInfo { fn from(v: VBTCPendingInfo) -> Self { match v { + VBTCPendingInfo::V0(c) => c.into(), VBTCPendingInfo::Current(c) => c, } } @@ -323,6 +349,7 @@ impl From for BTCPendingInfo { impl From<&VBTCPendingInfo> for BTCPendingInfo { fn from(v: &VBTCPendingInfo) -> Self { match v { + VBTCPendingInfo::V0(c) => c.clone().into(), VBTCPendingInfo::Current(c) => c.clone(), } } @@ -331,6 +358,7 @@ impl From<&VBTCPendingInfo> for BTCPendingInfo { impl<'a> From<&'a VBTCPendingInfo> for &'a BTCPendingInfo { fn from(v: &'a VBTCPendingInfo) -> Self { match v { + VBTCPendingInfo::V0(_) => unreachable!(), VBTCPendingInfo::Current(c) => c, } } @@ -339,6 +367,7 @@ impl<'a> From<&'a VBTCPendingInfo> for &'a BTCPendingInfo { impl<'a> From<&'a mut VBTCPendingInfo> for &'a mut BTCPendingInfo { fn from(v: &'a mut VBTCPendingInfo) -> Self { match v { + VBTCPendingInfo::V0(_) => unreachable!(), VBTCPendingInfo::Current(c) => c, } } @@ -377,11 +406,18 @@ impl Contract { &mut self, btc_pending_id: &String, ) -> &mut BTCPendingInfo { - self.data_mut() + let btc_pending_info = self + .data_mut() .btc_pending_infos .get_mut(btc_pending_id) - .map(Into::into) - .expect("BTC pending info not exist") + .expect("BTC pending info not exist"); + + if let VBTCPendingInfo::V0(old) = &btc_pending_info { + let new_current = BTCPendingInfo::from(old.clone()); + *btc_pending_info = VBTCPendingInfo::Current(new_current); + } + + btc_pending_info.into() } pub fn internal_remove_btc_pending_info(&mut self, btc_pending_id: &String) -> BTCPendingInfo { diff --git a/contracts/satoshi-bridge/src/event.rs b/contracts/satoshi-bridge/src/event.rs index 0a6559db..9e5977a9 100644 --- a/contracts/satoshi-bridge/src/event.rs +++ b/contracts/satoshi-bridge/src/event.rs @@ -44,6 +44,13 @@ pub enum Event<'a> { account_id: &'a AccountId, btc_pending_id: &'a String, }, + SubsidizeRbf { + origin_btc_pending_id: &'a String, + subsidy_amount: U128, + full_subsidy_amount: U128, + subsidizer: &'a AccountId, + beneficiary: &'a AccountId, + }, BtcInputSignature { account_id: &'a AccountId, btc_pending_id: &'a String, diff --git a/contracts/satoshi-bridge/src/legacy.rs b/contracts/satoshi-bridge/src/legacy.rs index 892a5d40..0e681731 100644 --- a/contracts/satoshi-bridge/src/legacy.rs +++ b/contracts/satoshi-bridge/src/legacy.rs @@ -1,10 +1,24 @@ #[cfg(not(feature = "zcash"))] use crate::VRefundRequest; use crate::{ - env, near, AccountId, BridgeFee, Config, ContractData, HashMap, HashSet, IterableMap, - IterableSet, LazyOption, LookupSet, PublicKey, StorageKey, VAccount, VBTCPendingInfo, VUTXO, + env, near, u128_dec_format, AccountId, BTCPendingInfo, BridgeFee, Config, ContractData, + HashMap, HashSet, IterableMap, IterableSet, LazyOption, LookupSet, OriginalState, + PendingInfoStage, PendingInfoState, PublicKey, RbfState, SignatureResponse, StorageKey, + VAccount, VBTCPendingInfo, U128, VUTXO, }; +pub(crate) fn migrate_btc_pending_infos_to_current( + btc_pending_infos: &mut IterableMap, +) { + let keys: Vec = btc_pending_infos.keys().cloned().collect(); + for key in keys { + if let Some(value) = btc_pending_infos.get(&key) { + let current: BTCPendingInfo = value.into(); + btc_pending_infos.insert(key, VBTCPendingInfo::Current(current)); + } + } +} + #[near(serializers = [borsh])] pub struct ContractDataV0 { pub config: LazyOption, @@ -32,7 +46,7 @@ impl From for ContractData { utxos, unavailable_utxos, verified_deposit_utxo, - btc_pending_infos, + mut btc_pending_infos, rbf_txs, relayer_white_list, post_action_receiver_id_white_list, @@ -44,6 +58,8 @@ impl From for ContractData { acc_protocol_fee_for_gas, } = c; + migrate_btc_pending_infos_to_current(&mut btc_pending_infos); + Self { config, accounts, @@ -371,7 +387,7 @@ impl From for ContractData { utxos, unavailable_utxos, verified_deposit_utxo, - btc_pending_infos, + mut btc_pending_infos, rbf_txs, relayer_white_list, post_action_receiver_id_white_list, @@ -383,6 +399,9 @@ impl From for ContractData { cur_reserved_protocol_fee, acc_protocol_fee_for_gas, } = c; + + migrate_btc_pending_infos_to_current(&mut btc_pending_infos); + let config_v0 = config.get().clone().unwrap(); Self { config: LazyOption::new(StorageKey::Config, Some(config_v0.into())), @@ -441,7 +460,7 @@ impl From for ContractData { utxos, unavailable_utxos, verified_deposit_utxo, - btc_pending_infos, + mut btc_pending_infos, rbf_txs, relayer_white_list, extra_msg_relayer_white_list, @@ -455,6 +474,8 @@ impl From for ContractData { acc_protocol_fee_for_gas, } = c; + migrate_btc_pending_infos_to_current(&mut btc_pending_infos); + Self { config: LazyOption::new( StorageKey::Config, @@ -619,7 +640,7 @@ impl From for ContractData { utxos, unavailable_utxos, verified_deposit_utxo, - btc_pending_infos, + mut btc_pending_infos, rbf_txs, relayer_white_list, extra_msg_relayer_white_list, @@ -633,6 +654,8 @@ impl From for ContractData { acc_protocol_fee_for_gas, } = c; + migrate_btc_pending_infos_to_current(&mut btc_pending_infos); + Self { config: LazyOption::new( StorageKey::Config, @@ -806,7 +829,7 @@ impl From for ContractData { utxos, unavailable_utxos, verified_deposit_utxo, - btc_pending_infos, + mut btc_pending_infos, rbf_txs, relayer_white_list, extra_msg_relayer_white_list, @@ -823,6 +846,8 @@ impl From for ContractData { refund_requests, } = c; + migrate_btc_pending_infos_to_current(&mut btc_pending_infos); + Self { config: LazyOption::new( StorageKey::Config, @@ -850,3 +875,103 @@ impl From for ContractData { } } } + +#[near(serializers = [borsh, json])] +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] +pub struct OriginalStateV0 { + pub stage: PendingInfoStage, + #[serde(with = "u128_dec_format")] + pub max_gas_fee: u128, + pub last_rbf_time_sec: Option, + pub cancel_rbf_reserved: Option, +} + +#[near(serializers = [borsh, json])] +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] +pub enum PendingInfoStateV0 { + WithdrawOriginal(OriginalStateV0), + WithdrawUserRbf(RbfState), + WithdrawCancelRbf(RbfState), + ActiveUtxoManagementOriginal(OriginalStateV0), + ActiveUtxoManagementRbf(RbfState), + ActiveUtxoManagementCancelRbf(RbfState), +} + +#[near(serializers = [borsh, json])] +#[derive(Clone)] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] +pub struct BTCPendingInfoV0 { + pub account_id: AccountId, + pub btc_pending_id: String, + #[serde(with = "u128_dec_format")] + pub transfer_amount: u128, + #[serde(with = "u128_dec_format")] + pub actual_received_amount: u128, + #[serde(with = "u128_dec_format")] + pub withdraw_fee: u128, + #[serde(with = "u128_dec_format")] + pub gas_fee: u128, + #[serde(with = "u128_dec_format")] + pub burn_amount: u128, + pub psbt_hex: String, + pub vutxos: Vec, + pub signatures: Vec>, + pub tx_bytes_with_sign: Option>, + pub create_time_sec: u32, + pub last_sign_time_sec: u32, + pub state: PendingInfoStateV0, +} + +impl From for OriginalState { + fn from(c: OriginalStateV0) -> Self { + Self { + stage: c.stage, + max_gas_fee: c.max_gas_fee, + last_rbf_time_sec: c.last_rbf_time_sec, + cancel_rbf_reserved: c.cancel_rbf_reserved, + subsidize_amount: 0, + } + } +} + +impl From for PendingInfoState { + fn from(c: PendingInfoStateV0) -> Self { + match c { + PendingInfoStateV0::WithdrawOriginal(x) => PendingInfoState::WithdrawOriginal(x.into()), + PendingInfoStateV0::WithdrawUserRbf(x) => PendingInfoState::WithdrawUserRbf(x), + PendingInfoStateV0::WithdrawCancelRbf(x) => PendingInfoState::WithdrawCancelRbf(x), + PendingInfoStateV0::ActiveUtxoManagementOriginal(x) => { + PendingInfoState::ActiveUtxoManagementOriginal(x.into()) + } + PendingInfoStateV0::ActiveUtxoManagementRbf(x) => { + PendingInfoState::ActiveUtxoManagementRbf(x) + } + PendingInfoStateV0::ActiveUtxoManagementCancelRbf(x) => { + PendingInfoState::ActiveUtxoManagementCancelRbf(x) + } + } + } +} + +impl From for BTCPendingInfo { + fn from(c: BTCPendingInfoV0) -> Self { + Self { + account_id: c.account_id, + btc_pending_id: c.btc_pending_id, + transfer_amount: c.transfer_amount, + actual_received_amount: c.actual_received_amount, + withdraw_fee: c.withdraw_fee, + gas_fee: c.gas_fee, + burn_amount: c.burn_amount, + psbt_hex: c.psbt_hex, + vutxos: c.vutxos, + signatures: c.signatures, + tx_bytes_with_sign: c.tx_bytes_with_sign, + create_time_sec: c.create_time_sec, + last_sign_time_sec: c.last_sign_time_sec, + state: c.state.into(), + } + } +} diff --git a/contracts/satoshi-bridge/src/rbf/withdraw.rs b/contracts/satoshi-bridge/src/rbf/withdraw.rs index 36fdc9a4..0e365085 100644 --- a/contracts/satoshi-bridge/src/rbf/withdraw.rs +++ b/contracts/satoshi-bridge/src/rbf/withdraw.rs @@ -8,6 +8,7 @@ impl Contract { &self, original_tx_btc_pending_info: &BTCPendingInfo, withdraw_rbf_psbt: &PsbtWrapper, + subsidy_amount: u128, ) -> (u128, u128) { let withdraw_change_address_script_pubkey = self.internal_config().get_change_script_pubkey(); @@ -25,7 +26,7 @@ impl Contract { target_address, &withdraw_change_address_script_pubkey, &original_tx_btc_pending_info.vutxos, - original_tx_btc_pending_info.transfer_amount, + original_tx_btc_pending_info.transfer_amount + subsidy_amount, original_tx_btc_pending_info.withdraw_fee, ); (actual_received_amount, gas_fee) @@ -54,8 +55,18 @@ impl Contract { original_tx_id: original_btc_pending_verify_id.clone(), }), ); - let (actual_received_amount, gas_fee) = - self.check_withdraw_rbf_psbt_valid(original_tx_btc_pending_info, &withdraw_rbf_psbt); + + let full_subsidy_amount = self + .internal_unwrap_btc_pending_info(&original_btc_pending_verify_id) + .get_subsidize_amount(); + btc_pending_info.transfer_amount += full_subsidy_amount; + + let (actual_received_amount, gas_fee) = self.check_withdraw_rbf_psbt_valid( + original_tx_btc_pending_info, + &withdraw_rbf_psbt, + full_subsidy_amount, + ); + btc_pending_info.gas_fee = gas_fee; btc_pending_info.actual_received_amount = actual_received_amount; btc_pending_info.burn_amount = actual_received_amount + gas_fee; diff --git a/contracts/satoshi-bridge/src/refund.rs b/contracts/satoshi-bridge/src/refund.rs index af5b677d..90c1ff4f 100644 --- a/contracts/satoshi-bridge/src/refund.rs +++ b/contracts/satoshi-bridge/src/refund.rs @@ -231,6 +231,7 @@ impl Contract { max_gas_fee: gas_fee, last_rbf_time_sec: None, cancel_rbf_reserved: None, + subsidize_amount: 0, }), }; diff --git a/contracts/satoshi-bridge/src/upgrade.rs b/contracts/satoshi-bridge/src/upgrade.rs index 9230fbeb..1523f426 100644 --- a/contracts/satoshi-bridge/src/upgrade.rs +++ b/contracts/satoshi-bridge/src/upgrade.rs @@ -1,4 +1,7 @@ -use crate::{env, near, Contract, ContractExt, VersionedContractData}; +use crate::{ + env, legacy::migrate_btc_pending_infos_to_current, near, Contract, ContractExt, + VersionedContractData, +}; #[near] impl Contract { @@ -14,7 +17,15 @@ impl Contract { VersionedContractData::V2(data) => VersionedContractData::Current(data.into()), VersionedContractData::V3(data) => VersionedContractData::Current(data.into()), VersionedContractData::V4(data) => VersionedContractData::Current(data.into()), - VersionedContractData::Current(data) => VersionedContractData::Current(data), + VersionedContractData::Current(mut data) => { + // Ensure all `VBTCPendingInfo` entries are in the `Current` variant + // even when the outer `ContractData` schema did not change. Without + // this, an upgrade that only modifies the inner pending-info schema + // leaves entries in the older variant, and `internal_unwrap_*` + // paths that take `&BTCPendingInfo` hit `unreachable!()`. + migrate_btc_pending_infos_to_current(&mut data.btc_pending_infos); + VersionedContractData::Current(data) + } }; contract } diff --git a/contracts/satoshi-bridge/src/zcash_utils/contract_methods.rs b/contracts/satoshi-bridge/src/zcash_utils/contract_methods.rs index dae92058..45604b60 100644 --- a/contracts/satoshi-bridge/src/zcash_utils/contract_methods.rs +++ b/contracts/satoshi-bridge/src/zcash_utils/contract_methods.rs @@ -276,4 +276,14 @@ impl Contract { self.internal_config(), ) } + + pub(crate) fn rbf_subsidize_chain_specific( + &mut self, + _amount: u128, + _sender_id: AccountId, + _pending_tx_id: String, + _output: Vec, + ) -> PromiseOrValue { + unimplemented!("This function is not supported yet"); + } } diff --git a/contracts/satoshi-bridge/tests/data/btc_bridge_v0-8-4.wasm b/contracts/satoshi-bridge/tests/data/btc_bridge_v0-8-4.wasm new file mode 100644 index 00000000..5cdd3b6c Binary files /dev/null and b/contracts/satoshi-bridge/tests/data/btc_bridge_v0-8-4.wasm differ diff --git a/contracts/satoshi-bridge/tests/setup/context.rs b/contracts/satoshi-bridge/tests/setup/context.rs index 0cc8511a..dd00ae08 100644 --- a/contracts/satoshi-bridge/tests/setup/context.rs +++ b/contracts/satoshi-bridge/tests/setup/context.rs @@ -45,6 +45,14 @@ pub struct Context { impl Context { pub async fn new(worker: &Worker, chain: Option) -> Self { + Self::new_with_bridge_wasm(worker, chain, BRIDGE_WASM_PATH).await + } + + pub async fn new_with_bridge_wasm( + worker: &Worker, + chain: Option, + bridge_wasm_path: &str, + ) -> Self { let root = worker.root_account().unwrap(); let ( bridge_contract, @@ -62,7 +70,7 @@ impl Context { .unwrap() .unwrap(); bridge - .deploy(&std::fs::read(BRIDGE_WASM_PATH).unwrap()) + .deploy(&std::fs::read(bridge_wasm_path).unwrap()) .await .unwrap() .unwrap() @@ -1096,6 +1104,42 @@ impl Context { .transact() .await } + + pub async fn upgrade_satoshi_bridge(&self, wasm_path: &str) -> Result { + let _ = self + .root + .call(self.bridge_contract.id(), "up_stage_code") + .args_borsh(std::fs::read(wasm_path).unwrap()) + .max_gas() + .transact() + .await + .unwrap(); + + let staged_code_hash: near_sdk::CryptoHash = self + .root + .call(self.bridge_contract.id(), "up_staged_code_hash") + .view() + .await + .unwrap() + .json::>() + .unwrap() + .unwrap(); + + self.root + .call(self.bridge_contract.id(), "up_deploy_code") + .args_json( + json!({"hash": base64::engine::general_purpose::STANDARD.encode(staged_code_hash), + "function_call_args": Some(near_plugins::upgradable::FunctionCallArgs{ + function_name: "migrate_state".to_string(), + arguments: vec![], + amount: NearToken::from_near(0), + gas: Gas::from_tgas(20) + })}), + ) + .max_gas() + .transact() + .await + } } pub struct UpgradeContext { diff --git a/contracts/satoshi-bridge/tests/test_upgrade.rs b/contracts/satoshi-bridge/tests/test_upgrade.rs index 5ffb1b91..a54ab4ce 100644 --- a/contracts/satoshi-bridge/tests/test_upgrade.rs +++ b/contracts/satoshi-bridge/tests/test_upgrade.rs @@ -5,6 +5,9 @@ use satoshi_bridge::{ }; use setup::*; +#[cfg(not(feature = "zcash"))] +const TARGET_ADDRESS: &str = "1PAGsaT5vDz6hjzvuenSw33hWzESTR3ZHQ"; + #[tokio::test] async fn test_btc_bridge_upgrade() { let worker = near_workspaces::sandbox().await.unwrap(); @@ -159,6 +162,214 @@ async fn test_btc_bridge_upgrade_from_v0_8_0_state_migration() { ); } +/// Set up a context on v0.8.4 with exactly one pending withdraw info, return +/// the context and the pending tx id. Used by upgrade-migration tests. +#[cfg(not(feature = "zcash"))] +async fn setup_v0_8_4_with_one_pending( + worker: &near_workspaces::Worker, +) -> (Context, String) { + use bitcoin::{Amount, OutPoint, TxOut}; + use satoshi_bridge::network::{Address, Chain}; + use satoshi_bridge::TokenReceiverMessage; + + let context = Context::new_with_bridge_wasm( + worker, + Some("BitcoinMainnet".to_string()), + "tests/data/btc_bridge_v0-8-4.wasm", + ) + .await; + + // Give alice 250000 sats of nBTC via a deposit. + let alice_btc_deposit_address = context + .get_user_deposit_address(DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: None, + }) + .await + .unwrap(); + + check!(context.verify_deposit( + "relayer", + DepositMsg { + recipient_id: context.get_account_by_name("alice").sdk_id(), + post_actions: None, + extra_msg: None, + safe_deposit: None, + refund_address: None, + }, + generate_transaction_bytes( + vec![( + "a2a5069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f19", + 1, + None, + )], + vec![(alice_btc_deposit_address.as_str(), 250000)], + ), + 0, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(), + 1, + vec![], + )); + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 250000); + + check!(context.storage_deposit("nbtc", "bridge")); + + // Create a pending info by withdrawing 110000 sats. + let withdraw_change_address = context.get_change_address().await.unwrap(); + let utxos = context.get_utxos_paged().await.unwrap(); + let utxo_key = utxos.keys().next().unwrap().clone(); + let utxo_parts: Vec<&str> = utxo_key.split('@').collect(); + + let withdraw_amount: u128 = 110000; + let btc_gas_fee: u64 = 25000; + let withdraw_fee: u64 = 50000; // fee_min=50000, fee_rate=0 + let recipient_value: u64 = withdraw_amount as u64 - btc_gas_fee - withdraw_fee; + let change_value: u64 = 250000 - (withdraw_amount as u64 - withdraw_fee); + + check!(context.do_withdraw( + "alice", + "bridge", + withdraw_amount, + TokenReceiverMessage::Withdraw { + target_btc_address: TARGET_ADDRESS.to_string(), + input: vec![OutPoint { + txid: utxo_parts[0].parse().unwrap(), + vout: utxo_parts[1].parse().unwrap(), + }], + output: vec![ + TxOut { + value: Amount::from_sat(recipient_value), + script_pubkey: Address::parse(TARGET_ADDRESS, Chain::BitcoinMainnet) + .expect("Invalid btc address") + .script_pubkey() + .expect("Failed to get script pubkey"), + }, + TxOut { + value: Amount::from_sat(change_value), + script_pubkey: Address::parse( + withdraw_change_address.as_str(), + Chain::BitcoinMainnet, + ) + .expect("Invalid btc address") + .script_pubkey() + .expect("Failed to get script pubkey"), + }, + ], + max_gas_fee: None, + chain_specific_data: None, + } + )); + + // Capture the pending id. The OLD contract's JSON response does not include + // `subsidize_amount`, so we read raw JSON to avoid client-side schema mismatch. + let pendings_raw: std::collections::HashMap = context + .bridge_contract + .call("get_btc_pending_infos_paged") + .args_json(json!({})) + .view() + .await + .unwrap() + .json() + .unwrap(); + assert_eq!(pendings_raw.len(), 1, "expected exactly one pending info"); + let pending_id = pendings_raw.keys().next().unwrap().clone(); + (context, pending_id) +} + +/// Assert that `verify_withdraw` on the given pending id does not panic in +/// `internal_unwrap_btc_pending_info`. It can still fail for unrelated reasons +/// (the merkle proof is fake), but must not hit the `unreachable!()` branch in +/// `From<&'a VBTCPendingInfo> for &'a BTCPendingInfo` on a `V0` entry. +#[cfg(not(feature = "zcash"))] +async fn assert_verify_withdraw_does_not_hit_unreachable(context: &Context, pending_id: &str) { + let result = context + .verify_withdraw( + "relayer", + pending_id, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(), + 1, + vec![], + ) + .await + .expect("verify_withdraw tx must be sent (execution may still fail)"); + + let failures = format!("{:?}", result.receipt_failures()); + assert!( + !failures.contains("unreachable"), + "read access to a pending info hit `unreachable!()` — `migrate_state` \ + did not migrate `VBTCPendingInfo::V0` entries to `Current`.\n\n\ + Receipt failures: {failures}" + ); +} + +/// Verify that on upgrade from v0.8.4, existing pending withdraw infos survive +/// both the view-path and any operational path that goes through +/// `internal_unwrap_btc_pending_info`. Regression for the migration bug where +/// `migrate_state`'s `Current → Current` arm did not eagerly migrate +/// `btc_pending_infos`, leaving all entries as `VBTCPendingInfo::V0` and +/// blocking every in-flight withdraw after upgrade. +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_btc_bridge_upgrade_from_v0_8_4_pending_info_survives_unwrap() { + use satoshi_bridge::PendingInfoState; + + let worker = near_workspaces::sandbox().await.unwrap(); + let (context, pending_id) = setup_v0_8_4_with_one_pending(&worker).await; + + check!(context.upgrade_satoshi_bridge("../../res/bitcoin_bridge.wasm")); + + // View-path: must surface the new `subsidize_amount` field defaulted to 0. + let pendings_after = context.get_btc_pending_infos_paged().await.unwrap(); + let info_after = pendings_after + .get(&pending_id) + .expect("pending info must survive view-path after upgrade"); + match &info_after.state { + PendingInfoState::WithdrawOriginal(state) => { + assert_eq!( + state.subsidize_amount, 0, + "new field must default to 0 on migrated entries" + ); + } + other => panic!("expected WithdrawOriginal state, got {other:?}"), + } + + assert_verify_withdraw_does_not_hit_unreachable(&context, &pending_id).await; +} + +/// Upgrading twice in a row must be safe: the second `migrate_state` runs on +/// already-`Current` `VBTCPendingInfo` entries and must leave the data +/// unchanged (no panic, `subsidize_amount` stays at 0). +#[tokio::test] +#[cfg(not(feature = "zcash"))] +async fn test_btc_bridge_upgrade_from_v0_8_4_double_migration_is_idempotent() { + use satoshi_bridge::PendingInfoState; + + let worker = near_workspaces::sandbox().await.unwrap(); + let (context, pending_id) = setup_v0_8_4_with_one_pending(&worker).await; + + check!(context.upgrade_satoshi_bridge("../../res/bitcoin_bridge.wasm")); + check!(context.upgrade_satoshi_bridge("../../res/bitcoin_bridge.wasm")); + + let pendings_after = context.get_btc_pending_infos_paged().await.unwrap(); + let info_after = pendings_after + .get(&pending_id) + .expect("pending info must survive two upgrades"); + match &info_after.state { + PendingInfoState::WithdrawOriginal(state) => { + assert_eq!( + state.subsidize_amount, 0, + "double migration must keep `subsidize_amount` at 0" + ); + } + other => panic!("expected WithdrawOriginal state, got {other:?}"), + } + + assert_verify_withdraw_does_not_hit_unreachable(&context, &pending_id).await; +} + #[tokio::test] async fn test_set_icon() { let worker = near_workspaces::sandbox().await.unwrap();