diff --git a/Cargo.lock b/Cargo.lock index 5b077b83..55e7c9fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "mock-btc-light-client" -version = "0.1.0" +version = "0.2.0" dependencies = [ "hex", "near-sdk", @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "satoshi-bridge" -version = "0.8.0" +version = "0.9.0" dependencies = [ "bitcoin", "bs58 0.5.1", diff --git a/contracts/mock-btc-light-client/Cargo.toml b/contracts/mock-btc-light-client/Cargo.toml index 614d440f..7821c66b 100644 --- a/contracts/mock-btc-light-client/Cargo.toml +++ b/contracts/mock-btc-light-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mock-btc-light-client" -version = "0.1.0" +version = "0.2.0" edition.workspace = true publish.workspace = true diff --git a/contracts/mock-btc-light-client/src/lib.rs b/contracts/mock-btc-light-client/src/lib.rs index c7984e0e..032f8c1e 100644 --- a/contracts/mock-btc-light-client/src/lib.rs +++ b/contracts/mock-btc-light-client/src/lib.rs @@ -29,6 +29,17 @@ pub struct ProofArgs { pub confirmations: u64, } +#[near(serializers = [borsh])] +pub struct ProofArgsV2 { + pub tx_id: H256, + pub tx_block_blockhash: H256, + pub tx_index: u64, + pub merkle_proof: Vec, + pub coinbase_tx_id: H256, + pub coinbase_merkle_proof: Vec, + pub confirmations: u64, +} + impl<'de> Deserialize<'de> for H256 { fn deserialize(deserializer: D) -> Result where @@ -82,6 +93,11 @@ impl Contract { true } + #[allow(unused_variables)] + pub fn verify_transaction_inclusion_v2(&self, #[serializer(borsh)] args: ProofArgsV2) -> bool { + true + } + pub fn get_last_block_height(&self) -> u32 { // Return a reasonable mock block height for Zcash testnet 1000 diff --git a/contracts/satoshi-bridge/Cargo.toml b/contracts/satoshi-bridge/Cargo.toml index f0ea9861..1a29ee86 100644 --- a/contracts/satoshi-bridge/Cargo.toml +++ b/contracts/satoshi-bridge/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "satoshi-bridge" -version = "0.8.0" +version = "0.9.0" edition.workspace = true publish.workspace = true repository.workspace = true diff --git a/contracts/satoshi-bridge/release_notes.md b/contracts/satoshi-bridge/release_notes.md index 9f0d2a8c..716edb0c 100644 --- a/contracts/satoshi-bridge/release_notes.md +++ b/contracts/satoshi-bridge/release_notes.md @@ -1,5 +1,8 @@ # Release Notes +### Version 0.9.0 +1. use `verify_transaction_inclusion_v2` with coinbase merkle proof. + ### Version 0.8.0 1. support BTC refund flow. 2. remove lock time verification. diff --git a/contracts/satoshi-bridge/src/api/bridge.rs b/contracts/satoshi-bridge/src/api/bridge.rs index b4396699..c92b2371 100644 --- a/contracts/satoshi-bridge/src/api/bridge.rs +++ b/contracts/satoshi-bridge/src/api/bridge.rs @@ -1,17 +1,20 @@ -use crate::psbt_wrapper::PsbtWrapper; use crate::*; use near_plugins::{access_control_any, pause}; #[trusted_relayer] #[near] impl Contract { - /// Verify that the user has transferred BTC asset to the protocol's designated BTC deposit account, and mint NBTC to the user's NEAR account. + /// Verify that the user has transferred BTC asset to the protocol's designated BTC deposit account, + /// and mint NBTC to the user's NEAR account. + /// + /// # Deprecated + /// Use `verify_deposit_v2` instead, which includes coinbase proof for stronger verification. /// /// # Arguments /// /// * `deposit_msg` - Information used to generate the deposit address path. - /// * `tx_bytes` - Successfully confirmed BTC transaction bytes - /// * `vout` - The index of the output where the user sent BTC to the deposit address + /// * `tx_bytes` - Successfully confirmed BTC transaction bytes. + /// * `vout` - The index of the output where the user sent BTC to the deposit address. /// * `tx_block_blockhash` - The block hash where the transaction is located. /// * `tx_index` - The index of the transaction in the block. /// * `merkle_proof` - Merkle proof of the transaction. @@ -21,6 +24,7 @@ impl Contract { /// bool - Whether nBTC minting was successful. #[trusted_relayer] #[pause(except(roles(Role::DAO)))] + #[deprecated(note = "use verify_deposit_v2")] pub fn verify_deposit( &mut self, deposit_msg: DepositMsg, @@ -30,57 +34,62 @@ impl Contract { tx_index: u64, merkle_proof: Vec, ) -> Promise { - require!( - deposit_msg.safe_deposit.is_none(), - "safe_deposit not supported in verify_deposit" - ); - let path = get_deposit_path(&deposit_msg); - let transaction = WrappedTransaction::decode(&tx_bytes, &self.internal_config().chain) - .expect("Deserialization tx_bytes failed"); - let deposit_amount = u128::from(transaction.output()[vout].value.to_sat()); - require!(deposit_amount > 0, "Invalid deposit_amount"); - let deposit_address = self.generate_utxo_chain_address(&path); - let deposit_address_script_pubkey = deposit_address - .script_pubkey() - .expect("Invalid deposit address"); - require!( - deposit_address_script_pubkey == transaction.output()[vout].script_pubkey, - "Invalid deposit tx_bytes" - ); - - let utxo = UTXO { - path, + self.internal_verify_deposit_entry( + deposit_msg, tx_bytes, vout, - balance: transaction.output()[vout].value.to_sat(), - }; - 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")), - ); - self.internal_verify_deposit( - deposit_amount, tx_block_blockhash, tx_index, merkle_proof, - PendingUTXOInfo { - tx_id, - utxo_storage_key, - utxo, - }, + None, + ) + } + + /// Verify that the user has transferred BTC asset to the protocol's designated BTC deposit account, + /// and mint NBTC to the user's NEAR account. + /// Includes coinbase proof for stronger transaction inclusion verification. + /// + /// # Arguments + /// + /// * `deposit_msg` - Information used to generate the deposit address path. + /// * `tx_bytes` - Successfully confirmed BTC transaction bytes. + /// * `vout` - The index of the output where the user sent BTC to the deposit address. + /// * `proof` - Transaction inclusion proof with coinbase verification. + /// + /// # Returns + /// + /// bool - Whether nBTC minting was successful. + #[trusted_relayer] + #[pause(except(roles(Role::DAO)))] + pub fn verify_deposit_v2( + &mut self, + deposit_msg: DepositMsg, + tx_bytes: Base64VecU8, + vout: usize, + proof: TxInclusionProof, + ) -> Promise { + self.internal_verify_deposit_entry( deposit_msg, + tx_bytes.0, + vout, + proof.tx_block_blockhash, + proof.tx_index, + proof.merkle_proof, + Some((proof.coinbase_tx_id, proof.coinbase_merkle_proof)), ) } /// Safe version of verify_deposit, only supports minting nBTC with safe_deposit message and revert the deposit on failed XCC calls. /// It doesn't charge deposit fee, and doesn't pay the token storage for the user /// + /// # Deprecated + /// Use `safe_verify_deposit_v2` instead, which includes coinbase proof for stronger verification. + /// /// # Arguments /// - /// * `deposit_msg` - Information used to generate the deposit address path. - /// * `tx_bytes` - Successfully confirmed BTC transaction bytes - /// * `vout` - The index of the output where the user sent BTC to the deposit address + /// * `deposit_msg` - Information used to generate the deposit address path. Must contain `safe_deposit`. + /// * `tx_bytes` - Successfully confirmed BTC transaction bytes. + /// * `vout` - The index of the output where the user sent BTC to the deposit address. /// * `tx_block_blockhash` - The block hash where the transaction is located. /// * `tx_index` - The index of the transaction in the block. /// * `merkle_proof` - Merkle proof of the transaction. @@ -91,6 +100,7 @@ impl Contract { #[payable] #[trusted_relayer] #[pause(except(roles(Role::DAO)))] + #[deprecated(note = "use safe_verify_deposit_v2")] pub fn safe_verify_deposit( &mut self, deposit_msg: DepositMsg, @@ -100,54 +110,56 @@ impl Contract { tx_index: u64, merkle_proof: Vec, ) -> Promise { - require!( - env::attached_deposit() >= self.required_balance_for_safe_deposit(), - "Insufficient deposit for storage" - ); - - let path = get_deposit_path(&deposit_msg); - let safe_deposit_msg = deposit_msg - .safe_deposit - .unwrap_or_else(|| env::panic_str("safe_deposit is required in safe_verify_deposit")); - - let transaction = WrappedTransaction::decode(&tx_bytes, &self.internal_config().chain) - .expect("Deserialization tx_bytes failed"); - let deposit_amount = transaction.output()[vout].value.to_sat().into(); - require!(deposit_amount > 0, "Invalid deposit_amount"); - let deposit_address = self.generate_utxo_chain_address(&path); - let deposit_address_script_pubkey = deposit_address - .script_pubkey() - .expect("Invalid deposit address"); - require!( - deposit_address_script_pubkey == transaction.output()[vout].script_pubkey, - "Invalid deposit tx_bytes" - ); - - let utxo = UTXO { - path, + self.internal_safe_verify_deposit_entry( + deposit_msg, tx_bytes, vout, - balance: transaction.output()[vout].value.to_sat(), - }; - let tx_id = transaction.compute_txid().to_string(); - let utxo_storage_key = generate_utxo_storage_key(tx_id.clone(), vout.try_into().unwrap()); - - self.internal_safe_verify_deposit( - deposit_amount, tx_block_blockhash, tx_index, merkle_proof, - PendingUTXOInfo { - tx_id, - utxo_storage_key, - utxo, - }, - deposit_msg.recipient_id, - safe_deposit_msg, + None, ) } - /// Verify that the user’s withdrawal has been successful, and then burn the corresponding amount of tokens. + /// Safe version of verify_deposit. Reverts the entire transaction if mint fails (no lost & found). + /// Does not charge deposit fees. User must attach NEAR for storage. + /// Includes coinbase proof for stronger transaction inclusion verification. + /// + /// # Arguments + /// + /// * `deposit_msg` - Information used to generate the deposit address path. Must contain `safe_deposit`. + /// * `tx_bytes` - Successfully confirmed BTC transaction bytes. + /// * `vout` - The index of the output where the user sent BTC to the deposit address. + /// * `proof` - Transaction inclusion proof with coinbase verification. + /// + /// # Returns + /// + /// bool - Whether nBTC minting was successful. + #[payable] + #[trusted_relayer] + #[pause(except(roles(Role::DAO)))] + pub fn safe_verify_deposit_v2( + &mut self, + deposit_msg: DepositMsg, + tx_bytes: Base64VecU8, + vout: usize, + proof: TxInclusionProof, + ) -> Promise { + self.internal_safe_verify_deposit_entry( + deposit_msg, + tx_bytes.0, + vout, + proof.tx_block_blockhash, + proof.tx_index, + proof.merkle_proof, + Some((proof.coinbase_tx_id, proof.coinbase_merkle_proof)), + ) + } + + /// Verify that the user's withdrawal has been successful, and burn the corresponding amount of tokens. + /// + /// # Deprecated + /// Use `verify_withdraw_v2` instead, which includes coinbase proof for stronger verification. /// /// # Arguments /// @@ -161,6 +173,7 @@ impl Contract { /// bool - Whether nBTC burning was successful. #[trusted_relayer] #[pause(except(roles(Role::DAO)))] + #[deprecated(note = "use verify_withdraw_v2")] pub fn verify_withdraw( &mut self, tx_id: String, @@ -168,24 +181,29 @@ impl Contract { tx_index: u64, merkle_proof: Vec, ) -> Promise { - let btc_pending_info = self.internal_unwrap_btc_pending_info(&tx_id); - btc_pending_info.assert_withdraw_related_pending_verify_tx(); - if let Some(original_tx_id) = btc_pending_info.get_original_tx_id() { - require!( - self.check_btc_pending_info_exists(original_tx_id), - "original tx already verified" - ); - } - require!( - btc_pending_info.tx_bytes_with_sign.is_some(), - "Missing tx_bytes_with_sign" - ); - self.internal_verify_withdraw( + self.internal_verify_withdraw_entry(tx_id, tx_block_blockhash, tx_index, merkle_proof, None) + } + + /// Verify that the user's withdrawal has been successful, and burn the corresponding amount of tokens. + /// Includes coinbase proof for stronger transaction inclusion verification. + /// + /// # Arguments + /// + /// * `tx_id` - The transaction ID of the successfully on-chain withdrawal. + /// * `proof` - Transaction inclusion proof with coinbase verification. + /// + /// # Returns + /// + /// bool - Whether nBTC burning was successful. + #[trusted_relayer] + #[pause(except(roles(Role::DAO)))] + pub fn verify_withdraw_v2(&mut self, tx_id: String, proof: TxInclusionProof) -> Promise { + self.internal_verify_withdraw_entry( tx_id, - tx_block_blockhash, - tx_index, - merkle_proof, - btc_pending_info, + proof.tx_block_blockhash, + proof.tx_index, + proof.merkle_proof, + Some((proof.coinbase_tx_id, proof.coinbase_merkle_proof)), ) } @@ -238,11 +256,14 @@ impl Contract { ); } - /// Verify that the active utxo management has been successful, and then burn the corresponding amount of tokens. + /// Verify that the active UTXO management has been successful, and burn the gas fee. + /// + /// # Deprecated + /// Use `verify_active_utxo_management_v2` instead, which includes coinbase proof for stronger verification. /// /// # Arguments /// - /// * `tx_id` - The transaction ID of the successfully on-chain withdrawal. + /// * `tx_id` - The transaction ID of the successfully on-chain UTXO management. /// * `tx_block_blockhash` - The block hash where the transaction is located. /// * `tx_index` - The index of the transaction in the block. /// * `merkle_proof` - Merkle proof of the transaction. @@ -252,6 +273,7 @@ impl Contract { /// bool - Whether nBTC burning was successful. #[trusted_relayer] #[pause(except(roles(Role::DAO)))] + #[deprecated(note = "use verify_active_utxo_management_v2")] pub fn verify_active_utxo_management( &mut self, tx_id: String, @@ -259,24 +281,39 @@ impl Contract { tx_index: u64, merkle_proof: Vec, ) -> Promise { - let btc_pending_info = self.internal_unwrap_btc_pending_info(&tx_id); - btc_pending_info.assert_active_utxo_management_related_pending_verify_tx(); - if let Some(original_tx_id) = btc_pending_info.get_original_tx_id() { - require!( - self.check_btc_pending_info_exists(original_tx_id), - "original tx already verified" - ); - } - require!( - btc_pending_info.tx_bytes_with_sign.is_some(), - "Missing tx_bytes_with_sign" - ); - self.internal_verify_active_utxo_management( + self.internal_verify_active_utxo_management_entry( tx_id, tx_block_blockhash, tx_index, merkle_proof, - btc_pending_info, + None, + ) + } + + /// Verify that the active UTXO management has been successful, and burn the gas fee. + /// Includes coinbase proof for stronger transaction inclusion verification. + /// + /// # Arguments + /// + /// * `tx_id` - The transaction ID of the successfully on-chain UTXO management. + /// * `proof` - Transaction inclusion proof with coinbase verification. + /// + /// # Returns + /// + /// bool - Whether nBTC burning was successful. + #[trusted_relayer] + #[pause(except(roles(Role::DAO)))] + pub fn verify_active_utxo_management_v2( + &mut self, + tx_id: String, + proof: TxInclusionProof, + ) -> Promise { + self.internal_verify_active_utxo_management_entry( + tx_id, + proof.tx_block_blockhash, + proof.tx_index, + proof.merkle_proof, + Some((proof.coinbase_tx_id, proof.coinbase_merkle_proof)), ) } @@ -427,11 +464,9 @@ impl Contract { &mut self, deposit_msg: DepositMsg, refund_address: String, - tx_bytes: Vec, + tx_bytes: Base64VecU8, vout: usize, - tx_block_blockhash: String, - tx_index: u64, - merkle_proof: Vec, + proof: TxInclusionProof, gas_fee: Option, ) -> Promise { if gas_fee.is_some() { @@ -447,9 +482,7 @@ impl Contract { refund_address, tx_bytes, vout, - tx_block_blockhash, - tx_index, - merkle_proof, + proof, gas_fee.map(|v| v.0), ) } @@ -516,86 +549,13 @@ impl Contract { /// * `merkle_proof` - Merkle proof for Light Client verification. #[trusted_relayer] #[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 { + pub fn verify_refund_finalize(&mut self, tx_id: String, proof: TxInclusionProof) -> 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, - account_id: AccountId, - mut psbt: PsbtWrapper, - ) { - self.require_pending_sign_capacity(&account_id); - - let (utxo_storage_keys, vutxos) = self.generate_vutxos(&mut psbt); - let (actual_received_amount, gas_fee) = - self.check_active_management_psbt_valid(&psbt, &vutxos); - require!( - gas_fee <= self.data().cur_available_protocol_fee, - "Insufficient protocol_fee" - ); - self.data_mut().cur_available_protocol_fee -= gas_fee; - self.data_mut().cur_reserved_protocol_fee += gas_fee; - - let need_signature_num = psbt.get_input_num(); - let psbt_hex = psbt.serialize(); - let btc_pending_id = psbt.get_pending_id(); - let btc_pending_info = BTCPendingInfo { - account_id: account_id.clone(), - btc_pending_id: btc_pending_id.clone(), - transfer_amount: 0, - actual_received_amount, - withdraw_fee: 0, - gas_fee, - burn_amount: gas_fee, - psbt_hex, - vutxos, - signatures: vec![None; need_signature_num], - tx_bytes_with_sign: None, - create_time_sec: nano_to_sec(env::block_timestamp()), - last_sign_time_sec: 0, - state: PendingInfoState::ActiveUtxoManagementOriginal(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(&account_id) - .btc_pending_sign_ids - .insert(btc_pending_id.clone()); - Event::UtxoRemoved { utxo_storage_keys }.emit(); - Event::GenerateBtcPendingInfo { - account_id: &account_id, - btc_pending_id: &btc_pending_id, - } - .emit(); + self.internal_verify_refund_finalize(tx_id, proof, btc_pending_info) } } diff --git a/contracts/satoshi-bridge/src/btc_light_client/active_utxo_management.rs b/contracts/satoshi-bridge/src/btc_light_client/active_utxo_management.rs index 385470ea..81e88c23 100644 --- a/contracts/satoshi-bridge/src/btc_light_client/active_utxo_management.rs +++ b/contracts/satoshi-bridge/src/btc_light_client/active_utxo_management.rs @@ -1,6 +1,7 @@ use crate::{ - env, near, require, serde_json, BTCPendingInfo, Contract, ContractExt, Gas, Promise, - PromiseOrValue, MAX_BOOL_RESULT, + env, near, psbt_wrapper::PsbtWrapper, require, serde_json, utils::nano_to_sec, AccountId, + BTCPendingInfo, Contract, ContractExt, Event, Gas, OriginalState, PendingInfoStage, + PendingInfoState, Promise, PromiseOrValue, MAX_BOOL_RESULT, }; pub const GAS_FOR_VERIFY_ACTIVE_UTXO_MANAGEMENT_CALL_BACK: Gas = Gas::from_tgas(50); @@ -12,6 +13,7 @@ impl Contract { tx_block_blockhash: String, tx_index: u64, merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, btc_pending_info: &BTCPendingInfo, ) -> Promise { let config = self.internal_config(); @@ -22,6 +24,7 @@ impl Contract { tx_block_blockhash, tx_index, merkle_proof, + coinbase_proof, confirmations, ) .then( @@ -30,6 +33,95 @@ impl Contract { .verify_active_utxo_management_callback(tx_id), ) } + + pub(crate) fn internal_verify_active_utxo_management_entry( + &mut self, + tx_id: String, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, + ) -> Promise { + let btc_pending_info = self.internal_unwrap_btc_pending_info(&tx_id); + btc_pending_info.assert_active_utxo_management_related_pending_verify_tx(); + if let Some(original_tx_id) = btc_pending_info.get_original_tx_id() { + require!( + self.check_btc_pending_info_exists(original_tx_id), + "original tx already verified" + ); + } + require!( + btc_pending_info.tx_bytes_with_sign.is_some(), + "Missing tx_bytes_with_sign" + ); + self.internal_verify_active_utxo_management( + tx_id, + tx_block_blockhash, + tx_index, + merkle_proof, + coinbase_proof, + btc_pending_info, + ) + } + + pub fn create_active_utxo_management_pending_info( + &mut self, + account_id: AccountId, + mut psbt: PsbtWrapper, + ) { + self.require_pending_sign_capacity(&account_id); + + let (utxo_storage_keys, vutxos) = self.generate_vutxos(&mut psbt); + let (actual_received_amount, gas_fee) = + self.check_active_management_psbt_valid(&psbt, &vutxos); + require!( + gas_fee <= self.data().cur_available_protocol_fee, + "Insufficient protocol_fee" + ); + self.data_mut().cur_available_protocol_fee -= gas_fee; + self.data_mut().cur_reserved_protocol_fee += gas_fee; + + let need_signature_num = psbt.get_input_num(); + let psbt_hex = psbt.serialize(); + let btc_pending_id = psbt.get_pending_id(); + let btc_pending_info = BTCPendingInfo { + account_id: account_id.clone(), + btc_pending_id: btc_pending_id.clone(), + transfer_amount: 0, + actual_received_amount, + withdraw_fee: 0, + gas_fee, + burn_amount: gas_fee, + psbt_hex, + vutxos, + signatures: vec![None; need_signature_num], + tx_bytes_with_sign: None, + create_time_sec: nano_to_sec(env::block_timestamp()), + last_sign_time_sec: 0, + state: PendingInfoState::ActiveUtxoManagementOriginal(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(&account_id) + .btc_pending_sign_ids + .insert(btc_pending_id.clone()); + Event::UtxoRemoved { utxo_storage_keys }.emit(); + Event::GenerateBtcPendingInfo { + account_id: &account_id, + btc_pending_id: &btc_pending_id, + } + .emit(); + } } #[near] diff --git a/contracts/satoshi-bridge/src/btc_light_client/deposit.rs b/contracts/satoshi-bridge/src/btc_light_client/deposit.rs index a92c4a24..413b7faa 100644 --- a/contracts/satoshi-bridge/src/btc_light_client/deposit.rs +++ b/contracts/satoshi-bridge/src/btc_light_client/deposit.rs @@ -2,11 +2,12 @@ use near_sdk::serde_json::Value; use crate::{ burn::GAS_FOR_BURN_CALL, - env, ext_nbtc, + deposit_msg::get_deposit_path, + env, ext_nbtc, generate_utxo_storage_key, mint::{GAS_FOR_MINT_CALL, GAS_FOR_MINT_CALL_BACK}, near, require, serde_json, AccountId, Contract, ContractExt, DepositMsg, Event, Gas, NearToken, - PendingUTXOInfo, PostAction, Promise, PromiseOrValue, SafeDepositMsg, MAX_BOOL_RESULT, - MAX_FT_TRANSFER_CALL_RESULT, U128, + PendingUTXOInfo, PostAction, Promise, PromiseOrValue, SafeDepositMsg, WrappedTransaction, + MAX_BOOL_RESULT, MAX_FT_TRANSFER_CALL_RESULT, U128, UTXO, }; pub const GAS_FOR_VERIFY_DEPOSIT_CALL_BACK: Gas = Gas::from_tgas(190); @@ -19,6 +20,7 @@ impl Contract { tx_block_blockhash: String, tx_index: u64, merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, pending_utxo_info: PendingUTXOInfo, deposit_msg: DepositMsg, ) -> Promise { @@ -35,6 +37,7 @@ impl Contract { tx_block_blockhash, tx_index, merkle_proof, + coinbase_proof, confirmations, ); @@ -67,13 +70,13 @@ impl Contract { } } - #[allow(clippy::too_many_arguments)] pub(crate) fn internal_safe_verify_deposit( &mut self, deposit_amount: u128, tx_block_blockhash: String, tx_index: u64, merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, pending_utxo_info: PendingUTXOInfo, recipient_id: AccountId, deposit_msg: SafeDepositMsg, @@ -86,6 +89,7 @@ impl Contract { tx_block_blockhash, tx_index, merkle_proof, + coinbase_proof, confirmations, ); @@ -108,6 +112,118 @@ impl Contract { ) } } + + pub(crate) fn internal_verify_deposit_entry( + &mut self, + deposit_msg: DepositMsg, + tx_bytes: Vec, + vout: usize, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, + ) -> Promise { + require!( + deposit_msg.safe_deposit.is_none(), + "safe_deposit not supported in verify_deposit" + ); + let path = get_deposit_path(&deposit_msg); + let transaction = WrappedTransaction::decode(&tx_bytes, &self.internal_config().chain) + .expect("Deserialization tx_bytes failed"); + let deposit_amount = u128::from(transaction.output()[vout].value.to_sat()); + require!(deposit_amount > 0, "Invalid deposit_amount"); + let deposit_address = self.generate_utxo_chain_address(&path); + let deposit_address_script_pubkey = deposit_address + .script_pubkey() + .expect("Invalid deposit address"); + require!( + deposit_address_script_pubkey == transaction.output()[vout].script_pubkey, + "Invalid deposit tx_bytes" + ); + + let utxo = UTXO { + path, + tx_bytes, + vout, + balance: transaction.output()[vout].value.to_sat(), + }; + 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")), + ); + self.internal_verify_deposit( + deposit_amount, + tx_block_blockhash, + tx_index, + merkle_proof, + coinbase_proof, + PendingUTXOInfo { + tx_id, + utxo_storage_key, + utxo, + }, + deposit_msg, + ) + } + + pub(crate) fn internal_safe_verify_deposit_entry( + &mut self, + deposit_msg: DepositMsg, + tx_bytes: Vec, + vout: usize, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, + ) -> Promise { + require!( + env::attached_deposit() >= self.required_balance_for_safe_deposit(), + "Insufficient deposit for storage" + ); + + let path = get_deposit_path(&deposit_msg); + let safe_deposit_msg = deposit_msg + .safe_deposit + .unwrap_or_else(|| env::panic_str("safe_deposit is required in safe_verify_deposit")); + + let transaction = WrappedTransaction::decode(&tx_bytes, &self.internal_config().chain) + .expect("Deserialization tx_bytes failed"); + let deposit_amount = transaction.output()[vout].value.to_sat().into(); + require!(deposit_amount > 0, "Invalid deposit_amount"); + let deposit_address = self.generate_utxo_chain_address(&path); + let deposit_address_script_pubkey = deposit_address + .script_pubkey() + .expect("Invalid deposit address"); + require!( + deposit_address_script_pubkey == transaction.output()[vout].script_pubkey, + "Invalid deposit tx_bytes" + ); + + let utxo = UTXO { + path, + tx_bytes, + vout, + balance: transaction.output()[vout].value.to_sat(), + }; + let tx_id = transaction.compute_txid().to_string(); + let utxo_storage_key = generate_utxo_storage_key(tx_id.clone(), vout.try_into().unwrap()); + + self.internal_safe_verify_deposit( + deposit_amount, + tx_block_blockhash, + tx_index, + merkle_proof, + coinbase_proof, + PendingUTXOInfo { + tx_id, + utxo_storage_key, + utxo, + }, + deposit_msg.recipient_id, + safe_deposit_msg, + ) + } } #[near] diff --git a/contracts/satoshi-bridge/src/btc_light_client/mod.rs b/contracts/satoshi-bridge/src/btc_light_client/mod.rs index da524661..ff4ece4b 100644 --- a/contracts/satoshi-bridge/src/btc_light_client/mod.rs +++ b/contracts/satoshi-bridge/src/btc_light_client/mod.rs @@ -58,7 +58,55 @@ impl ProofArgs { .into_iter() .map(|v| { v.parse() - .unwrap_or_else(|_| env::panic_str("Invalid merkle_proof: {v:?}")) + .unwrap_or_else(|_| env::panic_str(&format!("Invalid merkle_proof: {v:?}"))) + }) + .collect(), + confirmations, + } + } +} + +#[near(serializers = [borsh])] +pub struct ProofArgsV2 { + pub tx_id: H256, + pub tx_block_blockhash: H256, + pub tx_index: u64, + pub merkle_proof: Vec, + pub coinbase_tx_id: H256, + pub coinbase_merkle_proof: Vec, + pub confirmations: u64, +} + +impl ProofArgsV2 { + pub fn new( + tx_id: String, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + coinbase_tx_id: String, + coinbase_merkle_proof: Vec, + confirmations: u64, + ) -> Self { + ProofArgsV2 { + tx_id: tx_id.parse().expect("Invalid tx_id"), + tx_block_blockhash: tx_block_blockhash + .parse() + .expect("Invalid tx_block_blockhash"), + tx_index, + merkle_proof: merkle_proof + .into_iter() + .map(|v| { + v.parse() + .unwrap_or_else(|_| env::panic_str(&format!("Invalid merkle_proof: {v:?}"))) + }) + .collect(), + coinbase_tx_id: coinbase_tx_id.parse().expect("Invalid coinbase_tx_id"), + coinbase_merkle_proof: coinbase_merkle_proof + .into_iter() + .map(|v| { + v.parse().unwrap_or_else(|_| { + env::panic_str(&format!("Invalid coinbase_merkle_proof: {v:?}")) + }) }) .collect(), confirmations, @@ -111,6 +159,7 @@ impl Serialize for H256 { #[ext_contract(ext_btc_light_client)] pub trait BtcLightClient { fn verify_transaction_inclusion(&self, #[serializer(borsh)] args: ProofArgs) -> bool; + fn verify_transaction_inclusion_v2(&self, #[serializer(borsh)] args: ProofArgsV2) -> bool; fn get_last_block_height(&self) -> u32; } @@ -122,17 +171,30 @@ impl Contract { tx_block_blockhash: String, tx_index: u64, merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, confirmations: u64, ) -> Promise { - ext_btc_light_client::ext(btc_light_client_account_id) - .with_static_gas(GAS_FOR_VERIFY_TRANSACTION_INCLUSION) - .verify_transaction_inclusion(ProofArgs::new( - tx_id.clone(), + let ext = ext_btc_light_client::ext(btc_light_client_account_id) + .with_static_gas(GAS_FOR_VERIFY_TRANSACTION_INCLUSION); + if let Some((coinbase_tx_id, coinbase_merkle_proof)) = coinbase_proof { + ext.verify_transaction_inclusion_v2(ProofArgsV2::new( + tx_id, + tx_block_blockhash, + tx_index, + merkle_proof, + coinbase_tx_id, + coinbase_merkle_proof, + confirmations, + )) + } else { + ext.verify_transaction_inclusion(ProofArgs::new( + tx_id, tx_block_blockhash, tx_index, merkle_proof, confirmations, )) + } } pub fn get_last_block_height_promise(&self) -> Promise { diff --git a/contracts/satoshi-bridge/src/btc_light_client/withdraw.rs b/contracts/satoshi-bridge/src/btc_light_client/withdraw.rs index 794a068c..1b6b71bb 100644 --- a/contracts/satoshi-bridge/src/btc_light_client/withdraw.rs +++ b/contracts/satoshi-bridge/src/btc_light_client/withdraw.rs @@ -13,6 +13,7 @@ impl Contract { tx_block_blockhash: String, tx_index: u64, merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, btc_pending_info: &BTCPendingInfo, ) -> Promise { let config = self.internal_config(); @@ -23,6 +24,7 @@ impl Contract { tx_block_blockhash, tx_index, merkle_proof, + coinbase_proof, confirmations, ) .then( @@ -31,6 +33,36 @@ impl Contract { .internal_verify_withdraw_callback(tx_id), ) } + + pub(crate) fn internal_verify_withdraw_entry( + &mut self, + tx_id: String, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + coinbase_proof: Option<(String, Vec)>, + ) -> Promise { + let btc_pending_info = self.internal_unwrap_btc_pending_info(&tx_id); + btc_pending_info.assert_withdraw_related_pending_verify_tx(); + if let Some(original_tx_id) = btc_pending_info.get_original_tx_id() { + require!( + self.check_btc_pending_info_exists(original_tx_id), + "original tx already verified" + ); + } + require!( + btc_pending_info.tx_bytes_with_sign.is_some(), + "Missing tx_bytes_with_sign" + ); + self.internal_verify_withdraw( + tx_id, + tx_block_blockhash, + tx_index, + merkle_proof, + coinbase_proof, + btc_pending_info, + ) + } } #[near] diff --git a/contracts/satoshi-bridge/src/lib.rs b/contracts/satoshi-bridge/src/lib.rs index 813c8164..56ddac55 100644 --- a/contracts/satoshi-bridge/src/lib.rs +++ b/contracts/satoshi-bridge/src/lib.rs @@ -3,7 +3,7 @@ use near_sdk::{ assert_one_yocto, borsh::{BorshDeserialize, BorshSerialize}, env, ext_contract, is_promise_success, - json_types::U128, + json_types::{Base64VecU8, U128}, log, near, require, serde::{Deserialize, Serialize}, serde_json::{self, json, Value}, @@ -114,6 +114,16 @@ pub enum Role { RefundOperator, } +/// Transaction inclusion proof with coinbase verification (v2). +#[near(serializers = [json])] +pub struct TxInclusionProof { + pub tx_block_blockhash: String, + pub tx_index: u64, + pub merkle_proof: Vec, + pub coinbase_tx_id: String, + pub coinbase_merkle_proof: Vec, +} + #[near(serializers = [borsh])] pub struct ContractData { pub config: LazyOption, diff --git a/contracts/satoshi-bridge/src/refund.rs b/contracts/satoshi-bridge/src/refund.rs index ac56affc..1278d421 100644 --- a/contracts/satoshi-bridge/src/refund.rs +++ b/contracts/satoshi-bridge/src/refund.rs @@ -1,8 +1,10 @@ use bitcoin::{Amount, OutPoint, TxOut}; +use near_sdk::json_types::Base64VecU8; use crate::{ env, near, require, serde_json, BTCPendingInfo, Contract, ContractExt, DepositMsg, Event, Gas, - OriginalState, PendingInfoStage, PendingInfoState, Promise, MAX_BOOL_RESULT, UTXO, VUTXO, + OriginalState, PendingInfoStage, PendingInfoState, Promise, TxInclusionProof, MAX_BOOL_RESULT, + UTXO, VUTXO, }; use crate::deposit_msg::get_deposit_path; @@ -19,7 +21,7 @@ pub const GAS_FOR_VERIFY_REFUND_CALLBACK: Gas = Gas::from_tgas(20); pub struct RefundRequest { pub deposit_msg_json: String, pub utxo_storage_key: String, - pub tx_bytes: Vec, + pub tx_bytes: Base64VecU8, pub vout: usize, pub amount: u128, pub refund_address: String, @@ -70,11 +72,9 @@ impl Contract { &self, deposit_msg: DepositMsg, refund_address: String, - tx_bytes: Vec, + tx_bytes: Base64VecU8, vout: usize, - tx_block_blockhash: String, - tx_index: u64, - merkle_proof: Vec, + proof: TxInclusionProof, gas_fee: Option, ) -> Promise { if let Some(msg_refund_address) = &deposit_msg.refund_address { @@ -85,7 +85,7 @@ impl Contract { } let transaction = - crate::WrappedTransaction::decode(&tx_bytes, &self.internal_config().chain) + crate::WrappedTransaction::decode(&tx_bytes.0, &self.internal_config().chain) .expect("Deserialization tx_bytes failed"); let tx_id = transaction.compute_txid().to_string(); @@ -96,9 +96,10 @@ impl Contract { self.verify_transaction_inclusion_promise( config.btc_light_client_account_id.clone(), tx_id, - tx_block_blockhash, - tx_index, - merkle_proof, + proof.tx_block_blockhash, + proof.tx_index, + proof.merkle_proof, + Some((proof.coinbase_tx_id, proof.coinbase_merkle_proof)), confirmations, ) .then( @@ -154,7 +155,7 @@ impl Contract { // Parse the original deposit transaction to get OutPoint let transaction = - crate::WrappedTransaction::decode(&refund_request.tx_bytes, &config.chain) + crate::WrappedTransaction::decode(&refund_request.tx_bytes.0, &config.chain) .expect("Deserialization tx_bytes failed"); let txid = transaction.compute_txid(); let outpoint = OutPoint { @@ -199,7 +200,7 @@ impl Contract { let path = get_deposit_path(&deposit_msg); let vutxo = VUTXO::Current(UTXO { path, - tx_bytes: refund_request.tx_bytes.clone(), + tx_bytes: refund_request.tx_bytes.0.clone(), vout: refund_request.vout, balance: u64::try_from(refund_request.amount) .unwrap_or_else(|_| env::panic_str("Amount overflow")), @@ -273,9 +274,7 @@ impl Contract { pub fn internal_verify_refund_finalize( &self, tx_id: String, - tx_block_blockhash: String, - tx_index: u64, - merkle_proof: Vec, + proof: TxInclusionProof, btc_pending_info: &BTCPendingInfo, ) -> Promise { let config = self.internal_config(); @@ -283,9 +282,10 @@ impl Contract { self.verify_transaction_inclusion_promise( config.btc_light_client_account_id.clone(), tx_id.clone(), - tx_block_blockhash, - tx_index, - merkle_proof, + proof.tx_block_blockhash, + proof.tx_index, + proof.merkle_proof, + Some((proof.coinbase_tx_id, proof.coinbase_merkle_proof)), confirmations, ) .then( @@ -325,7 +325,7 @@ impl Contract { &mut self, deposit_msg: DepositMsg, refund_address: String, - tx_bytes: Vec, + tx_bytes: Base64VecU8, vout: usize, gas_fee: Option, ) -> bool { @@ -336,7 +336,7 @@ impl Contract { require!(is_valid, "verify_transaction_inclusion return false"); let config = self.internal_config(); - let transaction = crate::WrappedTransaction::decode(&tx_bytes, &config.chain) + let transaction = crate::WrappedTransaction::decode(&tx_bytes.0, &config.chain) .expect("Deserialization tx_bytes failed"); let output = &transaction.output()[vout]; diff --git a/contracts/satoshi-bridge/tests/setup/context.rs b/contracts/satoshi-bridge/tests/setup/context.rs index 5202e819..7d68fe4e 100644 --- a/contracts/satoshi-bridge/tests/setup/context.rs +++ b/contracts/satoshi-bridge/tests/setup/context.rs @@ -8,7 +8,7 @@ use bitcoin::{OutPoint, TxOut}; use near_contract_standards::fungible_token::metadata::FungibleTokenMetadata; use near_sdk::{ base64::{self, Engine}, - json_types::U128, + json_types::{Base64VecU8, U128}, serde_json::{json, Value}, AccountId, Gas, NearToken, }; @@ -908,6 +908,86 @@ impl Context { .await } + pub async fn verify_deposit_v2( + &self, + user: &str, + deposit_msg: DepositMsg, + tx_bytes: Vec, + vout: u32, + proof: Value, + ) -> Result { + self.get_account_by_name(user) + .call(self.bridge_contract.id(), "verify_deposit_v2") + .args_json(json!({ + "deposit_msg": deposit_msg, + "tx_bytes": Base64VecU8(tx_bytes), + "vout": vout, + "proof": proof, + })) + .max_gas() + .transact() + .await + } + + pub async fn verify_withdraw_v2( + &self, + user: &str, + tx_id: &str, + proof: Value, + ) -> Result { + self.get_account_by_name(user) + .call(self.bridge_contract.id(), "verify_withdraw_v2") + .args_json(json!({ + "tx_id": tx_id, + "proof": proof, + })) + .max_gas() + .transact() + .await + } + + pub async fn safe_verify_deposit_v2( + &self, + user: &str, + deposit_msg: DepositMsg, + tx_bytes: Vec, + vout: u32, + proof: Value, + ) -> Result { + self.get_account_by_name(user) + .call(self.bridge_contract.id(), "safe_verify_deposit_v2") + .args_json(json!({ + "deposit_msg": deposit_msg, + "tx_bytes": Base64VecU8(tx_bytes), + "vout": vout, + "proof": proof, + })) + .deposit(NearToken::from_near(1)) + .max_gas() + .transact() + .await + } + + pub async fn verify_active_utxo_management_v2( + &self, + user: &str, + tx_id: &str, + proof: Value, + ) -> Result { + self.get_account_by_name(user) + .call( + self.bridge_contract.id(), + "verify_active_utxo_management_v2", + ) + .args_json(json!({ + "tx_id": tx_id, + "proof": proof, + })) + .max_gas() + .transact() + .await + } + pub async fn clear_invalid_pending_verify_rbf( &self, user: &str, @@ -1298,11 +1378,15 @@ impl Context { .args_json(json!({ "deposit_msg": deposit_msg, "refund_address": refund_address, - "tx_bytes": tx_bytes, + "tx_bytes": Base64VecU8(tx_bytes), "vout": vout, - "tx_block_blockhash": tx_block_blockhash, - "tx_index": tx_index, - "merkle_proof": merkle_proof, + "proof": { + "tx_block_blockhash": tx_block_blockhash, + "tx_index": tx_index, + "merkle_proof": merkle_proof, + "coinbase_tx_id": "0000000000000000000000000000000000000000000000000000000000000000", + "coinbase_merkle_proof": Vec::::new(), + }, "gas_fee": gas_fee, })) .max_gas() @@ -1363,9 +1447,13 @@ impl Context { .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, + "proof": { + "tx_block_blockhash": tx_block_blockhash, + "tx_index": tx_index, + "merkle_proof": merkle_proof, + "coinbase_tx_id": "0000000000000000000000000000000000000000000000000000000000000000", + "coinbase_merkle_proof": Vec::::new(), + }, })) .max_gas() .transact() diff --git a/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs b/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs index 817463c4..506547f8 100644 --- a/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs +++ b/contracts/satoshi-bridge/tests/test_satoshi_bridge.rs @@ -2765,6 +2765,205 @@ async fn test_unauthorized_account_cannot_call_trusted_relayer_methods() { ); } +/// Helper: builds a `TxInclusionProof` JSON value for v2 methods. +fn mock_proof() -> near_sdk::serde_json::Value { + near_sdk::serde_json::json!({ + "tx_block_blockhash": "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d", + "tx_index": 1u64, + "merkle_proof": Vec::::new(), + "coinbase_tx_id": "0000000000000000000000000000000000000000000000000000000000000000", + "coinbase_merkle_proof": Vec::::new(), + }) +} + +#[tokio::test] +async fn test_verify_deposit_v2() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + 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(); + + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 0); + + // verify_deposit_v2: proof is a nested JSON object + check!(printr "verify_deposit_v2" context.verify_deposit_v2( + "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![( + "e1e1069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f50", + 1, + None, + )], + vec![ + (alice_btc_deposit_address.as_str(), 50000), + (TARGET_ADDRESS, 50000) + ], + ), + 0, + mock_proof() + )); + + assert!(context.ft_balance_of("alice").await.unwrap().0 > 0); + assert_eq!(context.get_utxos_paged().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn test_safe_verify_deposit_v2() { + 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: String::new() }), + refund_address: None, + }; + let deposit_address = context + .get_user_deposit_address(deposit_msg.clone()) + .await + .unwrap(); + + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 0); + + // Register alice for nBTC storage (required for safe_verify_deposit) + check!(context.storage_deposit("nbtc", "alice")); + + // safe_verify_deposit_v2: same nested proof struct + check!(printr "safe_verify_deposit_v2" context.safe_verify_deposit_v2( + "relayer", + deposit_msg, + generate_transaction_bytes( + vec![( + "f2f2069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f60", + 1, + None, + )], + vec![ + (deposit_address.as_str(), 50000), + (TARGET_ADDRESS, 50000) + ], + ), + 0, + mock_proof() + )); + + assert!(context.ft_balance_of("alice").await.unwrap().0 > 0); +} + +#[tokio::test] +async fn test_verify_withdraw_v2() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + let withdraw_change_address = context.get_change_address().await.unwrap(); + 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(); + + // 1. Deposit via v1 to get UTXOs and nBTC + 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![( + "a3a3069f02ad4ca31a16113903ab9fe9e8da6ddf20cad4b461b71e8b96050f70", + 1, + None, + )], + vec![ + (alice_btc_deposit_address.as_str(), 500000), + (TARGET_ADDRESS, 500000) + ], + ), + 0, + "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d".to_string(), + 1, + vec![] + )); + assert!(context.ft_balance_of("alice").await.unwrap().0 > 0); + + check!(context.storage_deposit("nbtc", "bridge")); + + // 2. Withdraw + let utxos_keys = context + .get_utxos_paged() + .await + .unwrap() + .keys() + .cloned() + .collect::>(); + let first_utxo = utxos_keys[0].split('@').collect::>(); + // withdraw_fee = 50000, gas_fee = 25000 + // user_output = 110000 - 50000 - 25000 = 35000 + // change = 500000 - 35000 - 25000 = 440000 + check!(print context.do_withdraw("alice", "bridge", 110000, TokenReceiverMessage::Withdraw { + target_btc_address: TARGET_ADDRESS.to_string(), + input: vec![OutPoint { + txid: first_utxo[0].parse().unwrap(), + vout: first_utxo[1].parse().unwrap(), + }], + output: vec![TxOut { + value: Amount::from_sat(35000), + script_pubkey: Address::parse(TARGET_ADDRESS, get_chain()) + .expect("Invalid btc address") + .script_pubkey().expect("Failed to get script pubkey") + }, TxOut { + value: Amount::from_sat(440000), + script_pubkey: Address::parse(withdraw_change_address.as_str(), get_chain()) + .expect("Invalid btc address") + .script_pubkey().expect("Failed to get script pubkey") + }], + max_gas_fee: None, + chain_specific_data: None, + })); + + // 3. Sign + let pending = context.get_btc_pending_infos_paged().await.unwrap(); + let keys = pending.keys().cloned().collect::>(); + check!(print context.sign_btc_transaction("relayer", &keys[0], 0, 0)); + + // 4. Verify withdraw via v2 — nested proof + check!(print "verify_withdraw_v2" context.verify_withdraw_v2( + "relayer", + &keys[0], + mock_proof() + )); + + // Pending info should be cleared + assert!(context + .get_btc_pending_infos_paged() + .await + .unwrap() + .is_empty()); +} + // Regression test for the safe_mint fix. // When safe_verify_deposit is called with an unregistered recipient, safe_mint // must deposit the amount to the bridge before returning U128(0) so that @@ -3081,6 +3280,22 @@ async fn test_verify_deposit_post_action_to_bridge_is_rejected() { .is_empty()); } +#[tokio::test] +async fn test_verify_active_utxo_management_v2() { + let worker = near_workspaces::sandbox().await.unwrap(); + let context = Context::new(&worker, Some(CHAIN.to_string())).await; + + // verify_active_utxo_management_v2 with non-existent tx_id + check!( + print "verify_active_utxo_management_v2" + context.verify_active_utxo_management_v2( + "relayer", + "non_existent_tx_id", + mock_proof() + ) + ); +} + // safe_mint (in nbtc) must reject account_id == bridge_id. Otherwise the // bridge-to-bridge ft_transfer* inside safe_mint would panic with // "sender == receiver" from the NEP-141 standard, leaving the bridge with