Skip to main content

paralock_core/protocol/
policy.rs

1use super::error::{ParalockError, ParalockErrorCode};
2use crate::jsnum;
3use crate::zcash::FUNDING_EXPIRY_DELTA;
4
5/// The values of a [`TimeoutPolicy`], before validation.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct TimeoutPolicyParams {
8    /// Blocks between funding and the taker's Zcash refund height.
9    pub zcash_lock_blocks: u64,
10    /// Seconds the maker's EVM lock stays claimable by the taker.
11    pub evm_lock_seconds: u64,
12    /// Expected Zcash block interval, used to compare the two clocks.
13    pub zcash_block_seconds: u64,
14    /// Confirmations the maker waits for on the Zcash funding transaction (and on its own claim).
15    pub min_zcash_confirmations: u64,
16    /// The remaining Zcash window must be at least this many times longer than the EVM lock (≥ 1).
17    pub safety_factor: f64,
18}
19
20/// Timeout policy. Safety rule: the EVM lock (the maker's money) must expire well before the Zcash refund height (the
21/// taker's money), so whoever learns the secret always has time to use it on the other chain.
22///
23/// The fields are private: a policy comes from [`MAINNET_POLICY`], [`DEVNET_POLICY`] or [`TimeoutPolicy::new`], which
24/// validates it (`assertValidPolicy`), so every `TimeoutPolicy` is coherent and the window rule can't be weakened by
25/// a zero, negative, NaN or below-1 safety factor.
26#[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
35/// Production defaults: ~3 h Zcash window at 75 s blocks, 1 h EVM lock, 2 confirmations.
36pub 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
44/// Short windows so refunds can be demoed; the devnet mines a Zcash block every few seconds.
45pub 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
53/// Blocks past a funding's expiry height after which an unmined funding counts as dead (it can never be mined; the
54/// margin absorbs indexer lag and short reorgs). The maker then drops its reservation and the taker abandons the swap.
55pub const FUNDING_EXPIRY_MARGIN_BLOCKS: u32 = 2;
56
57/// The taker broadcasts a funding only while the tip is at least this many blocks below the funding's expiry height,
58/// so it can still be mined (nodes refuse transactions that expire within 3 blocks of the tip).
59pub const FUNDING_BROADCAST_MARGIN_BLOCKS: u32 = 5;
60
61/// Default furthest a taker's declared `fundingExpiryHeight` may be above the maker's tip when it commits: the SDK's
62/// taker uses 40 (`FUNDING_EXPIRY_DELTA`), plus 5 blocks for a maker node that lags the taker's.
63pub 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    /// A validated policy (`assertValidPolicy`): the four integers must be positive safe integers, `safety_factor ≥ 1`
73    /// (not NaN), and the policy's own windows must pass the window rule. Fails with `unsafe_timeouts` and the
74    /// reference's messages.
75    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        // `!(x >= 1)` is also true for NaN.
83        #[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        // The integers are at most 2^53 − 1, so they are exact as f64.
98        window_rule(&policy, policy.zcash_lock_blocks as f64, policy.evm_lock_seconds)?;
99        Ok(policy)
100    }
101
102    /// Blocks between funding and the taker's Zcash refund height.
103    pub const fn zcash_lock_blocks(&self) -> u64 {
104        self.zcash_lock_blocks
105    }
106
107    /// Seconds the maker's EVM lock stays claimable by the taker.
108    pub const fn evm_lock_seconds(&self) -> u64 {
109        self.evm_lock_seconds
110    }
111
112    /// Expected Zcash block interval, in seconds.
113    pub const fn zcash_block_seconds(&self) -> u64 {
114        self.zcash_block_seconds
115    }
116
117    /// Confirmations the maker waits for on the Zcash funding transaction (and on its own claim).
118    pub const fn min_zcash_confirmations(&self) -> u64 {
119        self.min_zcash_confirmations
120    }
121
122    /// The required ratio of remaining Zcash time to EVM lock time (≥ 1).
123    pub const fn safety_factor(&self) -> f64 {
124        self.safety_factor
125    }
126
127    /// The policy's values.
128    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
147/// `blocks × zcash_block_seconds ≥ evm_lock_seconds × safety_factor`, in `f64` exactly like the reference.
148fn 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    // `!(a >= b)` rejects NaN as well, matching `a < b` in JavaScript for the finite case.
152    #[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
165/// The window rule (invariant 1, `assertSafeWindows`): fails with `unsafe_timeouts` unless the Zcash time left before
166/// `refund_height` comfortably outlasts the EVM lock: `(refund_height − tip) × zcash_block_seconds ≥
167/// evm_lock_seconds × safety_factor`. Equality is safe. Taking both heights (instead of a precomputed difference)
168/// means a refund height at or below the tip is simply unsafe: nothing can wrap around.
169pub 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
179/// The maker's last check right before it sends its EVM lock (spec §7.2): the lock must still have at least
180/// `min_evm_lock_seconds_left` of EVM time (`expiry − now_evm`, the maker limit `minEvmLockSecondsLeft`), and the
181/// window rule must hold with the time actually left. `now_evm` is the EVM chain's latest block time. Fails with
182/// `unsafe_timeouts`; computed without wraparound (an expiry in the past is simply too little time).
183pub 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    // `remaining` is in `[min_left, u64::MAX]` here.
196    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
214/// The maker's check of an **adaptor proposal's** funding expiry, which spec §6.5 step 7 requires: absent fails
215/// (`invalid_request`, only v1.0 takers omit it and they could fund but never claim); otherwise it must be above the
216/// tip (so the funding can still be mined) and at most `max_blocks` ahead (so an unfunded reservation can't hold
217/// inventory for long).
218pub 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
231/// The maker's check of an **htlc accept's** optional funding expiry (spec §6.5 step 7): when present, the bounds of
232/// [`assert_required_funding_expiry`]; when absent (a v1.0 taker, which funds before accepting) it passes, and the
233/// maker must then hold the reservation only until [`derived_funding_deadline`] (spec §7.5).
234pub 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
245/// The deadline a maker derives for an htlc accept without `fundingExpiryHeight` (spec §7.5): `tip + max_blocks`
246/// (tip at the accept). The maker drops the reservation once `tip > deadline + FUNDING_EXPIRY_MARGIN_BLOCKS` if it
247/// has never seen the funding.
248pub const fn derived_funding_deadline(tip: u32, max_blocks: u32) -> u32 {
249    tip.saturating_add(max_blocks)
250}
251
252/// The maker's upper bound on a lock's refund window (`refundHeight − tip ≤ 2 × zcashLockBlocks`), so a taker can't
253/// hold a reservation open with a refund height far in the future: `invalid_request` ("refund height is too far
254/// away") otherwise. Applies to adaptor proposals and htlc accepts (protocol v1.1).
255pub 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
263/// The taker broadcasts its funding only while `tip + FUNDING_BROADCAST_MARGIN_BLOCKS ≤ expiry_height`, so it can
264/// still be mined (nodes refuse transactions that expire within 3 blocks of the tip). Fails with `timeout`.
265pub 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
275/// True once a funding with this expiry height can no longer be mined, with the reorg/indexer margin:
276/// `tip > expiry_height + FUNDING_EXPIRY_MARGIN_BLOCKS`. Treat the funding as dead only if, in addition, the node
277/// confirms it was never mined (both parties then drop the swap).
278pub 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        // 40 blocks × 3 s = 120 s ≥ 2 × 45 s.
301        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        // A refund height below the tip is unsafe, never a huge wrapped-around window.
308        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        // An expiry in the past is too little time, not 2^64 seconds.
328        assert!(assert_safe_to_lock(&MAINNET_POLICY, refund, tip, 1_000, 2_000, 30).is_err());
329        // Enough EVM time, but too little Zcash time for it.
330        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        // Absent: fine for an htlc accept (v1.0 taker), refused for an adaptor proposal.
373        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}