use super::error::{ParalockError, ParalockErrorCode};
use crate::jsnum;
use crate::zcash::FUNDING_EXPIRY_DELTA;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TimeoutPolicyParams {
pub zcash_lock_blocks: u64,
pub evm_lock_seconds: u64,
pub zcash_block_seconds: u64,
pub min_zcash_confirmations: u64,
pub safety_factor: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TimeoutPolicy {
zcash_lock_blocks: u64,
evm_lock_seconds: u64,
zcash_block_seconds: u64,
min_zcash_confirmations: u64,
safety_factor: f64,
}
pub const MAINNET_POLICY: TimeoutPolicy = TimeoutPolicy {
zcash_lock_blocks: 144,
evm_lock_seconds: 3600,
zcash_block_seconds: 75,
min_zcash_confirmations: 2,
safety_factor: 2.0,
};
pub const DEVNET_POLICY: TimeoutPolicy = TimeoutPolicy {
zcash_lock_blocks: 40,
evm_lock_seconds: 45,
zcash_block_seconds: 3,
min_zcash_confirmations: 1,
safety_factor: 2.0,
};
pub const FUNDING_EXPIRY_MARGIN_BLOCKS: u32 = 2;
pub const FUNDING_BROADCAST_MARGIN_BLOCKS: u32 = 5;
pub const MAX_FUNDING_EXPIRY_BLOCKS: u32 = FUNDING_EXPIRY_DELTA + 5;
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
fn unsafe_timeouts(message: String) -> ParalockError {
ParalockError::new(ParalockErrorCode::UnsafeTimeouts, message)
}
impl TimeoutPolicy {
pub fn new(params: TimeoutPolicyParams) -> Result<Self, ParalockError> {
let positive = [
params.zcash_lock_blocks,
params.evm_lock_seconds,
params.zcash_block_seconds,
params.min_zcash_confirmations,
];
#[allow(clippy::neg_cmp_op_on_partial_ord)]
let bad_factor = !(params.safety_factor >= 1.0);
if !positive.iter().all(|&n| n > 0 && n <= MAX_SAFE_INTEGER) || bad_factor {
return Err(unsafe_timeouts(
"timeout policy values must be positive integers and safetyFactor \u{2265} 1".to_owned(),
));
}
let policy = Self {
zcash_lock_blocks: params.zcash_lock_blocks,
evm_lock_seconds: params.evm_lock_seconds,
zcash_block_seconds: params.zcash_block_seconds,
min_zcash_confirmations: params.min_zcash_confirmations,
safety_factor: params.safety_factor,
};
window_rule(&policy, policy.zcash_lock_blocks as f64, policy.evm_lock_seconds)?;
Ok(policy)
}
pub const fn zcash_lock_blocks(&self) -> u64 {
self.zcash_lock_blocks
}
pub const fn evm_lock_seconds(&self) -> u64 {
self.evm_lock_seconds
}
pub const fn zcash_block_seconds(&self) -> u64 {
self.zcash_block_seconds
}
pub const fn min_zcash_confirmations(&self) -> u64 {
self.min_zcash_confirmations
}
pub const fn safety_factor(&self) -> f64 {
self.safety_factor
}
pub const fn params(&self) -> TimeoutPolicyParams {
TimeoutPolicyParams {
zcash_lock_blocks: self.zcash_lock_blocks,
evm_lock_seconds: self.evm_lock_seconds,
zcash_block_seconds: self.zcash_block_seconds,
min_zcash_confirmations: self.min_zcash_confirmations,
safety_factor: self.safety_factor,
}
}
}
impl TryFrom<TimeoutPolicyParams> for TimeoutPolicy {
type Error = ParalockError;
fn try_from(params: TimeoutPolicyParams) -> Result<Self, Self::Error> {
Self::new(params)
}
}
fn window_rule(policy: &TimeoutPolicy, blocks: f64, evm_lock_seconds: u64) -> Result<(), ParalockError> {
let zcash_seconds = blocks * policy.zcash_block_seconds as f64;
let required = evm_lock_seconds as f64 * policy.safety_factor;
#[allow(clippy::neg_cmp_op_on_partial_ord)]
let unsafe_window = !(zcash_seconds >= required);
if unsafe_window {
return Err(unsafe_timeouts(format!(
"unsafe timeouts: Zcash refund in ~{}s must be at least {}x the EVM lock ({}s)",
jsnum::format(zcash_seconds),
jsnum::format(policy.safety_factor),
evm_lock_seconds
)));
}
Ok(())
}
pub fn assert_safe_windows(
policy: &TimeoutPolicy,
refund_height: u32,
tip: u32,
evm_lock_seconds: u64,
) -> Result<(), ParalockError> {
let blocks = i64::from(refund_height) - i64::from(tip);
window_rule(policy, blocks as f64, evm_lock_seconds)
}
pub fn assert_safe_to_lock(
policy: &TimeoutPolicy,
refund_height: u32,
tip: u32,
expiry: u64,
now_evm: u64,
min_evm_lock_seconds_left: u64,
) -> Result<(), ParalockError> {
let remaining = i128::from(expiry) - i128::from(now_evm);
if remaining < i128::from(min_evm_lock_seconds_left) {
return Err(unsafe_timeouts(format!("the EVM lock would have only {remaining}s left")));
}
let remaining = u64::try_from(remaining).unwrap_or(u64::MAX);
assert_safe_windows(policy, refund_height, tip, remaining)
}
fn check_funding_expiry(height: u32, tip: u32, max_blocks: u32) -> Result<(), ParalockError> {
if height <= tip {
return Err(ParalockError::invalid_request(format!(
"fundingExpiryHeight {height} is not above the tip ({tip})"
)));
}
if u64::from(height) > u64::from(tip) + u64::from(max_blocks) {
return Err(ParalockError::invalid_request(format!(
"fundingExpiryHeight must be at most {max_blocks} blocks above the tip ({tip})"
)));
}
Ok(())
}
pub fn assert_required_funding_expiry(
funding_expiry_height: Option<u32>,
tip: u32,
max_blocks: u32,
) -> Result<(), ParalockError> {
let Some(height) = funding_expiry_height else {
return Err(ParalockError::invalid_request(
"fundingExpiryHeight is required in private mode (update the taker to @paralock/sdk 0.3 or later)",
));
};
check_funding_expiry(height, tip, max_blocks)
}
pub fn assert_optional_funding_expiry(
funding_expiry_height: Option<u32>,
tip: u32,
max_blocks: u32,
) -> Result<(), ParalockError> {
match funding_expiry_height {
Some(height) => check_funding_expiry(height, tip, max_blocks),
None => Ok(()),
}
}
pub const fn derived_funding_deadline(tip: u32, max_blocks: u32) -> u32 {
tip.saturating_add(max_blocks)
}
pub fn assert_refund_window(refund_height: u32, tip: u32, policy: &TimeoutPolicy) -> Result<(), ParalockError> {
let window = i64::from(refund_height) - i64::from(tip);
if i128::from(window) > i128::from(policy.zcash_lock_blocks) * 2 {
return Err(ParalockError::invalid_request("refund height is too far away"));
}
Ok(())
}
pub fn assert_funding_broadcastable(expiry_height: u32, tip: u32) -> Result<(), ParalockError> {
if u64::from(tip) + u64::from(FUNDING_BROADCAST_MARGIN_BLOCKS) > u64::from(expiry_height) {
return Err(ParalockError::new(
ParalockErrorCode::Timeout,
format!("the funding expires at block {expiry_height} (tip {tip}); not broadcasting it"),
));
}
Ok(())
}
pub fn funding_past_expiry(expiry_height: u32, tip: u32) -> bool {
u64::from(tip) > u64::from(expiry_height) + u64::from(FUNDING_EXPIRY_MARGIN_BLOCKS)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn params(p: &TimeoutPolicy) -> TimeoutPolicyParams {
p.params()
}
#[test]
fn the_default_policies_are_valid() {
assert_eq!(TimeoutPolicy::new(MAINNET_POLICY.params()).unwrap(), MAINNET_POLICY);
assert_eq!(TimeoutPolicy::new(DEVNET_POLICY.params()).unwrap(), DEVNET_POLICY);
}
#[test]
fn rejects_unsafe_windows() {
let tip = 3_000_000;
assert!(assert_safe_windows(&DEVNET_POLICY, tip + 40, tip, 45).is_ok());
assert!(assert_safe_windows(&DEVNET_POLICY, tip + 30, tip, 45).is_ok());
let err = assert_safe_windows(&DEVNET_POLICY, tip + 29, tip, 45).unwrap_err();
assert_eq!(err.code, ParalockErrorCode::UnsafeTimeouts);
assert_eq!(err.message, "unsafe timeouts: Zcash refund in ~87s must be at least 2x the EVM lock (45s)");
assert!(assert_safe_windows(&DEVNET_POLICY, tip - 5, tip, 45).is_err());
assert!(assert_safe_windows(&MAINNET_POLICY, 3_000_000, 3_000_100, 3600).is_err());
assert!(assert_safe_windows(&MAINNET_POLICY, 0, u32::MAX, 0).is_err());
let bad = |p: TimeoutPolicyParams| TimeoutPolicy::new(p).is_err();
assert!(bad(TimeoutPolicyParams { safety_factor: f64::NAN, ..params(&MAINNET_POLICY) }));
assert!(bad(TimeoutPolicyParams { safety_factor: f64::INFINITY, ..params(&MAINNET_POLICY) }));
assert!(bad(TimeoutPolicyParams { safety_factor: 0.5, ..params(&MAINNET_POLICY) }));
assert!(bad(TimeoutPolicyParams { safety_factor: -1.0, ..params(&MAINNET_POLICY) }));
assert!(bad(TimeoutPolicyParams { zcash_lock_blocks: 0, ..params(&MAINNET_POLICY) }));
assert!(bad(TimeoutPolicyParams { zcash_lock_blocks: 47, ..params(&MAINNET_POLICY) }));
assert!(bad(TimeoutPolicyParams { zcash_block_seconds: MAX_SAFE_INTEGER + 1, ..params(&MAINNET_POLICY) }));
}
#[test]
fn rechecks_the_window_right_before_locking() {
let tip = 3_000_000;
let refund = tip + 144;
assert!(assert_safe_to_lock(&MAINNET_POLICY, refund, tip, 10_003_600, 10_000_000, 30).is_ok());
let err = assert_safe_to_lock(&MAINNET_POLICY, refund, tip, 1_000, 971, 30).unwrap_err();
assert_eq!(err.message, "the EVM lock would have only 29s left");
assert!(assert_safe_to_lock(&MAINNET_POLICY, refund, tip, 1_000, 2_000, 30).is_err());
assert_eq!(
assert_safe_to_lock(&MAINNET_POLICY, tip + 10, tip, 4_600, 1_000, 30).unwrap_err().code,
ParalockErrorCode::UnsafeTimeouts
);
}
#[test]
fn funding_expiry_rules() {
assert!(assert_funding_broadcastable(3_000_040, 3_000_035).is_ok());
let err = assert_funding_broadcastable(3_000_040, 3_000_036).unwrap_err();
assert_eq!(err.code, ParalockErrorCode::Timeout);
assert_eq!(err.message, "the funding expires at block 3000040 (tip 3000036); not broadcasting it");
assert!(!funding_past_expiry(3_000_040, 3_000_042));
assert!(funding_past_expiry(3_000_040, 3_000_043));
assert_eq!(derived_funding_deadline(100, 45), 145);
assert_eq!(derived_funding_deadline(u32::MAX, 45), u32::MAX);
}
#[test]
fn bounds_the_refund_window() {
assert!(assert_refund_window(3_000_288, 3_000_000, &MAINNET_POLICY).is_ok());
assert_eq!(
assert_refund_window(3_000_289, 3_000_000, &MAINNET_POLICY).unwrap_err().message,
"refund height is too far away"
);
assert!(assert_refund_window(10, 3_000_000, &MAINNET_POLICY).is_ok());
}
#[test]
fn bounds_the_funding_expiry() {
for check in [assert_required_funding_expiry, assert_optional_funding_expiry] {
assert!(check(Some(140), 100, 45).is_ok());
assert!(check(Some(145), 100, 45).is_ok());
assert_eq!(
check(Some(146), 100, 45).unwrap_err().message,
"fundingExpiryHeight must be at most 45 blocks above the tip (100)"
);
assert_eq!(
check(Some(100), 100, 45).unwrap_err().message,
"fundingExpiryHeight 100 is not above the tip (100)"
);
}
assert!(assert_optional_funding_expiry(None, 100, 45).is_ok());
let err = assert_required_funding_expiry(None, 100, 45).unwrap_err();
assert_eq!(err.code, ParalockErrorCode::InvalidRequest);
assert!(err.message.starts_with("fundingExpiryHeight is required in private mode"));
}
}