From 35c044ee01e6e2129165beb1f38fa9cb18fc354e Mon Sep 17 00:00:00 2001 From: karim-en Date: Sun, 23 Aug 2026 01:15:39 +0100 Subject: [PATCH 1/9] feat: rolling-window confirmations --- contracts/satoshi-bridge/src/api/view.rs | 8 +- .../satoshi-bridge/src/block_amount_ring.rs | 259 +++++++++++++++--- contracts/satoshi-bridge/src/config.rs | 45 +-- .../satoshi-bridge/tests/test_block_limit.rs | 75 ++++- 4 files changed, 313 insertions(+), 74 deletions(-) diff --git a/contracts/satoshi-bridge/src/api/view.rs b/contracts/satoshi-bridge/src/api/view.rs index 36779bc..217121b 100644 --- a/contracts/satoshi-bridge/src/api/view.rs +++ b/contracts/satoshi-bridge/src/api/view.rs @@ -92,11 +92,9 @@ impl Contract { relayer_account_id: Option, has_extra_msg: Option, ) -> u64 { - self.required_confirmations(block_height, amount.0) - + self.confirmations_delta_for( - relayer_account_id.as_ref(), - has_extra_msg.unwrap_or(false), - ) + let delta = self + .confirmations_delta_for(relayer_account_id.as_ref(), has_extra_msg.unwrap_or(false)); + self.required_confirmations(block_height, amount.0, delta) } pub fn get_account(&self, account_id: &AccountId) -> Option { diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index 1ba13c5..3439d89 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -11,8 +11,9 @@ pub struct BlockAmountCell { } /// Fixed-capacity ring of cumulative bridged satoshi amounts per BTC block -/// (slot = `block_height % capacity`), so confirmations tiers apply to the -/// per-block sum rather than to each tx separately. +/// (slot = `block_height % capacity`), so confirmations tiers apply to the total +/// bridged from a rolling window of blocks rather than to each tx, or each +/// block, separately. #[near(serializers = [borsh])] #[cfg_attr(not(target_arch = "wasm32"), derive(Debug, PartialEq, Eq))] pub struct BlockAmountRing { @@ -67,15 +68,21 @@ impl BlockAmountRing { } } - pub fn peek(&self, block_height: u64, amount: u128) -> Option { - let i = self.slot(block_height); - match &self.cells[i] { - Some(c) if c.block_height == block_height => { - Some(c.cumulative_sats.saturating_add(amount)) + /// Running totals of the amounts recorded for the windows + /// `[tip_height - k + 1, tip_height]`, yielded for every window size `k` + /// from `1` to `max_window`. Blocks the ring no longer remembers, and blocks + /// before genesis for a window reaching past it, contribute nothing. + pub fn window_totals( + &self, + tip_height: u64, + max_window: u64, + ) -> impl Iterator + '_ { + (1..=max_window).scan(0u128, move |total, k| { + if let Some(height) = tip_height.checked_sub(k - 1) { + *total = total.saturating_add(self.get(height).unwrap_or(0)); } - Some(c) if c.block_height > block_height => None, - _ => Some(amount), - } + Some(*total) + }) } pub fn resize(&mut self, new_capacity: usize) { @@ -106,9 +113,8 @@ impl BlockAmountRing { } impl Contract { - /// Panics unless the observed depth satisfies the confirmations tier for - /// the block's post-bump cumulative amount. Out-of-window blocks fall back - /// to the max tier. + /// Panics unless bridging `amount` keeps every rolling window of blocks + /// within the tier that its own depth buys, then records the amount. pub(crate) fn bump_and_check_confirmations( &mut self, block_height: u64, @@ -116,31 +122,86 @@ impl Contract { 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).saturating_add(1); + let tiers = self.internal_config().sorted_confirmations_tiers(); require!( - actual >= required, - "Not enough confirmations for the block-cumulative bridge amount" + self.confirmations_window_satisfied(&tiers, block_height, tip_height, amount, delta), + "Not enough confirmations for the rolling-window bridge amount" ); + // Recorded even when the block is already past every window. The write is + // dropped only when a newer block holds the slot, which puts this one at + // least a full ring behind the tip — further back than any window reaches. + self.data_mut() + .block_bridge_amounts + .bump(block_height, amount); } + /// Capacity tracks the widest window, so under a fixed config no height a + /// window reads can have been evicted. Growing it is the exception: the wider + /// window immediately reaches heights the smaller ring had already dropped, + /// which then count as zero until the tip moves past them. pub(crate) fn resize_block_amount_ring(&mut self) { let cap = BlockAmountRing::capacity_for(self.internal_config()); self.data_mut().block_bridge_amounts.resize(cap); } - pub(crate) fn required_confirmations(&self, block_height: u64, amount: u128) -> u64 { - let cumulative = self - .data() - .block_bridge_amounts - .peek(block_height, amount) - .unwrap_or(u128::MAX); - self.internal_config().get_confirmations(cumulative) + /// Minimum confirmations (`delta` included) at which `amount` from + /// `block_height` clears every rolling window, as of now. Anyone bridging + /// into a neighbouring block raises it again, up to `max_window`, since the + /// windows are a shared budget. + pub(crate) fn required_confirmations( + &self, + block_height: u64, + amount: u128, + delta: u64, + ) -> u64 { + let max_window = self.max_confirmations_window(delta); + let tiers = self.internal_config().sorted_confirmations_tiers(); + // The `max_window` window is satisfied by every amount the tier table can + // rate, so the search never has to look past it. + (1..max_window) + .find(|depth| { + self.confirmations_window_satisfied( + &tiers, + block_height, + block_height.saturating_add(depth - 1), + amount, + delta, + ) + }) + .unwrap_or(max_window) + } + + /// Bridging `amount` from `block_height` puts it at risk of every reorg at + /// least `depth = tip_height - block_height + 1` blocks deep, so each window + /// `[tip_height - k + 1, tip_height]` with `k >= depth` must keep its total + /// within the tier that `k` confirmations buy. Shallower windows do not + /// contain `block_height` and are left alone; blocks deeper than the widest + /// window are unconstrained, as the tier table cannot ask for more. + fn confirmations_window_satisfied( + &self, + tiers: &[(u128, u64)], + block_height: u64, + tip_height: u64, + amount: u128, + delta: u64, + ) -> bool { + let max_window = self.max_confirmations_window(delta); + let depth = tip_height.saturating_sub(block_height).saturating_add(1); + (1..=max_window) + .zip( + self.data() + .block_bridge_amounts + .window_totals(tip_height, max_window), + ) + .all(|(k, bridged)| { + k < depth + || Config::tier_confirmations(tiers, bridged.saturating_add(amount)) + delta + <= k + }) + } + + fn max_confirmations_window(&self, delta: u64) -> u64 { + u64::from(self.internal_config().max_tier_confirmations()) + delta } } @@ -306,29 +367,59 @@ mod tests { } #[test] - fn peek_matches_bump_without_mutation() { - let mut ring = BlockAmountRing::new(4); - assert_eq!(ring.peek(100, 500), Some(500)); - assert_eq!(ring.get(100), None); + fn window_totals_accumulate_backwards_from_the_tip() { + let mut ring = BlockAmountRing::new(8); + ring.bump(100, 5); + ring.bump(102, 7); + ring.bump(103, 1); + assert_eq!( + ring.window_totals(103, 4).collect::>(), + vec![1, 8, 8, 13] + ); + } - ring.bump(100, 500); - assert_eq!(ring.peek(100, 250), Some(750)); - assert_eq!(ring.get(100), Some(500)); + #[test] + fn window_totals_are_empty_for_an_untouched_ring() { + let ring = BlockAmountRing::new(4); + assert_eq!( + ring.window_totals(100, 3).collect::>(), + vec![0, 0, 0] + ); + } - assert_eq!(ring.peek(96, 10), None); - assert_eq!(ring.peek(104, 10), Some(10)); - assert_eq!(ring.get(100), Some(500)); + #[test] + fn window_totals_ignore_blocks_the_ring_forgot() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, 5); + // Height 104 takes over slot 0 and evicts 100. + ring.bump(104, 7); + assert_eq!( + ring.window_totals(104, 5).collect::>(), + vec![7, 7, 7, 7, 7] + ); } #[test] - fn peek_overflow_saturates() { + fn window_totals_stop_at_genesis() { let mut ring = BlockAmountRing::new(4); - ring.bump(100, u128::MAX); - assert_eq!(ring.peek(100, 1), Some(u128::MAX)); + ring.bump(1, 5); + assert_eq!(ring.window_totals(1, 3).collect::>(), vec![5, 5, 5]); } #[test] - fn get_required_confirmations_applies_tier_to_cumulative() { + fn window_totals_overflow_saturates() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, u128::MAX); + ring.bump(99, 5); + assert_eq!( + ring.window_totals(100, 2).collect::>(), + vec![u128::MAX, u128::MAX] + ); + } + + // Tiers {10_000 -> 2 confirmations, 10_000_000 -> 6}, no relayer delta, so + // the widest rolling window is 6 blocks. + fn two_tier_env() -> crate::UnitEnv { let mut unit_env = crate::init_unit_env(); crate::testing_env!(unit_env .context @@ -344,6 +435,12 @@ mod tests { unit_env .contract .set_confirmations_strategy(crate::U128(10_000_000), 6); + unit_env + } + + #[test] + fn get_required_confirmations_applies_tier_to_rolling_window() { + let mut unit_env = two_tier_env(); assert_eq!( unit_env @@ -361,20 +458,96 @@ mod tests { unit_env .contract .bump_and_check_confirmations(100, 110, 8_000, 0); + // 5_000 more from block 100 puts 13_000 in every window that holds it. assert_eq!( unit_env .contract .get_required_confirmations(100, crate::U128(5_000), None, None), 6 ); + // Block 101 shares windows with block 100: at depth 5 the 6-block window + // holding both is 6 deep, which the 13_000 tier accepts. assert_eq!( unit_env .contract .get_required_confirmations(101, crate::U128(5_000), None, None), + 5 + ); + // Far enough ahead, block 100 has left every window. + assert_eq!( + unit_env + .contract + .get_required_confirmations(106, crate::U128(5_000), None, None), 2 ); } + #[test] + #[should_panic(expected = "Not enough confirmations for the rolling-window bridge amount")] + fn consecutive_blocks_cannot_reuse_the_low_tier() { + let mut unit_env = two_tier_env(); + // 8_000 is under the 10_000 low tier, so 2 confirmations are enough... + unit_env + .contract + .bump_and_check_confirmations(100, 101, 8_000, 0); + // ...but repeating it one block later would leave 16_000 exposed to a + // 3-block reorg, which only the 6-confirmation tier covers. + unit_env + .contract + .bump_and_check_confirmations(101, 102, 8_000, 0); + } + + #[test] + fn consecutive_blocks_pass_once_the_shared_window_is_deep_enough() { + let mut unit_env = two_tier_env(); + unit_env + .contract + .bump_and_check_confirmations(100, 101, 8_000, 0); + assert_eq!( + unit_env + .contract + .get_required_confirmations(101, crate::U128(8_000), None, None), + 5 + ); + unit_env + .contract + .bump_and_check_confirmations(101, 105, 8_000, 0); + assert_eq!( + unit_env.contract.data().block_bridge_amounts.get(101), + Some(8_000) + ); + } + + #[test] + fn block_deeper_than_the_widest_window_is_unconstrained() { + let mut unit_env = two_tier_env(); + // Way over the top tier, but 7 blocks deep: no window reaches it. + unit_env + .contract + .bump_and_check_confirmations(100, 106, 50_000_000, 0); + // Still recorded, so a tip regression cannot lose track of it. + assert_eq!( + unit_env.contract.data().block_bridge_amounts.get(100), + Some(50_000_000) + ); + } + + #[test] + fn relayer_delta_widens_the_window_and_the_requirement() { + let mut unit_env = two_tier_env(); + unit_env + .contract + .bump_and_check_confirmations(100, 101, 8_000, 0); + // Same deposit as `consecutive_blocks_pass_once_...`, but each tier costs + // one extra confirmation for a non-whitelisted relayer. + assert_eq!( + unit_env + .contract + .get_required_confirmations(101, crate::U128(8_000), None, Some(true)), + 6 + ); + } + #[test] fn get_required_confirmations_includes_relayer_delta() { let mut unit_env = crate::init_unit_env(); diff --git a/contracts/satoshi-bridge/src/config.rs b/contracts/satoshi-bridge/src/config.rs index d6d0483..3c81d25 100644 --- a/contracts/satoshi-bridge/src/config.rs +++ b/contracts/satoshi-bridge/src/config.rs @@ -210,30 +210,41 @@ impl Config { self.chain.clone() } - pub fn get_confirmations(&self, satoshi_amount: u128) -> u64 { + /// Tiers as `(range_upper_bound, confirmations)`, ordered by bound. Hoist it + /// out of loops over amounts: the stored form has to be parsed and sorted on + /// every call. + pub fn sorted_confirmations_tiers(&self) -> Vec<(u128, u64)> { require!( !self.confirmations_strategy.is_empty(), "confirmations_strategy is empty" ); // The key is constrained to U64 during assignment, so it won't panic. - let mut keys = self + let mut tiers = self .confirmations_strategy - .keys() - .map(|k| k.parse::().unwrap()) + .iter() + .map(|(bound, confirmations)| { + (bound.parse::().unwrap(), u64::from(*confirmations)) + }) .collect::>(); - keys.sort_unstable(); - for key in &keys { - if *key > satoshi_amount { - return u64::from(*self.confirmations_strategy.get(&key.to_string()).unwrap()); - } - } - let max_key = keys.last().unwrap(); - u64::from( - *self - .confirmations_strategy - .get(&max_key.to_string()) - .unwrap(), - ) + tiers.sort_unstable(); + tiers + } + + /// Confirmations the first tier above `satoshi_amount` asks for, falling back + /// to the top tier for amounts past every bound. + pub fn tier_confirmations(tiers: &[(u128, u64)], satoshi_amount: u128) -> u64 { + tiers + .iter() + .find(|(bound, _)| *bound > satoshi_amount) + .or_else(|| tiers.last()) + .map_or_else( + || env::panic_str("confirmations_strategy is empty"), + |(_, confirmations)| *confirmations, + ) + } + + pub fn get_confirmations(&self, satoshi_amount: u128) -> u64 { + Self::tier_confirmations(&self.sorted_confirmations_tiers(), satoshi_amount) } pub fn max_tier_confirmations(&self) -> u8 { diff --git a/contracts/satoshi-bridge/tests/test_block_limit.rs b/contracts/satoshi-bridge/tests/test_block_limit.rs index 29643fd..0a9a6d3 100644 --- a/contracts/satoshi-bridge/tests/test_block_limit.rs +++ b/contracts/satoshi-bridge/tests/test_block_limit.rs @@ -12,7 +12,7 @@ const CHAIN: &str = "BitcoinMainnet"; const BLOCKHASH: &str = "0000000000000c3f818b0b6374c609dd8e548a0a9e61065e942cd466c426e00d"; const NOT_ENOUGH_CONFIRMATIONS_ERR: &str = - "Not enough confirmations for the block-cumulative bridge amount"; + "Not enough confirmations for the rolling-window bridge amount"; // Two-tier strategy: amounts below 10_000 need 3 confirmations, // amounts from 10_000 up to the max tier need 10. @@ -250,10 +250,11 @@ async fn test_same_block_cumulative_amount_escalates_tier() { } #[tokio::test] -async fn test_other_block_accumulates_independently() { +async fn test_consecutive_blocks_cannot_reuse_the_low_tier() { let worker = near_workspaces::sandbox().await.unwrap(); let (context, deposit_address) = setup_two_tier_context(&worker).await; + // 6000 on its own is a low-tier amount, so 3 confirmations are enough. set_heights( &context, BASE_BLOCK_HEIGHT, @@ -262,27 +263,64 @@ async fn test_other_block_accumulates_independently() { .await; check!(verify_deposit(&context, &deposit_address, 6000, 1)); + // Repeating it in the next block would leave 12_000 exposed to a 4-block + // reorg, which only the high tier covers. set_heights( &context, BASE_BLOCK_HEIGHT + 1, BASE_BLOCK_HEIGHT + LOW_TIER_CONFIRMATIONS, ) .await; + check!( + verify_deposit(&context, &deposit_address, 6000, 2), + NOT_ENOUGH_CONFIRMATIONS_ERR + ); + + // It passes once the window holding both blocks is high-tier deep, i.e. when + // the older of the two blocks is `HIGH_TIER_CONFIRMATIONS` deep. + set_heights( + &context, + BASE_BLOCK_HEIGHT + 1, + BASE_BLOCK_HEIGHT + HIGH_TIER_CONFIRMATIONS - 1, + ) + .await; check!(verify_deposit(&context, &deposit_address, 6000, 2)); - assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 12_000); + // Same story one block further along. + set_heights( + &context, + BASE_BLOCK_HEIGHT + 2, + BASE_BLOCK_HEIGHT + 2 + LOW_TIER_CONFIRMATIONS - 1, + ) + .await; + check!( + verify_deposit(&context, &deposit_address, 6000, 3), + NOT_ENOUGH_CONFIRMATIONS_ERR + ); + + // Block 100 has aged out of the widest window by now, so only blocks 101 and + // 102 share it: 12_000, which the high-tier depth accepts. + set_heights( + &context, + BASE_BLOCK_HEIGHT + 2, + BASE_BLOCK_HEIGHT + HIGH_TIER_CONFIRMATIONS, + ) + .await; + check!(verify_deposit(&context, &deposit_address, 6000, 3)); + + assert_eq!(context.ft_balance_of("alice").await.unwrap().0, 18_000); } #[tokio::test] -async fn test_ring_wraparound_evicts_old_block_and_falls_back_to_max_tier() { +async fn test_block_deeper_than_the_widest_window_is_unconstrained() { let worker = near_workspaces::sandbox().await.unwrap(); let (context, deposit_address) = setup_two_tier_context(&worker).await; let config = context.get_bridge_config().await.unwrap(); let capacity = u64::try_from(BlockAmountRing::capacity_for(&config)).unwrap(); assert!( - capacity + LOW_TIER_CONFIRMATIONS >= HIGH_TIER_CONFIRMATIONS, - "depth at the original height must already satisfy the max tier" + capacity + LOW_TIER_CONFIRMATIONS > HIGH_TIER_CONFIRMATIONS, + "the original height must end up deeper than the widest window" ); set_heights( @@ -314,6 +352,8 @@ async fn test_ring_wraparound_evicts_old_block_and_falls_back_to_max_tier() { .await; check!(verify_deposit(&context, &deposit_address, 6000, 3)); + // Back at the original height, now a full ring behind the tip: no window + // reaches it, so the tier table cannot ask for more confirmations. set_heights( &context, BASE_BLOCK_HEIGHT, @@ -551,17 +591,29 @@ async fn test_ring_shrink_merges_collided_blocks() { } #[tokio::test] -async fn test_ring_grow_forgotten_block_restarts_from_zero() { +async fn test_ring_grow_undercounts_an_evicted_block() { let worker = near_workspaces::sandbox().await.unwrap(); let (context, deposit_address) = setup_two_tier_context(&worker).await; let config = context.get_bridge_config().await.unwrap(); let capacity = u64::try_from(BlockAmountRing::capacity_for(&config)).unwrap(); let evicting_height = BASE_BLOCK_HEIGHT + capacity; - // The raised top tier must exceed the old capacity, otherwise the forgotten - // block's depth alone satisfies it and the under-count is not observable. + // The raised top tier must exceed the evicted block's final depth, otherwise + // that depth alone satisfies it and the under-count is not observable. let raised_tier = capacity + 13; + // A middle tier that separates the amount the ring remembers (12_000) from + // the amount it should have remembered (18_000). + dao_call( + &context, + "set_confirmations_strategy", + json!({ + "range_upper_bound": "15000", + "confirmations": LOW_TIER_CONFIRMATIONS, + }), + ) + .await; + set_heights( &context, BASE_BLOCK_HEIGHT, @@ -588,6 +640,10 @@ async fn test_ring_grow_forgotten_block_restarts_from_zero() { ) .await; + // The raised tier makes the window wide enough to cover both heights again, + // but the ring no longer remembers the first 6000: it sees 12_000 (low tier, + // accepted) where the true exposure is 18_000 (top tier, which this depth + // would not satisfy). set_heights( &context, BASE_BLOCK_HEIGHT, @@ -595,6 +651,7 @@ async fn test_ring_grow_forgotten_block_restarts_from_zero() { ) .await; check!(verify_deposit(&context, &deposit_address, 6000, 3)); + // One more and even the under-count reaches the top tier. check!( verify_deposit(&context, &deposit_address, 6000, 4), NOT_ENOUGH_CONFIRMATIONS_ERR From 2e9da3639ba72af5243af041323c7ce949adbe56 Mon Sep 17 00:00:00 2001 From: Olga Kunyavskaya Date: Mon, 24 Aug 2026 11:29:11 +0100 Subject: [PATCH 2/9] remove comments --- .../satoshi-bridge/src/block_amount_ring.rs | 37 ------------------- contracts/satoshi-bridge/src/config.rs | 5 --- .../satoshi-bridge/tests/test_block_limit.rs | 17 --------- 3 files changed, 59 deletions(-) diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index 3439d89..404d45e 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -68,10 +68,6 @@ impl BlockAmountRing { } } - /// Running totals of the amounts recorded for the windows - /// `[tip_height - k + 1, tip_height]`, yielded for every window size `k` - /// from `1` to `max_window`. Blocks the ring no longer remembers, and blocks - /// before genesis for a window reaching past it, contribute nothing. pub fn window_totals( &self, tip_height: u64, @@ -127,27 +123,16 @@ impl Contract { self.confirmations_window_satisfied(&tiers, block_height, tip_height, amount, delta), "Not enough confirmations for the rolling-window bridge amount" ); - // Recorded even when the block is already past every window. The write is - // dropped only when a newer block holds the slot, which puts this one at - // least a full ring behind the tip — further back than any window reaches. self.data_mut() .block_bridge_amounts .bump(block_height, amount); } - /// Capacity tracks the widest window, so under a fixed config no height a - /// window reads can have been evicted. Growing it is the exception: the wider - /// window immediately reaches heights the smaller ring had already dropped, - /// which then count as zero until the tip moves past them. pub(crate) fn resize_block_amount_ring(&mut self) { let cap = BlockAmountRing::capacity_for(self.internal_config()); self.data_mut().block_bridge_amounts.resize(cap); } - /// Minimum confirmations (`delta` included) at which `amount` from - /// `block_height` clears every rolling window, as of now. Anyone bridging - /// into a neighbouring block raises it again, up to `max_window`, since the - /// windows are a shared budget. pub(crate) fn required_confirmations( &self, block_height: u64, @@ -156,8 +141,6 @@ impl Contract { ) -> u64 { let max_window = self.max_confirmations_window(delta); let tiers = self.internal_config().sorted_confirmations_tiers(); - // The `max_window` window is satisfied by every amount the tier table can - // rate, so the search never has to look past it. (1..max_window) .find(|depth| { self.confirmations_window_satisfied( @@ -171,12 +154,6 @@ impl Contract { .unwrap_or(max_window) } - /// Bridging `amount` from `block_height` puts it at risk of every reorg at - /// least `depth = tip_height - block_height + 1` blocks deep, so each window - /// `[tip_height - k + 1, tip_height]` with `k >= depth` must keep its total - /// within the tier that `k` confirmations buy. Shallower windows do not - /// contain `block_height` and are left alone; blocks deeper than the widest - /// window are unconstrained, as the tier table cannot ask for more. fn confirmations_window_satisfied( &self, tiers: &[(u128, u64)], @@ -391,7 +368,6 @@ mod tests { fn window_totals_ignore_blocks_the_ring_forgot() { let mut ring = BlockAmountRing::new(4); ring.bump(100, 5); - // Height 104 takes over slot 0 and evicts 100. ring.bump(104, 7); assert_eq!( ring.window_totals(104, 5).collect::>(), @@ -417,8 +393,6 @@ mod tests { ); } - // Tiers {10_000 -> 2 confirmations, 10_000_000 -> 6}, no relayer delta, so - // the widest rolling window is 6 blocks. fn two_tier_env() -> crate::UnitEnv { let mut unit_env = crate::init_unit_env(); crate::testing_env!(unit_env @@ -458,22 +432,18 @@ mod tests { unit_env .contract .bump_and_check_confirmations(100, 110, 8_000, 0); - // 5_000 more from block 100 puts 13_000 in every window that holds it. assert_eq!( unit_env .contract .get_required_confirmations(100, crate::U128(5_000), None, None), 6 ); - // Block 101 shares windows with block 100: at depth 5 the 6-block window - // holding both is 6 deep, which the 13_000 tier accepts. assert_eq!( unit_env .contract .get_required_confirmations(101, crate::U128(5_000), None, None), 5 ); - // Far enough ahead, block 100 has left every window. assert_eq!( unit_env .contract @@ -486,12 +456,9 @@ mod tests { #[should_panic(expected = "Not enough confirmations for the rolling-window bridge amount")] fn consecutive_blocks_cannot_reuse_the_low_tier() { let mut unit_env = two_tier_env(); - // 8_000 is under the 10_000 low tier, so 2 confirmations are enough... unit_env .contract .bump_and_check_confirmations(100, 101, 8_000, 0); - // ...but repeating it one block later would leave 16_000 exposed to a - // 3-block reorg, which only the 6-confirmation tier covers. unit_env .contract .bump_and_check_confirmations(101, 102, 8_000, 0); @@ -521,11 +488,9 @@ mod tests { #[test] fn block_deeper_than_the_widest_window_is_unconstrained() { let mut unit_env = two_tier_env(); - // Way over the top tier, but 7 blocks deep: no window reaches it. unit_env .contract .bump_and_check_confirmations(100, 106, 50_000_000, 0); - // Still recorded, so a tip regression cannot lose track of it. assert_eq!( unit_env.contract.data().block_bridge_amounts.get(100), Some(50_000_000) @@ -538,8 +503,6 @@ mod tests { unit_env .contract .bump_and_check_confirmations(100, 101, 8_000, 0); - // Same deposit as `consecutive_blocks_pass_once_...`, but each tier costs - // one extra confirmation for a non-whitelisted relayer. assert_eq!( unit_env .contract diff --git a/contracts/satoshi-bridge/src/config.rs b/contracts/satoshi-bridge/src/config.rs index 3c81d25..aac1265 100644 --- a/contracts/satoshi-bridge/src/config.rs +++ b/contracts/satoshi-bridge/src/config.rs @@ -210,9 +210,6 @@ impl Config { self.chain.clone() } - /// Tiers as `(range_upper_bound, confirmations)`, ordered by bound. Hoist it - /// out of loops over amounts: the stored form has to be parsed and sorted on - /// every call. pub fn sorted_confirmations_tiers(&self) -> Vec<(u128, u64)> { require!( !self.confirmations_strategy.is_empty(), @@ -230,8 +227,6 @@ impl Config { tiers } - /// Confirmations the first tier above `satoshi_amount` asks for, falling back - /// to the top tier for amounts past every bound. pub fn tier_confirmations(tiers: &[(u128, u64)], satoshi_amount: u128) -> u64 { tiers .iter() diff --git a/contracts/satoshi-bridge/tests/test_block_limit.rs b/contracts/satoshi-bridge/tests/test_block_limit.rs index 0a9a6d3..3619078 100644 --- a/contracts/satoshi-bridge/tests/test_block_limit.rs +++ b/contracts/satoshi-bridge/tests/test_block_limit.rs @@ -254,7 +254,6 @@ async fn test_consecutive_blocks_cannot_reuse_the_low_tier() { let worker = near_workspaces::sandbox().await.unwrap(); let (context, deposit_address) = setup_two_tier_context(&worker).await; - // 6000 on its own is a low-tier amount, so 3 confirmations are enough. set_heights( &context, BASE_BLOCK_HEIGHT, @@ -263,8 +262,6 @@ async fn test_consecutive_blocks_cannot_reuse_the_low_tier() { .await; check!(verify_deposit(&context, &deposit_address, 6000, 1)); - // Repeating it in the next block would leave 12_000 exposed to a 4-block - // reorg, which only the high tier covers. set_heights( &context, BASE_BLOCK_HEIGHT + 1, @@ -276,8 +273,6 @@ async fn test_consecutive_blocks_cannot_reuse_the_low_tier() { NOT_ENOUGH_CONFIRMATIONS_ERR ); - // It passes once the window holding both blocks is high-tier deep, i.e. when - // the older of the two blocks is `HIGH_TIER_CONFIRMATIONS` deep. set_heights( &context, BASE_BLOCK_HEIGHT + 1, @@ -286,7 +281,6 @@ async fn test_consecutive_blocks_cannot_reuse_the_low_tier() { .await; check!(verify_deposit(&context, &deposit_address, 6000, 2)); - // Same story one block further along. set_heights( &context, BASE_BLOCK_HEIGHT + 2, @@ -298,8 +292,6 @@ async fn test_consecutive_blocks_cannot_reuse_the_low_tier() { NOT_ENOUGH_CONFIRMATIONS_ERR ); - // Block 100 has aged out of the widest window by now, so only blocks 101 and - // 102 share it: 12_000, which the high-tier depth accepts. set_heights( &context, BASE_BLOCK_HEIGHT + 2, @@ -352,8 +344,6 @@ async fn test_block_deeper_than_the_widest_window_is_unconstrained() { .await; check!(verify_deposit(&context, &deposit_address, 6000, 3)); - // Back at the original height, now a full ring behind the tip: no window - // reaches it, so the tier table cannot ask for more confirmations. set_heights( &context, BASE_BLOCK_HEIGHT, @@ -602,8 +592,6 @@ async fn test_ring_grow_undercounts_an_evicted_block() { // that depth alone satisfies it and the under-count is not observable. let raised_tier = capacity + 13; - // A middle tier that separates the amount the ring remembers (12_000) from - // the amount it should have remembered (18_000). dao_call( &context, "set_confirmations_strategy", @@ -640,10 +628,6 @@ async fn test_ring_grow_undercounts_an_evicted_block() { ) .await; - // The raised tier makes the window wide enough to cover both heights again, - // but the ring no longer remembers the first 6000: it sees 12_000 (low tier, - // accepted) where the true exposure is 18_000 (top tier, which this depth - // would not satisfy). set_heights( &context, BASE_BLOCK_HEIGHT, @@ -651,7 +635,6 @@ async fn test_ring_grow_undercounts_an_evicted_block() { ) .await; check!(verify_deposit(&context, &deposit_address, 6000, 3)); - // One more and even the under-count reaches the top tier. check!( verify_deposit(&context, &deposit_address, 6000, 4), NOT_ENOUGH_CONFIRMATIONS_ERR From 7c6ca0e661278234c44e4dba3e31e8ed0a2435bb Mon Sep 17 00:00:00 2001 From: Olga Kunyavskaya Date: Mon, 24 Aug 2026 20:17:14 +0100 Subject: [PATCH 3/9] prefix sum --- .../satoshi-bridge/src/block_amount_ring.rs | 100 +++++++++++------- 1 file changed, 59 insertions(+), 41 deletions(-) diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index 404d45e..db2890e 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -68,17 +68,32 @@ impl BlockAmountRing { } } - pub fn window_totals( - &self, - tip_height: u64, - max_window: u64, - ) -> impl Iterator + '_ { - (1..=max_window).scan(0u128, move |total, k| { - if let Some(height) = tip_height.checked_sub(k - 1) { - *total = total.saturating_add(self.get(height).unwrap_or(0)); + pub fn prefix_sums(&self) -> (Vec, u64) { + let cap = u64::try_from(self.cells.len()).expect("capacity fits u64"); + let top_height = self + .cells + .iter() + .flatten() + .map(|c| c.block_height) + .max() + .unwrap_or(0) + .max(cap - 1); + let mut slot = self.slot(top_height); + let mut height = top_height; + let mut total = 0u128; + let mut sums = Vec::with_capacity(self.cells.len() + 1); + sums.push(0); + for _ in 0..self.cells.len() { + if let Some(cell) = &self.cells[slot] { + if cell.block_height == height { + total = total.saturating_add(cell.cumulative_sats); + } } - Some(*total) - }) + sums.push(total); + height = height.saturating_sub(1); + slot = slot.checked_sub(1).unwrap_or(self.cells.len() - 1); + } + (sums, top_height) } pub fn resize(&mut self, new_capacity: usize) { @@ -164,17 +179,18 @@ impl Contract { ) -> bool { let max_window = self.max_confirmations_window(delta); let depth = tip_height.saturating_sub(block_height).saturating_add(1); - (1..=max_window) - .zip( - self.data() - .block_bridge_amounts - .window_totals(tip_height, max_window), - ) - .all(|(k, bridged)| { - k < depth - || Config::tier_confirmations(tiers, bridged.saturating_add(amount)) + delta - <= k - }) + let (sums, top_height) = self.data().block_bridge_amounts.prefix_sums(); + let recorded_from = |height: u64| { + let back = top_height.saturating_add(1).saturating_sub(height); + sums[usize::try_from(back) + .unwrap_or(usize::MAX) + .min(sums.len() - 1)] + }; + let above_tip = recorded_from(tip_height.saturating_add(1)); + (depth..=max_window).all(|k| { + let bridged = recorded_from(tip_height.saturating_sub(k - 1)).saturating_sub(above_tip); + Config::tier_confirmations(tiers, bridged.saturating_add(amount)) + delta <= k + }) } fn max_confirmations_window(&self, delta: u64) -> u64 { @@ -344,53 +360,55 @@ mod tests { } #[test] - fn window_totals_accumulate_backwards_from_the_tip() { + fn prefix_sums_accumulate_backwards_from_the_top() { let mut ring = BlockAmountRing::new(8); ring.bump(100, 5); ring.bump(102, 7); ring.bump(103, 1); assert_eq!( - ring.window_totals(103, 4).collect::>(), - vec![1, 8, 8, 13] + ring.prefix_sums(), + (vec![0, 1, 8, 8, 13, 13, 13, 13, 13], 103) ); } #[test] - fn window_totals_are_empty_for_an_untouched_ring() { + fn prefix_sums_are_zero_for_an_untouched_ring() { let ring = BlockAmountRing::new(4); - assert_eq!( - ring.window_totals(100, 3).collect::>(), - vec![0, 0, 0] - ); + assert_eq!(ring.prefix_sums(), (vec![0, 0, 0, 0, 0], 3)); } #[test] - fn window_totals_ignore_blocks_the_ring_forgot() { + fn prefix_sums_ignore_blocks_the_ring_forgot() { let mut ring = BlockAmountRing::new(4); ring.bump(100, 5); ring.bump(104, 7); - assert_eq!( - ring.window_totals(104, 5).collect::>(), - vec![7, 7, 7, 7, 7] - ); + assert_eq!(ring.prefix_sums(), (vec![0, 7, 7, 7, 7], 104)); } #[test] - fn window_totals_stop_at_genesis() { + fn prefix_sums_skip_blocks_behind_the_window() { let mut ring = BlockAmountRing::new(4); + ring.bump(100, 5); + ring.bump(105, 7); + assert_eq!(ring.prefix_sums(), (vec![0, 7, 7, 7, 7], 105)); + } + + #[test] + fn prefix_sums_clamp_top_height_near_genesis() { + let mut ring = BlockAmountRing::new(4); + ring.bump(0, 3); ring.bump(1, 5); - assert_eq!(ring.window_totals(1, 3).collect::>(), vec![5, 5, 5]); + assert_eq!(ring.prefix_sums(), (vec![0, 0, 0, 5, 8], 3)); } #[test] - fn window_totals_overflow_saturates() { + fn prefix_sums_overflow_saturates() { let mut ring = BlockAmountRing::new(4); ring.bump(100, u128::MAX); ring.bump(99, 5); - assert_eq!( - ring.window_totals(100, 2).collect::>(), - vec![u128::MAX, u128::MAX] - ); + let mut expected = vec![u128::MAX; 5]; + expected[0] = 0; + assert_eq!(ring.prefix_sums(), (expected, 100)); } fn two_tier_env() -> crate::UnitEnv { From 6aacc9be95738a7ecec2944d1825cad5ae52c8bc Mon Sep 17 00:00:00 2001 From: Olga Kunyavskaya Date: Mon, 24 Aug 2026 21:41:07 +0100 Subject: [PATCH 4/9] optimize required confirmations --- .../satoshi-bridge/src/block_amount_ring.rs | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index db2890e..a45955e 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -154,19 +154,29 @@ impl Contract { amount: u128, delta: u64, ) -> u64 { - let max_window = self.max_confirmations_window(delta); let tiers = self.internal_config().sorted_confirmations_tiers(); - (1..max_window) - .find(|depth| { - self.confirmations_window_satisfied( - &tiers, - block_height, - block_height.saturating_add(depth - 1), - amount, - delta, - ) - }) - .unwrap_or(max_window) + let max_confirmations = self.max_confirmations_window(delta); + let (sums, top_height) = self.data().block_bridge_amounts.prefix_sums(); + let last = u64::try_from(sums.len() - 1).expect("capacity fits u64"); + let mut tier = 0; + let mut required = 0; + for j in 0..=max_confirmations { + let height = block_height.saturating_sub(j); + let i = top_height + .saturating_add(1) + .saturating_sub(height) + .min(last); + let bridged = + sums[usize::try_from(i).expect("index fits usize")].saturating_add(amount); + while tier + 1 < tiers.len() && tiers[tier].0 <= bridged { + tier += 1; + } + required = required.max((tiers[tier].1 + delta).saturating_sub(j)); + if max_confirmations.saturating_sub(j + 1) <= required { + break; + } + } + required } fn confirmations_window_satisfied( @@ -470,6 +480,30 @@ mod tests { ); } + #[test] + fn get_required_confirmations_checks_every_window_against_its_own_tier() { + let mut unit_env = two_tier_env(); + unit_env + .contract + .set_confirmations_strategy(crate::U128(100_000), 5); + unit_env + .contract + .bump_and_check_confirmations(99, 110, 150_000, 0); + + assert_eq!( + unit_env + .contract + .get_required_confirmations(100, crate::U128(5_000), None, None), + 5 + ); + assert_eq!( + unit_env + .contract + .get_required_confirmations(101, crate::U128(5_000), None, None), + 4 + ); + } + #[test] #[should_panic(expected = "Not enough confirmations for the rolling-window bridge amount")] fn consecutive_blocks_cannot_reuse_the_low_tier() { From 6600b7d9955ee7ad0fbb6e9a8710b9201b0e8827 Mon Sep 17 00:00:00 2001 From: Olga Kunyavskaya Date: Mon, 24 Aug 2026 21:54:25 +0100 Subject: [PATCH 5/9] optimize confirmations_window_satisfied --- .../satoshi-bridge/src/block_amount_ring.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index a45955e..7f33cab 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -187,7 +187,6 @@ impl Contract { amount: u128, delta: u64, ) -> bool { - let max_window = self.max_confirmations_window(delta); let depth = tip_height.saturating_sub(block_height).saturating_add(1); let (sums, top_height) = self.data().block_bridge_amounts.prefix_sums(); let recorded_from = |height: u64| { @@ -196,11 +195,19 @@ impl Contract { .unwrap_or(usize::MAX) .min(sums.len() - 1)] }; - let above_tip = recorded_from(tip_height.saturating_add(1)); - (depth..=max_window).all(|k| { - let bridged = recorded_from(tip_height.saturating_sub(k - 1)).saturating_sub(above_tip); - Config::tier_confirmations(tiers, bridged.saturating_add(amount)) + delta <= k - }) + let mut prev_bound = 0; + for (bound, confirmations) in tiers { + let required = confirmations + delta; + if required > depth { + let window_low = tip_height.saturating_sub(required - 2); + let bridged = recorded_from(window_low); + if bridged.saturating_add(amount) >= prev_bound { + return false; + } + } + prev_bound = *bound; + } + true } fn max_confirmations_window(&self, delta: u64) -> u64 { From a91ce8f7c06370439fbdb9c5819de954520fb73b Mon Sep 17 00:00:00 2001 From: Olga Kunyavskaya Date: Tue, 25 Aug 2026 09:24:58 +0100 Subject: [PATCH 6/9] add PrefixSum structure --- .../satoshi-bridge/src/block_amount_ring.rs | 92 ++++++++++++------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index 7f33cab..61b96bc 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -68,9 +68,9 @@ impl BlockAmountRing { } } - pub fn prefix_sums(&self) -> (Vec, u64) { + pub fn prefix_sums(&self) -> PrefixSums { let cap = u64::try_from(self.cells.len()).expect("capacity fits u64"); - let top_height = self + let anchor_height = self .cells .iter() .flatten() @@ -78,8 +78,8 @@ impl BlockAmountRing { .max() .unwrap_or(0) .max(cap - 1); - let mut slot = self.slot(top_height); - let mut height = top_height; + let mut slot = self.slot(anchor_height); + let mut height = anchor_height; let mut total = 0u128; let mut sums = Vec::with_capacity(self.cells.len() + 1); sums.push(0); @@ -93,7 +93,10 @@ impl BlockAmountRing { height = height.saturating_sub(1); slot = slot.checked_sub(1).unwrap_or(self.cells.len() - 1); } - (sums, top_height) + PrefixSums { + sums, + anchor_height, + } } pub fn resize(&mut self, new_capacity: usize) { @@ -123,6 +126,20 @@ impl BlockAmountRing { } } +pub struct PrefixSums { + sums: Vec, + anchor_height: u64, +} + +impl PrefixSums { + pub fn recorded_from(&self, height: u64) -> u128 { + let back = self.anchor_height.saturating_add(1).saturating_sub(height); + self.sums[usize::try_from(back) + .unwrap_or(usize::MAX) + .min(self.sums.len() - 1)] + } +} + impl Contract { /// Panics unless bridging `amount` keeps every rolling window of blocks /// within the tier that its own depth buys, then records the amount. @@ -156,18 +173,13 @@ impl Contract { ) -> u64 { let tiers = self.internal_config().sorted_confirmations_tiers(); let max_confirmations = self.max_confirmations_window(delta); - let (sums, top_height) = self.data().block_bridge_amounts.prefix_sums(); - let last = u64::try_from(sums.len() - 1).expect("capacity fits u64"); + let sums = self.data().block_bridge_amounts.prefix_sums(); let mut tier = 0; let mut required = 0; for j in 0..=max_confirmations { - let height = block_height.saturating_sub(j); - let i = top_height - .saturating_add(1) - .saturating_sub(height) - .min(last); - let bridged = - sums[usize::try_from(i).expect("index fits usize")].saturating_add(amount); + let bridged = sums + .recorded_from(block_height.saturating_sub(j)) + .saturating_add(amount); while tier + 1 < tiers.len() && tiers[tier].0 <= bridged { tier += 1; } @@ -188,20 +200,13 @@ impl Contract { delta: u64, ) -> bool { let depth = tip_height.saturating_sub(block_height).saturating_add(1); - let (sums, top_height) = self.data().block_bridge_amounts.prefix_sums(); - let recorded_from = |height: u64| { - let back = top_height.saturating_add(1).saturating_sub(height); - sums[usize::try_from(back) - .unwrap_or(usize::MAX) - .min(sums.len() - 1)] - }; + let sums = self.data().block_bridge_amounts.prefix_sums(); let mut prev_bound = 0; for (bound, confirmations) in tiers { let required = confirmations + delta; if required > depth { let window_low = tip_height.saturating_sub(required - 2); - let bridged = recorded_from(window_low); - if bridged.saturating_add(amount) >= prev_bound { + if sums.recorded_from(window_low).saturating_add(amount) >= prev_bound { return false; } } @@ -382,16 +387,17 @@ mod tests { ring.bump(100, 5); ring.bump(102, 7); ring.bump(103, 1); - assert_eq!( - ring.prefix_sums(), - (vec![0, 1, 8, 8, 13, 13, 13, 13, 13], 103) - ); + let sums = ring.prefix_sums(); + assert_eq!(sums.sums, vec![0, 1, 8, 8, 13, 13, 13, 13, 13]); + assert_eq!(sums.anchor_height, 103); } #[test] fn prefix_sums_are_zero_for_an_untouched_ring() { let ring = BlockAmountRing::new(4); - assert_eq!(ring.prefix_sums(), (vec![0, 0, 0, 0, 0], 3)); + let sums = ring.prefix_sums(); + assert_eq!(sums.sums, vec![0, 0, 0, 0, 0]); + assert_eq!(sums.anchor_height, 3); } #[test] @@ -399,7 +405,9 @@ mod tests { let mut ring = BlockAmountRing::new(4); ring.bump(100, 5); ring.bump(104, 7); - assert_eq!(ring.prefix_sums(), (vec![0, 7, 7, 7, 7], 104)); + let sums = ring.prefix_sums(); + assert_eq!(sums.sums, vec![0, 7, 7, 7, 7]); + assert_eq!(sums.anchor_height, 104); } #[test] @@ -407,15 +415,19 @@ mod tests { let mut ring = BlockAmountRing::new(4); ring.bump(100, 5); ring.bump(105, 7); - assert_eq!(ring.prefix_sums(), (vec![0, 7, 7, 7, 7], 105)); + let sums = ring.prefix_sums(); + assert_eq!(sums.sums, vec![0, 7, 7, 7, 7]); + assert_eq!(sums.anchor_height, 105); } #[test] - fn prefix_sums_clamp_top_height_near_genesis() { + fn prefix_sums_clamp_anchor_height_near_genesis() { let mut ring = BlockAmountRing::new(4); ring.bump(0, 3); ring.bump(1, 5); - assert_eq!(ring.prefix_sums(), (vec![0, 0, 0, 5, 8], 3)); + let sums = ring.prefix_sums(); + assert_eq!(sums.sums, vec![0, 0, 0, 5, 8]); + assert_eq!(sums.anchor_height, 3); } #[test] @@ -425,7 +437,21 @@ mod tests { ring.bump(99, 5); let mut expected = vec![u128::MAX; 5]; expected[0] = 0; - assert_eq!(ring.prefix_sums(), (expected, 100)); + let sums = ring.prefix_sums(); + assert_eq!(sums.sums, expected); + assert_eq!(sums.anchor_height, 100); + } + + #[test] + fn recorded_from_reads_totals_by_height() { + let mut ring = BlockAmountRing::new(4); + ring.bump(100, 5); + ring.bump(99, 7); + let sums = ring.prefix_sums(); + assert_eq!(sums.recorded_from(101), 0); + assert_eq!(sums.recorded_from(100), 5); + assert_eq!(sums.recorded_from(99), 12); + assert_eq!(sums.recorded_from(0), 12); } fn two_tier_env() -> crate::UnitEnv { From d3eda5f54d3a605f5426c0dfd955e0fe7e204afa Mon Sep 17 00:00:00 2001 From: Olga Kunyavskaya Date: Tue, 25 Aug 2026 09:37:31 +0100 Subject: [PATCH 7/9] move tiers --- contracts/satoshi-bridge/src/block_amount_ring.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index 61b96bc..7c42a2b 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -150,9 +150,8 @@ impl Contract { amount: u128, delta: u64, ) { - let tiers = self.internal_config().sorted_confirmations_tiers(); require!( - self.confirmations_window_satisfied(&tiers, block_height, tip_height, amount, delta), + self.confirmations_window_satisfied(block_height, tip_height, amount, delta), "Not enough confirmations for the rolling-window bridge amount" ); self.data_mut() @@ -193,16 +192,16 @@ impl Contract { fn confirmations_window_satisfied( &self, - tiers: &[(u128, u64)], block_height: u64, tip_height: u64, amount: u128, delta: u64, ) -> bool { + let tiers = self.internal_config().sorted_confirmations_tiers(); let depth = tip_height.saturating_sub(block_height).saturating_add(1); let sums = self.data().block_bridge_amounts.prefix_sums(); let mut prev_bound = 0; - for (bound, confirmations) in tiers { + for (bound, confirmations) in &tiers { let required = confirmations + delta; if required > depth { let window_low = tip_height.saturating_sub(required - 2); From fd2a8dbb87896dd339bddc4e52dff1cdb7b7c41c Mon Sep 17 00:00:00 2001 From: Olga Kunyavskaya Date: Tue, 25 Aug 2026 10:36:54 +0100 Subject: [PATCH 8/9] add comments confirmations_window_satisfied --- .../satoshi-bridge/src/block_amount_ring.rs | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index 7c42a2b..9f6c2aa 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -140,6 +140,18 @@ impl PrefixSums { } } +/// Confirmations `block_height` has when the chain tip is `tip_height`: +/// the tip itself counts as one. +fn confirmations(block_height: u64, tip_height: u64) -> u64 { + tip_height.saturating_sub(block_height).saturating_add(1) +} + +/// The block height that has exactly `confirmations` confirmations when the +/// chain tip is `tip_height`. +fn height_with_confirmations(confirmations: u64, tip_height: u64) -> u64 { + tip_height.saturating_sub(confirmations.saturating_sub(1)) +} + impl Contract { /// Panics unless bridging `amount` keeps every rolling window of blocks /// within the tier that its own depth buys, then records the amount. @@ -190,6 +202,8 @@ impl Contract { required } + /// Checks that bridging `amount` from `block_height` would keep the + /// bridged totals within every risk limit of the confirmations strategy. fn confirmations_window_satisfied( &self, block_height: u64, @@ -197,14 +211,45 @@ impl Contract { amount: u128, delta: u64, ) -> bool { + // Each tier says how much we are willing to risk on blocks that have + // not yet reached its confirmation count. Tiers `3 -> 10_000`, + // `10 -> 100_000`, `25 -> 1_000_000` read as: + // - blocks with fewer than 3 confirmations must hold nothing; + // - blocks with fewer than 10 confirmations must hold less than + // 10_000 in total; + // - blocks with fewer than 25 confirmations must hold less than + // 100_000 in total; + // - with 25 confirmations and more, any amount goes (the top tier is + // a fallback). let tiers = self.internal_config().sorted_confirmations_tiers(); - let depth = tip_height.saturating_sub(block_height).saturating_add(1); + let depth = confirmations(block_height, tip_height); + // `sums.recorded_from(height)` tells how much in total was bridged + // from `height` up to the tip inclusive. let sums = self.data().block_bridge_amounts.prefix_sums(); + // Check that the new amount satisfies every such limit. Each limit + // comes from a pair of adjacent tiers: with `3 -> 10_000` followed by + // `10 -> 100_000`, the blocks with at most 9 confirmations must hold, + // together with the new `amount`, less than 10_000 — the bound of the + // previous tier, which is why `prev_bound` is carried along. let mut prev_bound = 0; for (bound, confirmations) in &tiers { + // The confirmations this tier actually demands: a non-zero + // `delta` shifts the whole ladder, so with `delta = 2` the + // example above acts as `5 -> 10_000`, `12 -> 100_000`, + // `27 -> 1_000_000`. let required = confirmations + delta; + // A tier limits the blocks with at most `required - 1` + // confirmations, so a block with `depth >= required` has outgrown + // it: a limit on up to 9 confirmations (`required == 10`) says + // nothing about a block that already has 15. if required > depth { - let window_low = tip_height.saturating_sub(required - 2); + // Everything the tier limits adds up in the prefix sum of its + // deepest block — the one with `required - 1` confirmations. + // E.g. when the new amount lands on the block with 4 + // confirmations and the limit allows 10_000 on blocks with up + // to 9 confirmations, we take the total recorded from the + // block with 9 confirmations and add the new amount to it. + let window_low = height_with_confirmations(required - 1, tip_height); if sums.recorded_from(window_low).saturating_add(amount) >= prev_bound { return false; } From fe737922a7621800cd77e159acd8e25a65f1db71 Mon Sep 17 00:00:00 2001 From: Olga Kunyavskaya Date: Tue, 25 Aug 2026 12:51:31 +0100 Subject: [PATCH 9/9] add risk limits --- .../satoshi-bridge/src/block_amount_ring.rs | 108 ++++++++---------- contracts/satoshi-bridge/src/config.rs | 59 ++++++++++ 2 files changed, 108 insertions(+), 59 deletions(-) diff --git a/contracts/satoshi-bridge/src/block_amount_ring.rs b/contracts/satoshi-bridge/src/block_amount_ring.rs index 9f6c2aa..059bc0b 100644 --- a/contracts/satoshi-bridge/src/block_amount_ring.rs +++ b/contracts/satoshi-bridge/src/block_amount_ring.rs @@ -176,28 +176,46 @@ impl Contract { self.data_mut().block_bridge_amounts.resize(cap); } + /// The minimum number of confirmations a deposit of `amount` from + /// `block_height` needs to pass `confirmations_window_satisfied`, given + /// what is already bridged from the surrounding blocks and the `delta`. pub(crate) fn required_confirmations( &self, block_height: u64, amount: u128, delta: u64, ) -> u64 { - let tiers = self.internal_config().sorted_confirmations_tiers(); - let max_confirmations = self.max_confirmations_window(delta); + let limits = self.internal_config().risk_limits(delta); let sums = self.data().block_bridge_amounts.prefix_sums(); - let mut tier = 0; + let bridged = |blocks_below: u64| { + sums.recorded_from(block_height.saturating_sub(blocks_below)) + .saturating_add(amount) + }; + // Each limit constrains us on its own. For a limit, find how many + // blocks below `block_height` can still join before the total stops + // fitting into `allowed_total`: the number `j` such that `j` blocks + // below ours (with ours and the new amount) no longer fit while + // `j - 1` still do. `j == 0` means even our own block does not fit; + // `j == 3` means two blocks below ours can join and a third cannot. + // + // So the `j - 1`-th block below ours needs `max_confirmations` + // confirmations, and ours, `j - 1` blocks higher, needs + // `max_confirmations + 1 - j`. + // + // The answer is the maximum over the limits. + // + // Limits grow in both `allowed_total` and `max_confirmations`, and + // the window totals grow with `j`, so `j` never has to move back + // between limits: one pass with two pointers. Past + // `max_confirmations` the limit asks nothing of our block, so `j` + // stops there. + let mut j = 0; let mut required = 0; - for j in 0..=max_confirmations { - let bridged = sums - .recorded_from(block_height.saturating_sub(j)) - .saturating_add(amount); - while tier + 1 < tiers.len() && tiers[tier].0 <= bridged { - tier += 1; - } - required = required.max((tiers[tier].1 + delta).saturating_sub(j)); - if max_confirmations.saturating_sub(j + 1) <= required { - break; + for limit in &limits { + while j <= limit.max_confirmations && bridged(j) < limit.allowed_total { + j += 1; } + required = required.max((limit.max_confirmations + 1).saturating_sub(j)); } required } @@ -211,56 +229,28 @@ impl Contract { amount: u128, delta: u64, ) -> bool { - // Each tier says how much we are willing to risk on blocks that have - // not yet reached its confirmation count. Tiers `3 -> 10_000`, - // `10 -> 100_000`, `25 -> 1_000_000` read as: - // - blocks with fewer than 3 confirmations must hold nothing; - // - blocks with fewer than 10 confirmations must hold less than - // 10_000 in total; - // - blocks with fewer than 25 confirmations must hold less than - // 100_000 in total; - // - with 25 confirmations and more, any amount goes (the top tier is - // a fallback). - let tiers = self.internal_config().sorted_confirmations_tiers(); let depth = confirmations(block_height, tip_height); // `sums.recorded_from(height)` tells how much in total was bridged // from `height` up to the tip inclusive. let sums = self.data().block_bridge_amounts.prefix_sums(); - // Check that the new amount satisfies every such limit. Each limit - // comes from a pair of adjacent tiers: with `3 -> 10_000` followed by - // `10 -> 100_000`, the blocks with at most 9 confirmations must hold, - // together with the new `amount`, less than 10_000 — the bound of the - // previous tier, which is why `prev_bound` is carried along. - let mut prev_bound = 0; - for (bound, confirmations) in &tiers { - // The confirmations this tier actually demands: a non-zero - // `delta` shifts the whole ladder, so with `delta = 2` the - // example above acts as `5 -> 10_000`, `12 -> 100_000`, - // `27 -> 1_000_000`. - let required = confirmations + delta; - // A tier limits the blocks with at most `required - 1` - // confirmations, so a block with `depth >= required` has outgrown - // it: a limit on up to 9 confirmations (`required == 10`) says - // nothing about a block that already has 15. - if required > depth { - // Everything the tier limits adds up in the prefix sum of its - // deepest block — the one with `required - 1` confirmations. - // E.g. when the new amount lands on the block with 4 - // confirmations and the limit allows 10_000 on blocks with up - // to 9 confirmations, we take the total recorded from the - // block with 9 confirmations and add the new amount to it. - let window_low = height_with_confirmations(required - 1, tip_height); - if sums.recorded_from(window_low).saturating_add(amount) >= prev_bound { - return false; - } - } - prev_bound = *bound; - } - true - } - - fn max_confirmations_window(&self, delta: u64) -> u64 { - u64::from(self.internal_config().max_tier_confirmations()) + delta + self.internal_config() + .risk_limits(delta) + .iter() + .all(|limit| { + // A block with more confirmations than the limit covers has + // outgrown it. Otherwise everything the limit covers adds up + // in the prefix sum of its deepest block — the one with + // exactly `max_confirmations` confirmations — and that total + // plus the new amount must stay within the allowance. + depth > limit.max_confirmations + || sums + .recorded_from(height_with_confirmations( + limit.max_confirmations, + tip_height, + )) + .saturating_add(amount) + < limit.allowed_total + }) } } diff --git a/contracts/satoshi-bridge/src/config.rs b/contracts/satoshi-bridge/src/config.rs index aac1265..1241467 100644 --- a/contracts/satoshi-bridge/src/config.rs +++ b/contracts/satoshi-bridge/src/config.rs @@ -120,6 +120,14 @@ pub struct Config { pub expiry_height_gap: u32, } +/// Blocks with at most `max_confirmations` confirmations must hold less than +/// `allowed_total` in total. +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug, PartialEq, Eq))] +pub struct RiskLimit { + pub max_confirmations: u64, + pub allowed_total: u128, +} + impl Config { pub fn assert_valid(&self) { let confirmations_valid_range = 2..=100; @@ -227,6 +235,23 @@ impl Config { tiers } + /// The confirmations strategy read as risk limits, shifted by `delta`: + /// tiers `3 -> 10_000`, `10 -> 100_000`, `25 -> 1_000_000` become + /// `(2, 0)`, `(9, 10_000)`, `(24, 100_000)`. The bound of the top tier + /// is the fallback for any larger amount and limits nothing. + pub fn risk_limits(&self, delta: u64) -> Vec { + let tiers = self.sorted_confirmations_tiers(); + let allowed_totals = std::iter::once(0).chain(tiers.iter().map(|(bound, _)| *bound)); + tiers + .iter() + .zip(allowed_totals) + .map(|((_, confirmations), allowed_total)| RiskLimit { + max_confirmations: (confirmations + delta).saturating_sub(1), + allowed_total, + }) + .collect() + } + pub fn tier_confirmations(tiers: &[(u128, u64)], satoshi_amount: u128) -> u64 { tiers .iter() @@ -508,6 +533,40 @@ mod tests { assert_eq!(config.get_confirmations(5_000), 3); } + #[test] + fn test_risk_limits_shift_tiers_by_one_and_by_delta() { + let mut unit_env = init_unit_env(); + testing_env!(unit_env + .context + .predecessor_account_id(owner_id()) + .attached_deposit(NearToken::from_yoctonear(1)) + .build()); + + unit_env + .contract + .set_confirmations_strategy(U128(10_000_000), 25); + unit_env + .contract + .set_confirmations_strategy(U128(10_000), 3); + unit_env + .contract + .set_confirmations_strategy(U128(100_000), 10); + + let config = unit_env.contract.internal_config(); + let limit = |max_confirmations, allowed_total| RiskLimit { + max_confirmations, + allowed_total, + }; + assert_eq!( + config.risk_limits(0), + vec![limit(2, 0), limit(9, 10_000), limit(24, 100_000)] + ); + assert_eq!( + config.risk_limits(2), + vec![limit(4, 0), limit(11, 10_000), limit(26, 100_000)] + ); + } + // Regression: a Zcash unified address with no transparent receiver (shielded-only, // e.g. Sapling+Orchard) has no scriptPubKey. `string_to_script_pubkey` panics on it // ("Failed to get script pubkey: No receiver found in address"), which is what broke