1use super::error::{ParalockError, ParalockErrorCode};
2use crate::jsnum;
3use crate::zcash::FUNDING_EXPIRY_DELTA;
4
5#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct TimeoutPolicyParams {
8 pub zcash_lock_blocks: u64,
10 pub evm_lock_seconds: u64,
12 pub zcash_block_seconds: u64,
14 pub min_zcash_confirmations: u64,
16 pub safety_factor: f64,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct TimeoutPolicy {
28 zcash_lock_blocks: u64,
29 evm_lock_seconds: u64,
30 zcash_block_seconds: u64,
31 min_zcash_confirmations: u64,
32 safety_factor: f64,
33}
34
35pub const MAINNET_POLICY: TimeoutPolicy = TimeoutPolicy {
37 zcash_lock_blocks: 144,
38 evm_lock_seconds: 3600,
39 zcash_block_seconds: 75,
40 min_zcash_confirmations: 2,
41 safety_factor: 2.0,
42};
43
44pub const DEVNET_POLICY: TimeoutPolicy = TimeoutPolicy {
46 zcash_lock_blocks: 40,
47 evm_lock_seconds: 45,
48 zcash_block_seconds: 3,
49 min_zcash_confirmations: 1,
50 safety_factor: 2.0,
51};
52
53pub const FUNDING_EXPIRY_MARGIN_BLOCKS: u32 = 2;
56
57pub const FUNDING_BROADCAST_MARGIN_BLOCKS: u32 = 5;
60
61pub const MAX_FUNDING_EXPIRY_BLOCKS: u32 = FUNDING_EXPIRY_DELTA + 5;
64
65const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
66
67fn unsafe_timeouts(message: String) -> ParalockError {
68 ParalockError::new(ParalockErrorCode::UnsafeTimeouts, message)
69}
70
71impl TimeoutPolicy {
72 pub fn new(params: TimeoutPolicyParams) -> Result<Self, ParalockError> {
76 let positive = [
77 params.zcash_lock_blocks,
78 params.evm_lock_seconds,
79 params.zcash_block_seconds,
80 params.min_zcash_confirmations,
81 ];
82 #[allow(clippy::neg_cmp_op_on_partial_ord)]
84 let bad_factor = !(params.safety_factor >= 1.0);
85 if !positive.iter().all(|&n| n > 0 && n <= MAX_SAFE_INTEGER) || bad_factor {
86 return Err(unsafe_timeouts(
87 "timeout policy values must be positive integers and safetyFactor \u{2265} 1".to_owned(),
88 ));
89 }
90 let policy = Self {
91 zcash_lock_blocks: params.zcash_lock_blocks,
92 evm_lock_seconds: params.evm_lock_seconds,
93 zcash_block_seconds: params.zcash_block_seconds,
94 min_zcash_confirmations: params.min_zcash_confirmations,
95 safety_factor: params.safety_factor,
96 };
97 window_rule(&policy, policy.zcash_lock_blocks as f64, policy.evm_lock_seconds)?;
99 Ok(policy)
100 }
101
102 pub const fn zcash_lock_blocks(&self) -> u64 {
104 self.zcash_lock_blocks
105 }
106
107 pub const fn evm_lock_seconds(&self) -> u64 {
109 self.evm_lock_seconds
110 }
111
112 pub const fn zcash_block_seconds(&self) -> u64 {
114 self.zcash_block_seconds
115 }
116
117 pub const fn min_zcash_confirmations(&self) -> u64 {
119 self.min_zcash_confirmations
120 }
121
122 pub const fn safety_factor(&self) -> f64 {
124 self.safety_factor
125 }
126
127 pub const fn params(&self) -> TimeoutPolicyParams {
129 TimeoutPolicyParams {
130 zcash_lock_blocks: self.zcash_lock_blocks,
131 evm_lock_seconds: self.evm_lock_seconds,
132 zcash_block_seconds: self.zcash_block_seconds,
133 min_zcash_confirmations: self.min_zcash_confirmations,
134 safety_factor: self.safety_factor,
135 }
136 }
137}
138
139impl TryFrom<TimeoutPolicyParams> for TimeoutPolicy {
140 type Error = ParalockError;
141
142 fn try_from(params: TimeoutPolicyParams) -> Result<Self, Self::Error> {
143 Self::new(params)
144 }
145}
146
147fn window_rule(policy: &TimeoutPolicy, blocks: f64, evm_lock_seconds: u64) -> Result<(), ParalockError> {
149 let zcash_seconds = blocks * policy.zcash_block_seconds as f64;
150 let required = evm_lock_seconds as f64 * policy.safety_factor;
151 #[allow(clippy::neg_cmp_op_on_partial_ord)]
153 let unsafe_window = !(zcash_seconds >= required);
154 if unsafe_window {
155 return Err(unsafe_timeouts(format!(
156 "unsafe timeouts: Zcash refund in ~{}s must be at least {}x the EVM lock ({}s)",
157 jsnum::format(zcash_seconds),
158 jsnum::format(policy.safety_factor),
159 evm_lock_seconds
160 )));
161 }
162 Ok(())
163}
164
165pub fn assert_safe_windows(
170 policy: &TimeoutPolicy,
171 refund_height: u32,
172 tip: u32,
173 evm_lock_seconds: u64,
174) -> Result<(), ParalockError> {
175 let blocks = i64::from(refund_height) - i64::from(tip);
176 window_rule(policy, blocks as f64, evm_lock_seconds)
177}
178
179pub fn assert_safe_to_lock(
184 policy: &TimeoutPolicy,
185 refund_height: u32,
186 tip: u32,
187 expiry: u64,
188 now_evm: u64,
189 min_evm_lock_seconds_left: u64,
190) -> Result<(), ParalockError> {
191 let remaining = i128::from(expiry) - i128::from(now_evm);
192 if remaining < i128::from(min_evm_lock_seconds_left) {
193 return Err(unsafe_timeouts(format!("the EVM lock would have only {remaining}s left")));
194 }
195 let remaining = u64::try_from(remaining).unwrap_or(u64::MAX);
197 assert_safe_windows(policy, refund_height, tip, remaining)
198}
199
200fn check_funding_expiry(height: u32, tip: u32, max_blocks: u32) -> Result<(), ParalockError> {
201 if height <= tip {
202 return Err(ParalockError::invalid_request(format!(
203 "fundingExpiryHeight {height} is not above the tip ({tip})"
204 )));
205 }
206 if u64::from(height) > u64::from(tip) + u64::from(max_blocks) {
207 return Err(ParalockError::invalid_request(format!(
208 "fundingExpiryHeight must be at most {max_blocks} blocks above the tip ({tip})"
209 )));
210 }
211 Ok(())
212}
213
214pub fn assert_required_funding_expiry(
219 funding_expiry_height: Option<u32>,
220 tip: u32,
221 max_blocks: u32,
222) -> Result<(), ParalockError> {
223 let Some(height) = funding_expiry_height else {
224 return Err(ParalockError::invalid_request(
225 "fundingExpiryHeight is required in private mode (update the taker to @paralock/sdk 0.3 or later)",
226 ));
227 };
228 check_funding_expiry(height, tip, max_blocks)
229}
230
231pub fn assert_optional_funding_expiry(
235 funding_expiry_height: Option<u32>,
236 tip: u32,
237 max_blocks: u32,
238) -> Result<(), ParalockError> {
239 match funding_expiry_height {
240 Some(height) => check_funding_expiry(height, tip, max_blocks),
241 None => Ok(()),
242 }
243}
244
245pub const fn derived_funding_deadline(tip: u32, max_blocks: u32) -> u32 {
249 tip.saturating_add(max_blocks)
250}
251
252pub fn assert_refund_window(refund_height: u32, tip: u32, policy: &TimeoutPolicy) -> Result<(), ParalockError> {
256 let window = i64::from(refund_height) - i64::from(tip);
257 if i128::from(window) > i128::from(policy.zcash_lock_blocks) * 2 {
258 return Err(ParalockError::invalid_request("refund height is too far away"));
259 }
260 Ok(())
261}
262
263pub fn assert_funding_broadcastable(expiry_height: u32, tip: u32) -> Result<(), ParalockError> {
266 if u64::from(tip) + u64::from(FUNDING_BROADCAST_MARGIN_BLOCKS) > u64::from(expiry_height) {
267 return Err(ParalockError::new(
268 ParalockErrorCode::Timeout,
269 format!("the funding expires at block {expiry_height} (tip {tip}); not broadcasting it"),
270 ));
271 }
272 Ok(())
273}
274
275pub fn funding_past_expiry(expiry_height: u32, tip: u32) -> bool {
279 u64::from(tip) > u64::from(expiry_height) + u64::from(FUNDING_EXPIRY_MARGIN_BLOCKS)
280}
281
282#[cfg(test)]
283mod tests {
284 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
285 use super::*;
286
287 fn params(p: &TimeoutPolicy) -> TimeoutPolicyParams {
288 p.params()
289 }
290
291 #[test]
292 fn the_default_policies_are_valid() {
293 assert_eq!(TimeoutPolicy::new(MAINNET_POLICY.params()).unwrap(), MAINNET_POLICY);
294 assert_eq!(TimeoutPolicy::new(DEVNET_POLICY.params()).unwrap(), DEVNET_POLICY);
295 }
296
297 #[test]
298 fn rejects_unsafe_windows() {
299 let tip = 3_000_000;
300 assert!(assert_safe_windows(&DEVNET_POLICY, tip + 40, tip, 45).is_ok());
302 assert!(assert_safe_windows(&DEVNET_POLICY, tip + 30, tip, 45).is_ok());
303 let err = assert_safe_windows(&DEVNET_POLICY, tip + 29, tip, 45).unwrap_err();
304 assert_eq!(err.code, ParalockErrorCode::UnsafeTimeouts);
305 assert_eq!(err.message, "unsafe timeouts: Zcash refund in ~87s must be at least 2x the EVM lock (45s)");
306 assert!(assert_safe_windows(&DEVNET_POLICY, tip - 5, tip, 45).is_err());
307 assert!(assert_safe_windows(&MAINNET_POLICY, 3_000_000, 3_000_100, 3600).is_err());
309 assert!(assert_safe_windows(&MAINNET_POLICY, 0, u32::MAX, 0).is_err());
310 let bad = |p: TimeoutPolicyParams| TimeoutPolicy::new(p).is_err();
311 assert!(bad(TimeoutPolicyParams { safety_factor: f64::NAN, ..params(&MAINNET_POLICY) }));
312 assert!(bad(TimeoutPolicyParams { safety_factor: f64::INFINITY, ..params(&MAINNET_POLICY) }));
313 assert!(bad(TimeoutPolicyParams { safety_factor: 0.5, ..params(&MAINNET_POLICY) }));
314 assert!(bad(TimeoutPolicyParams { safety_factor: -1.0, ..params(&MAINNET_POLICY) }));
315 assert!(bad(TimeoutPolicyParams { zcash_lock_blocks: 0, ..params(&MAINNET_POLICY) }));
316 assert!(bad(TimeoutPolicyParams { zcash_lock_blocks: 47, ..params(&MAINNET_POLICY) }));
317 assert!(bad(TimeoutPolicyParams { zcash_block_seconds: MAX_SAFE_INTEGER + 1, ..params(&MAINNET_POLICY) }));
318 }
319
320 #[test]
321 fn rechecks_the_window_right_before_locking() {
322 let tip = 3_000_000;
323 let refund = tip + 144;
324 assert!(assert_safe_to_lock(&MAINNET_POLICY, refund, tip, 10_003_600, 10_000_000, 30).is_ok());
325 let err = assert_safe_to_lock(&MAINNET_POLICY, refund, tip, 1_000, 971, 30).unwrap_err();
326 assert_eq!(err.message, "the EVM lock would have only 29s left");
327 assert!(assert_safe_to_lock(&MAINNET_POLICY, refund, tip, 1_000, 2_000, 30).is_err());
329 assert_eq!(
331 assert_safe_to_lock(&MAINNET_POLICY, tip + 10, tip, 4_600, 1_000, 30).unwrap_err().code,
332 ParalockErrorCode::UnsafeTimeouts
333 );
334 }
335
336 #[test]
337 fn funding_expiry_rules() {
338 assert!(assert_funding_broadcastable(3_000_040, 3_000_035).is_ok());
339 let err = assert_funding_broadcastable(3_000_040, 3_000_036).unwrap_err();
340 assert_eq!(err.code, ParalockErrorCode::Timeout);
341 assert_eq!(err.message, "the funding expires at block 3000040 (tip 3000036); not broadcasting it");
342 assert!(!funding_past_expiry(3_000_040, 3_000_042));
343 assert!(funding_past_expiry(3_000_040, 3_000_043));
344 assert_eq!(derived_funding_deadline(100, 45), 145);
345 assert_eq!(derived_funding_deadline(u32::MAX, 45), u32::MAX);
346 }
347
348 #[test]
349 fn bounds_the_refund_window() {
350 assert!(assert_refund_window(3_000_288, 3_000_000, &MAINNET_POLICY).is_ok());
351 assert_eq!(
352 assert_refund_window(3_000_289, 3_000_000, &MAINNET_POLICY).unwrap_err().message,
353 "refund height is too far away"
354 );
355 assert!(assert_refund_window(10, 3_000_000, &MAINNET_POLICY).is_ok());
356 }
357
358 #[test]
359 fn bounds_the_funding_expiry() {
360 for check in [assert_required_funding_expiry, assert_optional_funding_expiry] {
361 assert!(check(Some(140), 100, 45).is_ok());
362 assert!(check(Some(145), 100, 45).is_ok());
363 assert_eq!(
364 check(Some(146), 100, 45).unwrap_err().message,
365 "fundingExpiryHeight must be at most 45 blocks above the tip (100)"
366 );
367 assert_eq!(
368 check(Some(100), 100, 45).unwrap_err().message,
369 "fundingExpiryHeight 100 is not above the tip (100)"
370 );
371 }
372 assert!(assert_optional_funding_expiry(None, 100, 45).is_ok());
374 let err = assert_required_funding_expiry(None, 100, 45).unwrap_err();
375 assert_eq!(err.code, ParalockErrorCode::InvalidRequest);
376 assert!(err.message.starts_with("fundingExpiryHeight is required in private mode"));
377 }
378}