diff --git a/contracts/satoshi-bridge/src/api/management.rs b/contracts/satoshi-bridge/src/api/management.rs index 31f177d..18b23d9 100644 --- a/contracts/satoshi-bridge/src/api/management.rs +++ b/contracts/satoshi-bridge/src/api/management.rs @@ -275,6 +275,9 @@ impl Contract { pub fn update_config(&mut self, update: ConfigUpdate) { assert_one_yocto(); update.apply(self.internal_mut_config()); + // Capacity depends on `confirmations_delta` / `extra_msg_confirmations_delta`; + // resize is a no-op if those didn't change. + self.resize_block_amount_ring(); } #[payable] @@ -288,6 +291,8 @@ impl Contract { self.internal_mut_config() .confirmations_strategy .insert(range_upper_bound.0.to_string(), confirmations); + // Capacity depends on `max_tier_confirmations`; resize after the tier table changes. + self.resize_block_amount_ring(); } #[payable] @@ -304,5 +309,7 @@ impl Contract { !self.internal_config().confirmations_strategy.is_empty(), "confirmations_strategy must not be empty" ); + // Removing the top-tier entry shrinks the ring; lower-tier removals are no-ops. + self.resize_block_amount_ring(); } } diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs new file mode 100644 index 0000000..819b43f --- /dev/null +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -0,0 +1,350 @@ +use crate::{near, require, Config, Contract}; + +/// Extra slack added to the ring capacity on top of the worst-case +/// required-confirmations value, so a tx near the top tier still has room left +/// to be tracked across in-flight verifies. Chosen heuristically; not tied to +/// any protocol parameter. +pub const BLOCK_AMOUNT_RING_CAPACITY_SLACK: usize = 5; + +#[near(serializers = [borsh])] +#[derive(Clone)] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug, PartialEq, Eq))] +pub struct BlockAmountCell { + pub block_height: u64, + pub cumulative_sats: u128, +} + +/// Fixed-capacity ring buffer of cumulative bridge-related satoshi amounts per BTC block. +/// Each slot is `cells[block_height % capacity]` and stores the block_height explicitly +/// so a stale entry can be detected and lazily overwritten when a new block lands in the +/// same slot. Capacity must cover any block whose confirmations could still be in question +/// for tier checks; blocks deeper than capacity are out of the active window and the caller +/// must require max-tier confirmations for them (which they trivially satisfy by depth). +#[near(serializers = [borsh])] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug, PartialEq, Eq))] +pub struct BlockAmountRing { + cells: Vec>, +} + +impl BlockAmountRing { + /// Recommended ring capacity for `config`: large enough that ANY tx whose + /// block is still within the worst-case confirmations window + /// (`max_tier + max_delta`) can still be looked up. Blocks beyond this depth + /// trivially satisfy max-tier confirmations and don't need cumulative tracking. + pub fn capacity_for(config: &Config) -> usize { + usize::from(config.max_tier_confirmations()) + + usize::from(config.confirmations_delta) + + usize::from(config.extra_msg_confirmations_delta) + + BLOCK_AMOUNT_RING_CAPACITY_SLACK + } + + pub fn new(capacity: usize) -> Self { + require!(capacity > 0, "BlockAmountRing capacity must be > 0"); + Self { + cells: vec![None; capacity], + } + } + + pub fn capacity(&self) -> usize { + self.cells.len() + } + + /// Add `amount` to the cumulative bridge sats for `block_height` and return + /// `Some(post_bump)`. + /// + /// Returns `None` if the target slot holds a NEWER block — `block_height` is + /// outside the active tracking window and must not corrupt the newer block's + /// data. Callers should treat `None` as "out of window — require max-tier + /// confirmations" (which is trivially satisfied by depth at that point). + /// + /// If the slot is empty or holds an OLDER block (now out of window), it is + /// overwritten with `(block_height, amount)` — lazy eviction. + pub fn bump(&mut self, block_height: u64, amount: u128) -> Option { + let i = self.slot(block_height); + match &mut self.cells[i] { + Some(c) if c.block_height == block_height => { + c.cumulative_sats = c + .cumulative_sats + .checked_add(amount) + .expect("BlockAmountRing: cumulative_sats overflow"); + Some(c.cumulative_sats) + } + Some(c) if c.block_height > block_height => None, + cell => { + *cell = Some(BlockAmountCell { + block_height, + cumulative_sats: amount, + }); + Some(amount) + } + } + } + + /// Cumulative bridge sats currently stored for `block_height`, or `None` if the slot + /// is empty or holds a different block (caller treats `None` as "out of active window — + /// require max-tier confirmations"). + pub fn get(&self, block_height: u64) -> Option { + let i = self.slot(block_height); + match &self.cells[i] { + Some(c) if c.block_height == block_height => Some(c.cumulative_sats), + _ => None, + } + } + + /// Re-allocate to `new_capacity`, rehashing existing entries to `block_height % new_capacity`. + /// On collisions, the entry with the larger `block_height` (newer block) wins; older entries + /// are dropped. Capacity-preserving resize is a no-op. + pub fn resize(&mut self, new_capacity: usize) { + require!(new_capacity > 0, "BlockAmountRing capacity must be > 0"); + if new_capacity == self.cells.len() { + return; + } + let new_cap_u64 = u64::try_from(new_capacity).expect("capacity fits u64"); + let mut new_cells: Vec> = vec![None; new_capacity]; + for entry in self.cells.iter().flatten() { + let i = usize::try_from(entry.block_height % new_cap_u64) + .expect("slot index fits usize"); + let replace = match &new_cells[i] { + Some(existing) => entry.block_height > existing.block_height, + None => true, + }; + if replace { + new_cells[i] = Some(entry.clone()); + } + } + self.cells = new_cells; + } + + fn slot(&self, block_height: u64) -> usize { + let cap = u64::try_from(self.capacity()).expect("capacity fits u64"); + usize::try_from(block_height % cap).expect("slot index fits usize") + } +} + +impl Contract { + /// Bump the block-amount ring with `amount` for `block_height` and require + /// that the observed depth `tip - height + 1` satisfies the confirmations + /// tier for the resulting cumulative amount. `delta` is the pre-computed + /// whitelist delta from the entry point (the callback's predecessor is + /// this contract, not the original relayer, so the whitelist check must + /// be done up-front). + /// + /// If `block_height` is outside the active ring window, the ring refuses + /// the bump and we fall back to max-tier — which is trivially satisfied + /// by depth at that point, so the check passes. + /// + /// Panics if not enough confirmations. + pub fn bump_and_check_confirmations( + &mut self, + block_height: u64, + tip_height: u64, + amount: u128, + delta: u64, + ) { + let cumulative = self + .data_mut() + .block_bridge_amounts + .bump(block_height, amount) + .unwrap_or(u128::MAX); + let required = self.internal_config().get_confirmations(cumulative) + delta; + let actual = tip_height.saturating_sub(block_height) + 1; + require!( + actual >= required, + "Not enough confirmations for the block-cumulative bridge amount" + ); + } + + /// Resize the block-amount ring to match the current config's capacity. + /// Called after config updates that change `confirmations_delta`, + /// `extra_msg_confirmations_delta`, or the tier table. + pub fn resize_block_amount_ring(&mut self) { + let cap = BlockAmountRing::capacity_for(self.internal_config()); + self.data_mut().block_bridge_amounts.resize(cap); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_ring_is_empty() { + let ring = BlockAmountRing::new(8); + assert_eq!(ring.capacity(), 8); + for h in 0..100u64 { + assert_eq!(ring.get(h), None); + } + } + + #[test] + #[should_panic(expected = "BlockAmountRing capacity must be > 0")] + fn new_with_zero_capacity_panics() { + BlockAmountRing::new(0); + } + + #[test] + fn bump_then_get_returns_amount() { + let mut ring = BlockAmountRing::new(4); + assert_eq!(ring.bump(100, 500), Some(500)); + assert_eq!(ring.get(100), Some(500)); + } + + #[test] + fn bumping_same_height_accumulates() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, 500); + assert_eq!(ring.bump(100, 250), Some(750)); + assert_eq!(ring.bump(100, 1), Some(751)); + assert_eq!(ring.get(100), Some(751)); + } + + #[test] + fn bumping_newer_block_same_slot_evicts_older() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, 500); + // 100 and 104 collide (both 100 % 4 == 0); 104 is newer → evict 100. + assert_eq!(ring.bump(104, 999), Some(999)); + assert_eq!(ring.get(104), Some(999)); + // The previous block's data is gone + assert_eq!(ring.get(100), None); + } + + #[test] + fn bumping_older_block_when_newer_present_returns_none() { + let mut ring = BlockAmountRing::new(4); + // 104 is newer; bumping older 100 into the same slot must not corrupt 104's data. + ring.bump(104, 999); + assert_eq!(ring.bump(100, 500), None); + // Newer block's data still intact + assert_eq!(ring.get(104), Some(999)); + // Older block was not stored — out of active window + assert_eq!(ring.get(100), None); + } + + #[test] + fn distinct_heights_in_distinct_slots_do_not_interfere() { + let mut ring = BlockAmountRing::new(8); + ring.bump(100, 1); + ring.bump(101, 2); + ring.bump(102, 3); + assert_eq!(ring.get(100), Some(1)); + assert_eq!(ring.get(101), Some(2)); + assert_eq!(ring.get(102), Some(3)); + } + + #[test] + fn get_returns_none_for_unknown_height_in_used_slot() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, 500); + // 200 hits the same slot as 100 but no data was stored for height 200 + assert_eq!(ring.get(200), None); + // Original entry still intact + assert_eq!(ring.get(100), Some(500)); + } + + #[test] + #[should_panic(expected = "cumulative_sats overflow")] + fn bump_overflow_panics() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, u128::MAX); + ring.bump(100, 1); + } + + #[test] + fn capacity_one_works() { + let mut ring = BlockAmountRing::new(1); + ring.bump(100, 10); + assert_eq!(ring.get(100), Some(10)); + // Newer height evicts older + ring.bump(101, 20); + assert_eq!(ring.get(100), None); + assert_eq!(ring.get(101), Some(20)); + // Older height after newer is refused + assert_eq!(ring.bump(100, 9), None); + assert_eq!(ring.get(101), Some(20)); + assert_eq!(ring.get(100), None); + } + + #[test] + fn resize_same_capacity_is_noop() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, 500); + ring.bump(101, 700); + ring.resize(4); + assert_eq!(ring.get(100), Some(500)); + assert_eq!(ring.get(101), Some(700)); + } + + #[test] + fn resize_grow_preserves_all_entries() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, 500); + ring.bump(101, 700); + ring.bump(102, 900); + ring.resize(8); + assert_eq!(ring.capacity(), 8); + assert_eq!(ring.get(100), Some(500)); + assert_eq!(ring.get(101), Some(700)); + assert_eq!(ring.get(102), Some(900)); + } + + #[test] + fn resize_grow_no_collisions_when_new_cap_geq_height_range() { + let mut ring = BlockAmountRing::new(4); + // Fill all slots with distinct heights + ring.bump(100, 1); + ring.bump(101, 2); + ring.bump(102, 3); + ring.bump(103, 4); + ring.resize(8); + for (h, expected) in [(100u64, 1u128), (101, 2), (102, 3), (103, 4)] { + assert_eq!(ring.get(h), Some(expected)); + } + } + + #[test] + fn resize_shrink_keeps_newer_block_on_collision() { + let mut ring = BlockAmountRing::new(8); + // 100 and 104 do NOT collide in cap=8 (100 % 8 == 4, 104 % 8 == 0). + ring.bump(100, 500); + ring.bump(104, 999); + // After shrinking to cap=4: 100 % 4 == 0, 104 % 4 == 0 — collision. + ring.resize(4); + assert_eq!(ring.capacity(), 4); + // Newer block wins + assert_eq!(ring.get(104), Some(999)); + assert_eq!(ring.get(100), None); + } + + #[test] + fn resize_shrink_drops_only_collided_entries() { + let mut ring = BlockAmountRing::new(8); + ring.bump(100, 500); // 100 % 8 == 4, 100 % 4 == 0 + ring.bump(101, 600); // 101 % 8 == 5, 101 % 4 == 1 + ring.bump(102, 700); // 102 % 8 == 6, 102 % 4 == 2 + ring.bump(104, 800); // 104 % 8 == 0, 104 % 4 == 0 — will collide with 100 + ring.resize(4); + assert_eq!(ring.get(104), Some(800)); // newer wins over 100 + assert_eq!(ring.get(100), None); + assert_eq!(ring.get(101), Some(600)); + assert_eq!(ring.get(102), Some(700)); + } + + #[test] + #[should_panic(expected = "BlockAmountRing capacity must be > 0")] + fn resize_to_zero_panics() { + let mut ring = BlockAmountRing::new(4); + ring.resize(0); + } + + #[test] + fn resize_preserves_cumulative_within_kept_block() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, 100); + ring.bump(100, 200); + ring.bump(100, 300); + assert_eq!(ring.get(100), Some(600)); + ring.resize(8); + assert_eq!(ring.get(100), Some(600)); + } +} 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 385470e..619a298 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 @@ -15,7 +15,8 @@ impl Contract { btc_pending_info: &BTCPendingInfo, ) -> Promise { let config = self.internal_config(); - let confirmations = self.get_confirmations(config, btc_pending_info.actual_received_amount); + let confirmations = config.get_confirmations(btc_pending_info.actual_received_amount) + + self.relayer_delta_for_predecessor(); self.verify_transaction_inclusion_promise( config.btc_light_client_account_id.clone(), tx_id.clone(), diff --git a/contracts/satoshi-bridge/src/btc_light_client/deposit.rs b/contracts/satoshi-bridge/src/btc_light_client/deposit.rs index c17ecec..2963ea5 100644 --- a/contracts/satoshi-bridge/src/btc_light_client/deposit.rs +++ b/contracts/satoshi-bridge/src/btc_light_client/deposit.rs @@ -1,12 +1,18 @@ +// `verify_deposit_callback` takes 8 args (extra `confirmations_delta`); the `#[near]` +// proc-macro re-emits the signature in the ext-trait so the `clippy::too_many_arguments` +// lint fires from inside the macro expansion and an inner `#[allow]` doesn't reach it. +#![allow(clippy::too_many_arguments)] + use near_sdk::serde_json::Value; use crate::{ + btc_light_client::TxInclusionInfo, burn::GAS_FOR_BURN_CALL, env, ext_nbtc, 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, + MAX_FT_TRANSFER_CALL_RESULT, MAX_INCLUSION_INFO_RESULT, U128, }; pub const GAS_FOR_VERIFY_DEPOSIT_CALL_BACK: Gas = Gas::from_tgas(130); @@ -22,27 +28,33 @@ impl Contract { pending_utxo_info: PendingUTXOInfo, deposit_msg: DepositMsg, ) -> Promise { - let config = self.internal_config(); let recipient_id = deposit_msg.recipient_id.clone(); - let confirmations = if deposit_msg.extra_msg.is_none() { - self.get_confirmations(config, deposit_amount) + // Predecessor is the original relayer/user here — capture the whitelist + // delta before the cross-contract call. The callback runs with the contract + // itself as predecessor and cannot redo this check. + let confirmations_delta = if deposit_msg.extra_msg.is_none() { + self.relayer_delta_for_predecessor() } else { - self.get_extra_msg_confirmations(config, deposit_amount) + self.extra_msg_relayer_delta_for_predecessor() }; - let promise = self.verify_transaction_inclusion_promise( + let config = self.internal_config(); + let promise = self.verify_transaction_inclusion_with_heights_promise( config.btc_light_client_account_id.clone(), pending_utxo_info.tx_id.clone(), tx_block_blockhash, tx_index, merkle_proof, - confirmations, ); if deposit_amount < config.min_deposit_amount { promise.then( Self::ext(env::current_account_id()) .with_static_gas(GAS_FOR_UNAVAILABLE_UTXO_CALL_BACK) - .unavailable_utxo_callback(recipient_id, pending_utxo_info), + .unavailable_utxo_callback( + recipient_id, + pending_utxo_info, + confirmations_delta, + ), ) } else { let deposit_fee = config.deposit_bridge_fee.get_fee(deposit_amount); @@ -62,6 +74,7 @@ impl Contract { relayer_fee.into(), pending_utxo_info, post_actions, + confirmations_delta, ), ) } @@ -78,22 +91,25 @@ impl Contract { recipient_id: AccountId, deposit_msg: SafeDepositMsg, ) -> Promise { + let confirmations_delta = self.relayer_delta_for_predecessor(); let config = self.internal_config(); - let confirmations = self.get_confirmations(config, deposit_amount); - let promise = self.verify_transaction_inclusion_promise( + let promise = self.verify_transaction_inclusion_with_heights_promise( config.btc_light_client_account_id.clone(), pending_utxo_info.tx_id.clone(), tx_block_blockhash, tx_index, merkle_proof, - confirmations, ); if deposit_amount < config.min_deposit_amount { promise.then( Self::ext(env::current_account_id()) .with_static_gas(GAS_FOR_UNAVAILABLE_UTXO_CALL_BACK) - .unavailable_utxo_callback(recipient_id, pending_utxo_info), + .unavailable_utxo_callback( + recipient_id, + pending_utxo_info, + confirmations_delta, + ), ) } else { promise.then( @@ -104,10 +120,35 @@ impl Contract { deposit_amount.into(), deposit_msg.msg, pending_utxo_info, + confirmations_delta, ), ) } } + + /// Parse the LC's `Option` response, bump the block-amount + /// ring with this tx's amount, and panic if depth doesn't satisfy the + /// confirmations tier for the resulting cumulative. + /// + /// Single helper so the three deposit-related callbacks share identical + /// inclusion-check semantics. + fn process_inclusion_and_check( + &mut self, + pending_utxo_info: &PendingUTXOInfo, + confirmations_delta: u64, + ) { + let result_bytes = env::promise_result_checked(0, MAX_INCLUSION_INFO_RESULT) + .expect("Call verify_transaction_inclusion_with_heights failed"); + let info: Option = serde_json::from_slice(&result_bytes) + .expect("verify_transaction_inclusion_with_heights returned an unexpected payload"); + let info = info.expect("Transaction not included in the BTC mainchain"); + self.bump_and_check_confirmations( + info.tx_block_height, + info.mainchain_tip_height, + u128::from(pending_utxo_info.utxo.balance), + confirmations_delta, + ); + } } #[near] @@ -117,12 +158,9 @@ impl Contract { &mut self, recipient_id: AccountId, pending_utxo_info: PendingUTXOInfo, + confirmations_delta: u64, ) -> PromiseOrValue { - let result_bytes = env::promise_result_checked(0, MAX_BOOL_RESULT) - .expect("Call verify_transaction_inclusion failed"); - let is_valid = serde_json::from_slice::(&result_bytes) - .expect("verify_transaction_inclusion return not bool"); - require!(is_valid, "verify_transaction_inclusion return false"); + self.process_inclusion_and_check(&pending_utxo_info, confirmations_delta); require!( self.data_mut() .verified_deposit_utxo @@ -152,12 +190,9 @@ impl Contract { relayer_fee: U128, pending_utxo_info: PendingUTXOInfo, post_actions: Option>, + confirmations_delta: u64, ) -> PromiseOrValue { - let result_bytes = env::promise_result_checked(0, MAX_BOOL_RESULT) - .expect("Call verify_transaction_inclusion failed"); - let is_valid = serde_json::from_slice::(&result_bytes) - .expect("verify_transaction_inclusion return not bool"); - require!(is_valid, "verify_transaction_inclusion return false"); + self.process_inclusion_and_check(&pending_utxo_info, confirmations_delta); require!( self.data_mut() .verified_deposit_utxo @@ -182,12 +217,9 @@ impl Contract { mint_amount: U128, msg: String, pending_utxo_info: PendingUTXOInfo, + confirmations_delta: u64, ) -> PromiseOrValue { - let result_bytes = env::promise_result_checked(0, MAX_BOOL_RESULT) - .expect("Call verify_transaction_inclusion failed"); - let is_valid = serde_json::from_slice::(&result_bytes) - .expect("verify_transaction_inclusion return not bool"); - require!(is_valid, "verify_transaction_inclusion return false"); + self.process_inclusion_and_check(&pending_utxo_info, confirmations_delta); require!( self.data_mut() .verified_deposit_utxo diff --git a/contracts/satoshi-bridge/src/btc_light_client/mod.rs b/contracts/satoshi-bridge/src/btc_light_client/mod.rs index da52466..a312078 100644 --- a/contracts/satoshi-bridge/src/btc_light_client/mod.rs +++ b/contracts/satoshi-bridge/src/btc_light_client/mod.rs @@ -108,9 +108,57 @@ impl Serialize for H256 { } } +/// SPV-proof args for the heights-returning variant (no confirmations field). +/// Mirrors `btc-types::contract_args::TxInclusionProof` from +/// Near-One/btc-light-client-contract#140. +#[near(serializers = [borsh])] +pub struct TxInclusionProof { + pub tx_id: H256, + pub tx_block_blockhash: H256, + pub tx_index: u64, + pub merkle_proof: Vec, +} + +impl TxInclusionProof { + pub fn new( + tx_id: String, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + ) -> Self { + TxInclusionProof { + 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("Invalid merkle_proof: {v:?}")) + }) + .collect(), + } + } +} + +/// SPV inclusion result with heights. Mirrors `btc-types::contract_args::TxInclusionInfo` +/// from Near-One/btc-light-client-contract#140. +#[near(serializers = [borsh, json])] +#[derive(Clone, Debug)] +pub struct TxInclusionInfo { + pub tx_block_height: u64, + pub mainchain_tip_height: u64, +} + #[ext_contract(ext_btc_light_client)] pub trait BtcLightClient { fn verify_transaction_inclusion(&self, #[serializer(borsh)] args: ProofArgs) -> bool; + fn verify_transaction_inclusion_with_heights( + &self, + #[serializer(borsh)] args: TxInclusionProof, + ) -> Option; fn get_last_block_height(&self) -> u32; } @@ -135,6 +183,27 @@ impl Contract { )) } + /// Untrusted-path variant: requests SPV proof + heights in one call. The + /// callback receives `Option` and applies the + /// (cumulative-aware) confirmations check on this side. + pub fn verify_transaction_inclusion_with_heights_promise( + &self, + btc_light_client_account_id: AccountId, + tx_id: String, + tx_block_blockhash: String, + tx_index: u64, + merkle_proof: Vec, + ) -> Promise { + ext_btc_light_client::ext(btc_light_client_account_id) + .with_static_gas(GAS_FOR_VERIFY_TRANSACTION_INCLUSION) + .verify_transaction_inclusion_with_heights(TxInclusionProof::new( + tx_id, + tx_block_blockhash, + tx_index, + merkle_proof, + )) + } + pub fn get_last_block_height_promise(&self) -> Promise { let config = self.internal_config(); ext_btc_light_client::ext(config.btc_light_client_account_id.clone()) diff --git a/contracts/satoshi-bridge/src/btc_light_client/withdraw.rs b/contracts/satoshi-bridge/src/btc_light_client/withdraw.rs index 794a068..c4844ca 100644 --- a/contracts/satoshi-bridge/src/btc_light_client/withdraw.rs +++ b/contracts/satoshi-bridge/src/btc_light_client/withdraw.rs @@ -16,7 +16,8 @@ impl Contract { btc_pending_info: &BTCPendingInfo, ) -> Promise { let config = self.internal_config(); - let confirmations = self.get_confirmations(config, btc_pending_info.actual_received_amount); + let confirmations = config.get_confirmations(btc_pending_info.actual_received_amount) + + self.relayer_delta_for_predecessor(); self.verify_transaction_inclusion_promise( config.btc_light_client_account_id.clone(), tx_id.clone(), diff --git a/contracts/satoshi-bridge/src/config.rs b/contracts/satoshi-bridge/src/config.rs index df8d73b..a83b930 100644 --- a/contracts/satoshi-bridge/src/config.rs +++ b/contracts/satoshi-bridge/src/config.rs @@ -203,6 +203,22 @@ impl Config { .unwrap(), ) } + + pub fn max_tier_confirmations(&self) -> u8 { + self.confirmations_strategy + .values() + .copied() + .max() + .unwrap_or(0) + } + +pub fn max_required_confirmations(&self) -> u64 { + u64::from(self.max_tier_confirmations()) + + u64::from(std::cmp::max( + self.confirmations_delta, + self.extra_msg_confirmations_delta, + )) + } } #[near(serializers = [json])] @@ -303,31 +319,36 @@ impl Contract { .expect("ERR_CONFIG: contract not initialized") } - pub fn get_confirmations(&self, config: &Config, satoshi_amount: u128) -> u64 { + /// Whitelist-aware confirmations delta for the standard tier, based on the + /// CURRENT predecessor. Must be called at the synchronous entry point of a + /// verify_* function — in a callback, predecessor is the contract itself + /// (or the LC), not the original relayer. + pub fn relayer_delta_for_predecessor(&self) -> u64 { if self .data() .relayer_white_list - // Use predecessor_account_id to support both users and proxy protocols. .contains(&env::predecessor_account_id()) { - config.get_confirmations(satoshi_amount) + 0 } else { - config.get_confirmations(satoshi_amount) + u64::from(config.confirmations_delta) + u64::from(self.internal_config().confirmations_delta) } } - pub fn get_extra_msg_confirmations(&self, config: &Config, satoshi_amount: u128) -> u64 { + /// Whitelist-aware confirmations delta for the extra-msg tier. Same caller + /// constraint as [`Self::relayer_delta_for_predecessor`]. + pub fn extra_msg_relayer_delta_for_predecessor(&self) -> u64 { if self .data() .extra_msg_relayer_white_list .contains(&env::predecessor_account_id()) { - config.get_confirmations(satoshi_amount) + 0 } else { - config.get_confirmations(satoshi_amount) - + u64::from(config.extra_msg_confirmations_delta) + u64::from(self.internal_config().extra_msg_confirmations_delta) } } + } #[cfg(test)] diff --git a/contracts/satoshi-bridge/src/legacy.rs b/contracts/satoshi-bridge/src/legacy.rs index 892a5d4..3b0b26b 100644 --- a/contracts/satoshi-bridge/src/legacy.rs +++ b/contracts/satoshi-bridge/src/legacy.rs @@ -1,8 +1,9 @@ #[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, AccountId, BlockAmountRing, BridgeFee, Config, ContractData, HashMap, HashSet, + IterableMap, IterableSet, LazyOption, LookupSet, PublicKey, StorageKey, VAccount, + VBTCPendingInfo, VUTXO, }; #[near(serializers = [borsh])] @@ -44,6 +45,12 @@ impl From for ContractData { acc_protocol_fee_for_gas, } = c; + let ring_capacity = BlockAmountRing::capacity_for( + config + .get() + .as_ref() + .expect("ContractDataV0: config missing"), + ); Self { config, accounts, @@ -65,6 +72,7 @@ impl From for ContractData { acc_protocol_fee_for_gas, #[cfg(not(feature = "zcash"))] refund_requests: IterableMap::new(StorageKey::RefundRequests), + block_bridge_amounts: BlockAmountRing::new(ring_capacity), } } } @@ -384,8 +392,10 @@ impl From for ContractData { acc_protocol_fee_for_gas, } = c; let config_v0 = config.get().clone().unwrap(); + let new_config: Config = config_v0.into(); + let ring_capacity = BlockAmountRing::capacity_for(&new_config); Self { - config: LazyOption::new(StorageKey::Config, Some(config_v0.into())), + config: LazyOption::new(StorageKey::Config, Some(new_config)), accounts, utxos, unavailable_utxos, @@ -405,6 +415,7 @@ impl From for ContractData { acc_protocol_fee_for_gas, #[cfg(not(feature = "zcash"))] refund_requests: IterableMap::new(StorageKey::RefundRequests), + block_bridge_amounts: BlockAmountRing::new(ring_capacity), } } } @@ -455,11 +466,10 @@ impl From for ContractData { acc_protocol_fee_for_gas, } = c; + let new_config: Config = config.get().clone().unwrap().into(); + let ring_capacity = BlockAmountRing::capacity_for(&new_config); Self { - config: LazyOption::new( - StorageKey::Config, - Some(config.get().clone().unwrap().into()), - ), + config: LazyOption::new(StorageKey::Config, Some(new_config)), accounts, utxos, unavailable_utxos, @@ -479,6 +489,7 @@ impl From for ContractData { acc_protocol_fee_for_gas, #[cfg(not(feature = "zcash"))] refund_requests: IterableMap::new(StorageKey::RefundRequests), + block_bridge_amounts: BlockAmountRing::new(ring_capacity), } } } @@ -633,11 +644,10 @@ impl From for ContractData { acc_protocol_fee_for_gas, } = c; + let new_config: Config = config.get().clone().unwrap().into(); + let ring_capacity = BlockAmountRing::capacity_for(&new_config); Self { - config: LazyOption::new( - StorageKey::Config, - Some(config.get().clone().unwrap().into()), - ), + config: LazyOption::new(StorageKey::Config, Some(new_config)), accounts, utxos, unavailable_utxos, @@ -657,6 +667,7 @@ impl From for ContractData { acc_protocol_fee_for_gas, #[cfg(not(feature = "zcash"))] refund_requests: IterableMap::new(StorageKey::RefundRequests), + block_bridge_amounts: BlockAmountRing::new(ring_capacity), } } } @@ -823,11 +834,10 @@ impl From for ContractData { refund_requests, } = c; + let new_config: Config = config.get().clone().unwrap().into(); + let ring_capacity = BlockAmountRing::capacity_for(&new_config); Self { - config: LazyOption::new( - StorageKey::Config, - Some(config.get().clone().unwrap().into()), - ), + config: LazyOption::new(StorageKey::Config, Some(new_config)), accounts, utxos, unavailable_utxos, @@ -847,6 +857,7 @@ impl From for ContractData { acc_protocol_fee_for_gas, #[cfg(not(feature = "zcash"))] refund_requests, + block_bridge_amounts: BlockAmountRing::new(ring_capacity), } } } diff --git a/contracts/satoshi-bridge/src/lib.rs b/contracts/satoshi-bridge/src/lib.rs index 6cee812..7f36c9d 100644 --- a/contracts/satoshi-bridge/src/lib.rs +++ b/contracts/satoshi-bridge/src/lib.rs @@ -23,6 +23,7 @@ pub mod bitcoin_utils; #[cfg(feature = "zcash")] pub mod zcash_utils; +pub mod block_amount_ring; pub mod btc_light_client; pub mod btc_pending_info; pub mod chain_signature; @@ -47,6 +48,7 @@ pub mod utxo; pub use crate::account::*; pub use crate::api::*; +pub use crate::block_amount_ring::BlockAmountRing; pub use crate::btc_pending_info::*; pub use crate::chain_signature::*; pub use crate::config::*; @@ -136,6 +138,12 @@ pub struct ContractData { pub acc_protocol_fee_for_gas: u128, #[cfg(not(feature = "zcash"))] pub refund_requests: IterableMap, + // Fixed-capacity ring of cumulative bridge-related sats per BTC block (indexed by + // block_height % capacity). Used so the confirmations tier is computed against the + // SUM of bridge txs in a block, blocking an attacker from splitting one big deposit + // into many small ones to bypass the high-tier confirmations requirement. + // Capacity is derived from config; see `BlockAmountRing::capacity_for`. + pub block_bridge_amounts: BlockAmountRing, } #[near(serializers = [borsh])] @@ -181,6 +189,7 @@ impl Contract { config.change_address.is_none(), "Init change_address must be None" ); + let block_bridge_amounts = BlockAmountRing::new(BlockAmountRing::capacity_for(&config)); let mut contract = Self { data: VersionedContractData::Current(ContractData { config: LazyOption::new(StorageKey::Config, Some(config)), @@ -202,6 +211,7 @@ impl Contract { lost_found: IterableMap::new(StorageKey::LostFound), #[cfg(not(feature = "zcash"))] refund_requests: IterableMap::new(StorageKey::RefundRequests), + block_bridge_amounts, acc_collected_protocol_fee: 0, cur_available_protocol_fee: 0, acc_claimed_protocol_fee: 0, diff --git a/contracts/satoshi-bridge/src/refund.rs b/contracts/satoshi-bridge/src/refund.rs index af5b677..fb4a803 100644 --- a/contracts/satoshi-bridge/src/refund.rs +++ b/contracts/satoshi-bridge/src/refund.rs @@ -1,8 +1,9 @@ use bitcoin::{Amount, OutPoint, TxOut}; use crate::{ - env, near, require, serde_json, BTCPendingInfo, Contract, ContractExt, DepositMsg, Event, Gas, - OriginalState, PendingInfoStage, PendingInfoState, Promise, MAX_BOOL_RESULT, UTXO, VUTXO, + btc_light_client::TxInclusionInfo, env, near, require, serde_json, BTCPendingInfo, Contract, + ContractExt, DepositMsg, Event, Gas, OriginalState, PendingInfoStage, PendingInfoState, + Promise, MAX_BOOL_RESULT, MAX_INCLUSION_INFO_RESULT, UTXO, VUTXO, }; use crate::deposit_msg::get_deposit_path; @@ -67,7 +68,7 @@ impl Contract { /// If `deposit_msg.refund_address` is None, the provided `refund_address` is used. #[allow(clippy::too_many_arguments)] pub fn internal_request_refund( - &self, + &mut self, deposit_msg: DepositMsg, refund_address: String, tx_bytes: Vec, @@ -89,17 +90,17 @@ impl Contract { .expect("Deserialization tx_bytes failed"); let tx_id = transaction.compute_txid().to_string(); + // Refund-request is rare and already gated by `refund_timelock_sec`, so we + // skip the cumulative-amount ring entirely and just demand max-tier depth + // (see `Config::max_required_confirmations`). The callback doesn't need to + // know about the deposit amount or whitelist deltas. let config = self.internal_config(); - let deposit_amount = u128::from(transaction.output()[vout].value.to_sat()); - let confirmations = self.get_confirmations(config, deposit_amount); - - self.verify_transaction_inclusion_promise( + self.verify_transaction_inclusion_with_heights_promise( config.btc_light_client_account_id.clone(), tx_id, tx_block_blockhash, tx_index, merkle_proof, - confirmations, ) .then( Self::ext(env::current_account_id()) @@ -276,7 +277,8 @@ impl Contract { btc_pending_info: &BTCPendingInfo, ) -> Promise { let config = self.internal_config(); - let confirmations = self.get_confirmations(config, btc_pending_info.actual_received_amount); + let confirmations = config.get_confirmations(btc_pending_info.actual_received_amount) + + self.relayer_delta_for_predecessor(); self.verify_transaction_inclusion_promise( config.btc_light_client_account_id.clone(), tx_id.clone(), @@ -326,13 +328,22 @@ impl Contract { vout: usize, gas_fee: Option, ) -> bool { - let result_bytes = env::promise_result_checked(0, MAX_BOOL_RESULT) - .expect("Call verify_transaction_inclusion failed"); - let is_valid = serde_json::from_slice::(&result_bytes) - .expect("verify_transaction_inclusion return not bool"); - require!(is_valid, "verify_transaction_inclusion return false"); + let result_bytes = env::promise_result_checked(0, MAX_INCLUSION_INFO_RESULT) + .expect("Call verify_transaction_inclusion_with_heights failed"); + let info: Option = serde_json::from_slice(&result_bytes) + .expect("verify_transaction_inclusion_with_heights returned an unexpected payload"); + let info = info.expect("Transaction not included in the BTC mainchain"); let config = self.internal_config(); + let required = config.max_required_confirmations(); + let actual = info + .mainchain_tip_height + .saturating_sub(info.tx_block_height) + + 1; + require!( + actual >= required, + "Refund request: not enough confirmations (max-tier required)" + ); let transaction = crate::WrappedTransaction::decode(&tx_bytes, &config.chain) .expect("Deserialization tx_bytes failed"); let output = &transaction.output()[vout]; diff --git a/contracts/satoshi-bridge/src/utils.rs b/contracts/satoshi-bridge/src/utils.rs index e406c5e..9c6fc32 100644 --- a/contracts/satoshi-bridge/src/utils.rs +++ b/contracts/satoshi-bridge/src/utils.rs @@ -6,6 +6,9 @@ pub const UTXO_STORAGE_KEY_TAG: &str = "@"; /// Maximum expected byte length of a JSON-serialized `bool` promise result (`true`/`false`). pub const MAX_BOOL_RESULT: usize = 10; +/// Maximum expected byte length of a JSON-serialized `Option` +/// promise result. `null` or `{"tx_block_height":,"mainchain_tip_height":}`. +pub const MAX_INCLUSION_INFO_RESULT: usize = 100; /// Maximum expected byte length of a JSON-serialized `U128` promise result (e.g. from `ft_on_transfer`). pub const MAX_FT_TRANSFER_CALL_RESULT: usize = 50; /// Maximum expected byte length of a JSON-serialized `near_sdk::PublicKey` promise result.