diff --git a/Cargo.lock b/Cargo.lock index 6ed87652..ac8fa470 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1600,7 +1600,7 @@ dependencies = [ [[package]] name = "bridge-cli" -version = "0.3.67" +version = "0.3.69" dependencies = [ "alloy", "aptos-bridge-client", @@ -4336,7 +4336,7 @@ dependencies = [ [[package]] name = "near-bridge-client" -version = "0.2.29" +version = "0.2.30" dependencies = [ "base64 0.21.7", "bitcoin", @@ -4759,7 +4759,7 @@ dependencies = [ [[package]] name = "near-rpc-client" -version = "0.2.1" +version = "0.2.2" dependencies = [ "base64 0.22.1", "borsh", @@ -5095,7 +5095,7 @@ dependencies = [ [[package]] name = "omni-connector" -version = "0.5.2" +version = "0.5.4" dependencies = [ "alloy", "aptos-bridge-client", @@ -5106,6 +5106,7 @@ dependencies = [ "derive_builder", "eth-proof", "evm-bridge-client", + "futures", "hex", "hypercore-bridge-client", "light-client", diff --git a/bridge-cli/Cargo.toml b/bridge-cli/Cargo.toml index c5762910..d9fc827b 100644 --- a/bridge-cli/Cargo.toml +++ b/bridge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bridge-cli" -version = "0.3.68" +version = "0.3.69" edition = "2021" repository = "https://github.com/Near-One/bridge-sdk-rs" rust-version = "1.88.0" diff --git a/bridge-cli/src/omni_connector_command.rs b/bridge-cli/src/omni_connector_command.rs index 8149b3d5..dea2b9c1 100644 --- a/bridge-cli/src/omni_connector_command.rs +++ b/bridge-cli/src/omni_connector_command.rs @@ -2012,15 +2012,17 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { // `--dry-run` (if set) is honored at the NEAR client: the verify_deposit // transaction is printed as an unsigned payload instead of broadcast. + // The `_checked` variant pre-checks light-client confirmations, giving + // a clear error instead of a contract panic. connector - .fin_transfer(FinTransferArgs::NearFinTransferBTC { - chain_kind: chain.into(), + .near_fin_transfer_btc_checked( + chain.into(), btc_tx_hash, - vout: resolved_vout, - btc_deposit_args: deposit_args, + resolved_vout, + deposit_args, prefetched, - transaction_options: TransactionOptions::default(), - }) + TransactionOptions::default(), + ) .await .unwrap(); } diff --git a/bridge-sdk/bridge-clients/near-bridge-client/Cargo.toml b/bridge-sdk/bridge-clients/near-bridge-client/Cargo.toml index 4a2e8794..e9aace64 100644 --- a/bridge-sdk/bridge-clients/near-bridge-client/Cargo.toml +++ b/bridge-sdk/bridge-clients/near-bridge-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "near-bridge-client" -version = "0.2.29" +version = "0.2.30" edition = "2021" [dependencies] diff --git a/bridge-sdk/bridge-clients/near-bridge-client/src/btc.rs b/bridge-sdk/bridge-clients/near-bridge-client/src/btc.rs index ac79164a..3729cd65 100644 --- a/bridge-sdk/bridge-clients/near-bridge-client/src/btc.rs +++ b/bridge-sdk/bridge-clients/near-bridge-client/src/btc.rs @@ -18,6 +18,7 @@ use serde_with::{serde_as, DisplayFromStr}; use std::cmp::max; use std::collections::HashMap; use std::str::FromStr; +use std::sync::OnceLock; use utxo_utils::UTXO; const INIT_BTC_TRANSFER_GAS: u64 = 300_000_000_000_000; @@ -315,15 +316,13 @@ pub struct BtcConfirmationContext { } impl BtcConfirmationContext { - /// Compute required confirmations for a contract call. + /// Required confirmations without the block-cumulative amount rules. /// /// `uses_extra_msg_path` must be `true` only when the contract will dispatch - /// to `get_extra_msg_confirmations` — that is, the SDK is calling - /// `verify_deposit` AND `deposit_msg.extra_msg.is_some()`. All other paths - /// (`safe_verify_deposit`, `verify_withdraw`, `verify_active_utxo_management`, - /// and `verify_deposit` without `extra_msg`) dispatch to `get_confirmations` - /// and must pass `false` here, even if the surrounding `DepositMsg` happens - /// to carry an `extra_msg` field. + /// to the extra-msg confirmation delta — that is, the SDK is calling + /// `verify_deposit_v2` with `extra_msg` set and no `safe_deposit`. All other + /// paths (`verify_withdraw_v2` and deposits without `extra_msg`) + /// use the plain delta and must pass `false` here. pub fn required_confirmations(&self, amount: u128, uses_extra_msg_path: bool) -> Result { let base = base_confirmations(&self.confirmations_strategy, amount)?; @@ -341,6 +340,51 @@ impl BtcConfirmationContext { Ok(u64::from(base) + u64::from(delta)) } + + /// Mirrors `Config::max_required_confirmations` from the satoshi-bridge + /// contract: the depth refund requests must reach unconditionally — no + /// whitelist discount. + pub fn max_required_confirmations(&self) -> Result { + let max_tier = self + .confirmations_strategy + .values() + .max() + .copied() + .ok_or_else(|| { + BridgeSdkError::ContractConfigurationError( + "confirmations_strategy is empty".to_string(), + ) + })?; + + Ok(u64::from(max_tier) + + u64::from(max( + self.confirmations_delta, + self.extra_msg_confirmations_delta, + ))) + } +} + +/// UTXO-chain transaction type whose verification on the BTC connector +/// contract requires a light-client confirmation depth. +#[derive(Clone, Copy, Debug)] +pub enum BtcTxType { + /// `verify_deposit_v2`. `uses_extra_msg_path` must be `true` only when the + /// contract will dispatch to the extra-msg confirmation delta — see + /// [`BtcConfirmationContext::required_confirmations`]. + Deposit { + amount: u128, + uses_extra_msg_path: bool, + }, + /// `verify_withdraw_v2`. + Withdraw { amount: u128 }, + /// `verify_active_utxo_management_v2`. + ActiveUtxoManagement { amount: u128 }, + /// `request_refund`: newer contracts demand the maximum confirmation depth + /// unconditionally. Against older versions, which tier refund requests by + /// amount, this over-waits slightly — never premature. + RefundRequest, + /// `verify_refund_finalize`. + RefundFinalize { amount: u128 }, } #[derive(Clone, Debug)] @@ -643,7 +687,7 @@ impl NearBridgeClient { Ok(tx_hash) } - /// Finalizes a BTC transfer by calling `verify_deposit` or `verify_safe_deposit` on the BTC connector contract. + /// Finalizes a BTC transfer by calling `verify_deposit_v2` on the BTC connector contract. #[tracing::instrument(skip_all, name = "NEAR FIN BTC TRANSFER")] pub async fn fin_btc_transfer( &self, @@ -1343,6 +1387,133 @@ impl NearBridgeClient { }) } + /// Required confirmations for a deposit, from the contract's live + /// `get_required_confirmations` view. If the contract predates the view + /// (method not found), that fact is remembered for the lifetime of this + /// client and the cached local amount-tier formula is used instead — + /// restart the relayer to pick up a contract upgrade. Any other error is + /// propagated, since falling back would underestimate against a newer + /// contract. + pub async fn get_required_confirmations_for_deposit( + &self, + chain: ChainKind, + block_height: u64, + amount: u128, + has_extra_msg: bool, + ) -> Result { + let missing_view = self.missing_required_confirmations_view_cell(chain)?; + + if missing_view.get().is_none() { + let endpoint = self.endpoint()?; + let btc_connector = self.utxo_chain_connector(chain)?; + let relayer_account_id = self.account_id()?; + + let response = near_rpc_client::view( + endpoint, + ViewRequest { + contract_account_id: btc_connector, + method_name: "get_required_confirmations".to_string(), + args: json!({ + "block_height": block_height, + "amount": U128(amount), + "relayer_account_id": relayer_account_id, + "has_extra_msg": has_extra_msg, + }), + }, + ) + .await; + + match response { + Ok(response) => return Ok(serde_json::from_slice::(&response)?), + Err(err) if err.is_method_not_found() => { + let _ = missing_view.set(()); + } + Err(err) => return Err(err.into()), + } + } + + self.cached_btc_confirmation_context(chain) + .await? + .required_confirmations(amount, has_extra_msg) + } + + fn missing_required_confirmations_view_cell(&self, chain: ChainKind) -> Result<&OnceLock<()>> { + match chain { + ChainKind::Btc => Ok(&self.btc_missing_required_confirmations_view), + ChainKind::Zcash => Ok(&self.zcash_missing_required_confirmations_view), + _ => Err(BridgeSdkError::InvalidArgument(format!( + "missing_required_confirmations_view_cell called with non-UTXO chain: {chain:?}" + ))), + } + } + + fn btc_confirmation_context_cell( + &self, + chain: ChainKind, + ) -> Result<&OnceLock> { + match chain { + ChainKind::Btc => Ok(&self.btc_confirmation_context), + ChainKind::Zcash => Ok(&self.zcash_confirmation_context), + _ => Err(BridgeSdkError::InvalidArgument(format!( + "cached_btc_confirmation_context called with non-UTXO chain: {chain:?}" + ))), + } + } + + /// The BTC connector confirmation context for `chain`, fetched from the + /// contract on the first call per chain and reused for the lifetime of + /// this client. + pub async fn cached_btc_confirmation_context( + &self, + chain: ChainKind, + ) -> Result { + let cell = self.btc_confirmation_context_cell(chain)?; + + if let Some(ctx) = cell.get() { + return Ok(ctx.clone()); + } + + let ctx = self.get_btc_confirmation_context(chain).await?; + let _ = cell.set(ctx.clone()); + + Ok(ctx) + } + + /// Confirmations required to verify `tx_type` on the BTC connector + /// contract. Deposits ask the contract's live `get_required_confirmations` + /// view; the other paths use the amount-tier formula. + pub async fn get_required_btc_confirmations( + &self, + chain: ChainKind, + block_height: u64, + tx_type: BtcTxType, + ) -> Result { + match tx_type { + BtcTxType::Deposit { + amount, + uses_extra_msg_path, + } => { + self.get_required_confirmations_for_deposit( + chain, + block_height, + amount, + uses_extra_msg_path, + ) + .await + } + BtcTxType::Withdraw { amount } + | BtcTxType::ActiveUtxoManagement { amount } + | BtcTxType::RefundFinalize { amount } => self + .cached_btc_confirmation_context(chain) + .await? + .required_confirmations(amount, false), + BtcTxType::RefundRequest => self + .cached_btc_confirmation_context(chain) + .await? + .max_required_confirmations(), + } + } + async fn get_whitelist_metadata(&self, chain: ChainKind) -> Result { let endpoint = self.endpoint()?; let btc_connector = self.utxo_chain_connector(chain)?; @@ -1909,4 +2080,51 @@ mod tests { Err(BridgeSdkError::ContractConfigurationError(_)) )); } + + fn confirmation_context( + strategy_entries: &[(&str, u8)], + confirmations_delta: u8, + extra_msg_confirmations_delta: u8, + ) -> BtcConfirmationContext { + BtcConfirmationContext { + confirmations_strategy: strategy(strategy_entries), + confirmations_delta, + extra_msg_confirmations_delta, + is_relayer_whitelisted: false, + is_extra_msg_relayer_whitelisted: false, + } + } + + #[test] + fn max_required_confirmations_takes_max_tier_and_max_delta() { + let ctx = confirmation_context( + &[("100000000", 2), ("1000000000", 4), ("10000000000", 6)], + 3, + 5, + ); + assert_eq!(ctx.max_required_confirmations().unwrap(), 6 + 5); + } + + #[test] + fn max_required_confirmations_with_equal_deltas() { + let ctx = confirmation_context(&[("100000000", 2), ("1000000000", 4)], 3, 3); + assert_eq!(ctx.max_required_confirmations().unwrap(), 4 + 3); + } + + #[test] + fn max_required_confirmations_ignores_whitelists() { + let mut ctx = confirmation_context(&[("100000000", 2)], 1, 4); + ctx.is_relayer_whitelisted = true; + ctx.is_extra_msg_relayer_whitelisted = true; + assert_eq!(ctx.max_required_confirmations().unwrap(), 2 + 4); + } + + #[test] + fn max_required_confirmations_empty_strategy_errors() { + let ctx = confirmation_context(&[], 3, 5); + assert!(matches!( + ctx.max_required_confirmations(), + Err(BridgeSdkError::ContractConfigurationError(_)) + )); + } } diff --git a/bridge-sdk/bridge-clients/near-bridge-client/src/near_bridge_client.rs b/bridge-sdk/bridge-clients/near-bridge-client/src/near_bridge_client.rs index 115976fb..5f1e2bfd 100644 --- a/bridge-sdk/bridge-clients/near-bridge-client/src/near_bridge_client.rs +++ b/bridge-sdk/bridge-clients/near-bridge-client/src/near_bridge_client.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::str::FromStr; +use std::sync::OnceLock; use bridge_connector_common::result::{BridgeSdkError, Result}; use derive_builder::Builder; @@ -114,6 +115,15 @@ pub struct NearBridgeClient { utxo_bridges: HashMap, #[doc = r"Bridge Indexer API base URL"] bridge_indexer_api_url: Option, + #[builder(setter(skip), default)] + btc_confirmation_context: OnceLock, + #[builder(setter(skip), default)] + zcash_confirmation_context: OnceLock, + #[doc = r"Set once the BTC connector is seen to predate the `get_required_confirmations` view; skips the doomed RPC call afterwards"] + #[builder(setter(skip), default)] + btc_missing_required_confirmations_view: OnceLock<()>, + #[builder(setter(skip), default)] + zcash_missing_required_confirmations_view: OnceLock<()>, } impl NearBridgeClient { diff --git a/bridge-sdk/connectors/bridge-connector-common/src/result.rs b/bridge-sdk/connectors/bridge-connector-common/src/result.rs index 420d2c09..36d09e10 100644 --- a/bridge-sdk/connectors/bridge-connector-common/src/result.rs +++ b/bridge-sdk/connectors/bridge-connector-common/src/result.rs @@ -51,8 +51,13 @@ pub enum BridgeSdkError { InsufficientBalance(String), #[error("Invalid argument provided: {0}")] InvalidArgument(String), - #[error("Light client not synced, current height {0}")] - LightClientNotSynced(u64), + #[error( + "Light client not synced, current height {current_height}, waiting for {target_height}" + )] + LightClientNotSynced { + current_height: u64, + target_height: u64, + }, #[error("Invalid log found. {0}")] InvalidLog(String), #[error("Invalid contract configuration. {0}")] diff --git a/bridge-sdk/connectors/omni-connector/Cargo.toml b/bridge-sdk/connectors/omni-connector/Cargo.toml index a511f1db..75fc086c 100644 --- a/bridge-sdk/connectors/omni-connector/Cargo.toml +++ b/bridge-sdk/connectors/omni-connector/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "omni-connector" -version = "0.5.3" +version = "0.5.4" edition = "2021" rust-version = "1.96.0" @@ -19,6 +19,7 @@ near-contract-standards.workspace = true near-sdk.workspace = true omni-types.workspace = true serde_json.workspace = true +futures = "0.3.31" tracing.workspace = true solana-sdk.workspace = true light-client.workspace = true diff --git a/bridge-sdk/connectors/omni-connector/src/omni_connector.rs b/bridge-sdk/connectors/omni-connector/src/omni_connector.rs index ddd506db..4f40866d 100644 --- a/bridge-sdk/connectors/omni-connector/src/omni_connector.rs +++ b/bridge-sdk/connectors/omni-connector/src/omni_connector.rs @@ -34,6 +34,7 @@ use evm_bridge_client::{EvmBridgeClient, InitTransferFilter}; use hypercore_bridge_client::{ encode_init_transfer_action, encode_transfer_action, format_amount, HyperCoreBridgeClient, }; +pub use near_bridge_client::btc::BtcTxType; use near_bridge_client::btc::{ BtcConfirmationContext, BtcRequestRefundArgs, BtcVerifyRefundFinalizeArgs, BtcVerifyWithdrawArgs, ChainSpecificData, DepositMsg, FinBtcTransferArgs, @@ -51,7 +52,6 @@ use solana_sdk::transaction::Transaction; use starknet_bridge_client::{StarknetBridgeClient, StarknetInitTransferEvent}; use std::collections::HashMap; use std::str::FromStr; -use std::sync::OnceLock; use utxo_bridge_client::{ types::{Bitcoin, PrefetchedTxData, Zcash}, UTXOBridgeClient, @@ -94,10 +94,6 @@ pub struct OmniConnector { btc_light_client: Option, zcash_light_client: Option, enable_orchard: Option, - #[builder(default)] - btc_confirmation_context: OnceLock, - #[builder(default)] - zcash_confirmation_context: OnceLock, } macro_rules! forward_common_utxo_method { @@ -415,6 +411,7 @@ pub enum BtcDepositArgs { msg: DepositMsg, }, } + impl OmniConnector { pub fn new() -> Self { Self::default() @@ -665,13 +662,14 @@ impl OmniConnector { .await } - pub async fn build_fin_btc_transfer_args( + async fn build_fin_btc_transfer_args( &self, chain: ChainKind, tx_hash: String, vout: usize, deposit_args: BtcDepositArgs, prefetched: Option, + ensure_confirmations: bool, ) -> Result { let near_bridge_client = self.near_bridge_client()?; @@ -703,27 +701,27 @@ impl OmniConnector { } => near_bridge_client.get_deposit_msg_for_near_account(recipient_id, refund_address), }; + // Bounds-check vout early; the contract would only panic on it later. let deposit_output = proof_data.outputs.get(vout).ok_or_else(|| { BridgeSdkError::InvalidArgument(format!( "vout {vout} out of range; tx has {} outputs", proof_data.outputs.len() )) })?; - let deposit_amount = u128::from(deposit_output.value_sat); - - // The contract dispatches to `get_extra_msg_confirmations` only when - // calling `verify_deposit` with `extra_msg` set. `safe_verify_deposit` - // (chosen when `safe_deposit.is_some()`) always uses `get_confirmations`, - // even if `extra_msg` is also present. - let uses_extra_msg_path = - deposit_msg.safe_deposit.is_none() && deposit_msg.extra_msg.is_some(); - self.ensure_sufficient_btc_confirmations( - chain, - proof_data.block_height, - deposit_amount, - uses_extra_msg_path, - ) - .await?; + + if ensure_confirmations { + let uses_extra_msg_path = + deposit_msg.safe_deposit.is_none() && deposit_msg.extra_msg.is_some(); + self.ensure_sufficient_btc_confirmations( + chain, + proof_data.block_height, + BtcTxType::Deposit { + amount: u128::from(deposit_output.value_sat), + uses_extra_msg_path, + }, + ) + .await?; + } Ok(FinBtcTransferArgs { deposit_msg, @@ -749,7 +747,25 @@ impl OmniConnector { transaction_options: TransactionOptions, ) -> Result { let args = self - .build_fin_btc_transfer_args(chain, tx_hash, vout, deposit_args, prefetched) + .build_fin_btc_transfer_args(chain, tx_hash, vout, deposit_args, prefetched, false) + .await?; + + self.near_bridge_client()? + .fin_btc_transfer(chain, args, transaction_options) + .await + } + + pub async fn near_fin_transfer_btc_checked( + &self, + chain: ChainKind, + tx_hash: String, + vout: usize, + deposit_args: BtcDepositArgs, + prefetched: Option, + transaction_options: TransactionOptions, + ) -> Result { + let args = self + .build_fin_btc_transfer_args(chain, tx_hash, vout, deposit_args, prefetched, true) .await?; self.near_bridge_client()? @@ -775,8 +791,9 @@ impl OmniConnector { self.ensure_sufficient_btc_confirmations( chain, proof_data.block_height, - pending_info.actual_received_amount, - false, + BtcTxType::Withdraw { + amount: pending_info.actual_received_amount, + }, ) .await?; @@ -827,8 +844,9 @@ impl OmniConnector { self.ensure_sufficient_btc_confirmations( chain, proof_data.block_height, - pending_info.actual_received_amount, - false, + BtcTxType::ActiveUtxoManagement { + amount: pending_info.actual_received_amount, + }, ) .await?; @@ -876,19 +894,18 @@ impl OmniConnector { } }; - let deposit_output = proof_data.outputs.get(vout).ok_or_else(|| { + // Bounds-check vout early; the contract would only panic on it later. + proof_data.outputs.get(vout).ok_or_else(|| { BridgeSdkError::InvalidArgument(format!( "vout {vout} out of range; tx has {} outputs", proof_data.outputs.len() )) })?; - let deposit_amount = u128::from(deposit_output.value_sat); self.ensure_sufficient_btc_confirmations( chain, proof_data.block_height, - deposit_amount, - false, + BtcTxType::RefundRequest, ) .await?; @@ -995,8 +1012,9 @@ impl OmniConnector { self.ensure_sufficient_btc_confirmations( chain, proof_data.block_height, - pending_info.actual_received_amount, - false, + BtcTxType::RefundFinalize { + amount: pending_info.actual_received_amount, + }, ) .await?; @@ -3718,55 +3736,62 @@ impl OmniConnector { }) } - /// Returns the BTC connector confirmation context for `chain`, fetching it - /// from the contract on the first call per chain and reusing the stored - /// snapshot for the lifetime of this `OmniConnector`. + /// Returns the BTC connector confirmation context for `chain`; see + /// [`NearBridgeClient::cached_btc_confirmation_context`]. pub async fn confirmation_context(&self, chain: ChainKind) -> Result { - let cell = match chain { - ChainKind::Btc => &self.btc_confirmation_context, - ChainKind::Zcash => &self.zcash_confirmation_context, - _ => { - return Err(BridgeSdkError::InvalidArgument(format!( - "confirmation_context called with non-UTXO chain: {chain:?}" - ))); - } - }; - - if let Some(ctx) = cell.get() { - return Ok(ctx.clone()); - } + self.near_bridge_client()? + .cached_btc_confirmation_context(chain) + .await + } - let ctx = self - .near_bridge_client()? - .get_btc_confirmation_context(chain) - .await?; + /// Confirmations required to verify `tx_type` on the BTC connector + /// contract; see [`NearBridgeClient::get_required_btc_confirmations`]. + pub async fn get_required_btc_confirmations( + &self, + chain: ChainKind, + tx_block_height: u64, + tx_type: BtcTxType, + ) -> Result { + self.near_bridge_client()? + .get_required_btc_confirmations(chain, tx_block_height, tx_type) + .await + } - let _ = cell.set(ctx.clone()); + /// Confirmations the light client still lacks before `tx_type` can be + /// verified; 0 means [`Self::ensure_sufficient_btc_confirmations`] would + /// pass right now. + pub async fn get_remaining_btc_confirmations( + &self, + chain: ChainKind, + tx_block_height: u64, + tx_type: BtcTxType, + ) -> Result { + let (required_confirmations, light_client_last_block) = futures::try_join!( + self.get_required_btc_confirmations(chain, tx_block_height, tx_type), + self.light_client(chain)?.get_last_block_number() + )?; - Ok(ctx) + Ok((tx_block_height + required_confirmations).saturating_sub(light_client_last_block + 1)) } - /// Verifies that the chain's light client has caught up far enough to - /// finalize the proof at `tx_block_height`, given the BTC connector's - /// confirmation policy for `amount` and the dispatch path. Returns + /// Pre-check shared by the UTXO-chain verification paths. Returns /// `LightClientNotSynced` when more blocks are needed. pub async fn ensure_sufficient_btc_confirmations( &self, chain: ChainKind, tx_block_height: u64, - amount: u128, - uses_extra_msg_path: bool, + tx_type: BtcTxType, ) -> Result<()> { - let light_client_last_block = self.light_client(chain)?.get_last_block_number().await?; - let required_confirmations = self - .confirmation_context(chain) - .await? - .required_confirmations(amount, uses_extra_msg_path)?; + let (required_confirmations, light_client_last_block) = futures::try_join!( + self.get_required_btc_confirmations(chain, tx_block_height, tx_type), + self.light_client(chain)?.get_last_block_number() + )?; if tx_block_height + required_confirmations > light_client_last_block + 1 { - return Err(BridgeSdkError::LightClientNotSynced( - light_client_last_block, - )); + return Err(BridgeSdkError::LightClientNotSynced { + current_height: light_client_last_block, + target_height: (tx_block_height + required_confirmations).saturating_sub(1), + }); } Ok(()) } @@ -4085,9 +4110,10 @@ impl OmniConnector { let tx_block_number = evm_bridge_client.get_tx_block_number(tx_hash).await?; if last_eth_block_number_on_near < tx_block_number { - return Err(BridgeSdkError::LightClientNotSynced( - last_eth_block_number_on_near, - )); + return Err(BridgeSdkError::LightClientNotSynced { + current_height: last_eth_block_number_on_near, + target_height: tx_block_number, + }); } let evm_proof = evm_bridge_client diff --git a/bridge-sdk/near-rpc-client/Cargo.toml b/bridge-sdk/near-rpc-client/Cargo.toml index c5c01879..c7ccc920 100644 --- a/bridge-sdk/near-rpc-client/Cargo.toml +++ b/bridge-sdk/near-rpc-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "near-rpc-client" -version = "0.2.1" +version = "0.2.2" edition = "2021" [dependencies] diff --git a/bridge-sdk/near-rpc-client/src/error.rs b/bridge-sdk/near-rpc-client/src/error.rs index fa863192..6c1537c7 100644 --- a/bridge-sdk/near-rpc-client/src/error.rs +++ b/bridge-sdk/near-rpc-client/src/error.rs @@ -1,5 +1,5 @@ use near_jsonrpc_client::{ - errors::JsonRpcError, + errors::{JsonRpcError, JsonRpcServerError}, methods::{ block::RpcBlockError, broadcast_tx_async::RpcBroadcastTxAsyncError, query::RpcQueryError, tx::RpcTransactionError, @@ -30,3 +30,64 @@ pub enum NearRpcError { public_key: String, }, } + +impl NearRpcError { + /// `true` when a call failed because the contract does not export the + /// requested method, as opposed to a transport failure or a panic inside + /// an existing method. + #[must_use] + pub fn is_method_not_found(&self) -> bool { + matches!( + self, + Self::RpcQueryError(JsonRpcError::ServerError(JsonRpcServerError::HandlerError( + RpcQueryError::ContractExecutionError { vm_error, .. }, + ))) if vm_error.contains("MethodNotFound") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn contract_execution_error(vm_error: &str) -> NearRpcError { + NearRpcError::RpcQueryError(JsonRpcError::ServerError(JsonRpcServerError::HandlerError( + RpcQueryError::ContractExecutionError { + vm_error: vm_error.to_string(), + block_height: 0, + block_hash: near_primitives::hash::CryptoHash::default(), + }, + ))) + } + + #[test] + fn method_not_found_is_detected() { + let err = contract_execution_error( + "wasm execution failed with error: MethodResolveError(MethodNotFound)", + ); + assert!(err.is_method_not_found()); + } + + #[test] + fn contract_panic_is_not_method_not_found() { + let err = contract_execution_error( + "wasm execution failed with error: HostError(GuestPanic { panic_msg: \"Not enough confirmations for the block-cumulative bridge amount\" })", + ); + assert!(!err.is_method_not_found()); + } + + #[test] + fn non_handler_server_error_is_not_method_not_found() { + let err = NearRpcError::RpcQueryError(JsonRpcError::ServerError( + JsonRpcServerError::InternalError { + info: Some("MethodNotFound".to_string()), + }, + )); + assert!(!err.is_method_not_found()); + } + + #[test] + fn unrelated_error_is_not_method_not_found() { + assert!(!NearRpcError::ResultError.is_method_not_found()); + } +}