Skip to main content

paralock_core/protocol/
validation.rs

1//! Validation of everything that crosses a trust boundary: quotes the taker receives and requests the maker receives.
2//! Failures are [`ParalockError`]s whose codes and messages match the TypeScript validators. The parsers take
3//! `serde_json::Value`s and follow JavaScript's view of JSON (`5.0` is the integer 5), so a Rust maker accepts exactly
4//! what a TypeScript maker accepts.
5
6use std::collections::BTreeMap;
7
8use serde_json::{Map, Value};
9
10use super::error::{ParalockError, ParalockErrorCode};
11use super::messages::{AcceptRequest, AdaptorPreSignatureRequest, AdaptorProposal, AdaptorProposalAccepted};
12use super::pricing::{usd_units_to_zat, zat_to_usd_units};
13use super::types::{EvmChain, Hex, Quote, SwapDirection, SwapMode};
14use crate::keys::PublicKey;
15use crate::zcash::{Network, Txid, is_transparent_address};
16
17/// 21 million ZEC in zatoshi.
18pub const MAX_ZAT: u64 = 21_000_000 * 100_000_000;
19/// `Number.MAX_SAFE_INTEGER`.
20const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
21
22fn bad(message: impl Into<String>) -> ParalockError {
23    ParalockError::new(ParalockErrorCode::InvalidRequest, message)
24}
25
26fn reject(message: impl Into<String>) -> ParalockError {
27    ParalockError::new(ParalockErrorCode::QuoteRejected, message)
28}
29
30/// True for `0x`-prefixed hex with an even number of digits, optionally of exactly `bytes` bytes (`isHex`).
31pub fn is_hex(value: &str, bytes: Option<usize>) -> bool {
32    let Some(digits) = value.strip_prefix("0x") else {
33        return false;
34    };
35    if digits.len() % 2 != 0 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
36        return false;
37    }
38    bytes.is_none_or(|n| digits.len() == n * 2)
39}
40
41/// True for a 20-byte `0x` address other than the zero address, in any letter case (`isEvmAddress`). This is the
42/// wire check (the reference accepts addresses in messages in any case, without an EIP-55 checksum); parse addresses
43/// a person entered with [`crate::evm::EvmAddress::parse`], which verifies mixed-case checksums.
44pub fn is_evm_address(value: &str) -> bool {
45    is_hex(value, Some(20)) && value.bytes().skip(2).any(|b| b != b'0')
46}
47
48/// True for a known chain name (`isEvmChain`).
49pub fn is_evm_chain(value: &str) -> bool {
50    EvmChain::parse(value).is_some()
51}
52
53/// True for `htlc` or `adaptor` (`isSwapMode`).
54pub fn is_swap_mode(value: &str) -> bool {
55    SwapMode::parse(value).is_some()
56}
57
58/// True for a UUID (any version, either case): the quote id format (`isQuoteId`).
59pub fn is_quote_id(value: &str) -> bool {
60    let b = value.as_bytes();
61    b.len() == 36
62        && b.iter().enumerate().all(|(i, c)| match i {
63            8 | 13 | 18 | 23 => *c == b'-',
64            _ => c.is_ascii_hexdigit(),
65        })
66}
67
68/// JavaScript's view of a JSON value as a safe integer: `Some(n)` for an integral number with `|n| ≤ 2^53 − 1`.
69fn safe_integer(value: &Value) -> Option<i64> {
70    let Value::Number(n) = value else { return None };
71    if let Some(u) = n.as_u64() {
72        return (u <= MAX_SAFE_INTEGER).then_some(u as i64);
73    }
74    if let Some(i) = n.as_i64() {
75        return (i.unsigned_abs() <= MAX_SAFE_INTEGER).then_some(i);
76    }
77    let f = n.as_f64()?;
78    (f.is_finite() && f.fract() == 0.0 && f.abs() <= MAX_SAFE_INTEGER as f64).then_some(f as i64)
79}
80
81/// Parses an amount from a decimal string (1–30 digits) or a safe integer, and checks `min ≤ value ≤ max`
82/// (`min` defaults to 1). Rejects fractions, signs, exponents in strings and anything else (`parseAmount`).
83pub fn parse_amount(value: &Value, what: &str, min: Option<u128>, max: Option<u128>) -> Result<u128, ParalockError> {
84    let n: i128 = match value {
85        Value::String(s) if (1..=30).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_digit()) => {
86            s.parse::<i128>().map_err(|_| bad(format!("{what} must be a non-negative integer")))?
87        }
88        Value::Number(_) => {
89            safe_integer(value).map(i128::from).ok_or_else(|| bad(format!("{what} must be a non-negative integer")))?
90        }
91        _ => return Err(bad(format!("{what} must be a non-negative integer"))),
92    };
93    let min = min.unwrap_or(1);
94    if n < 0 || (n as u128) < min {
95        return Err(bad(format!("{what} must be at least {min}")));
96    }
97    if let Some(max) = max {
98        if n as u128 > max {
99            return Err(bad(format!("{what} must be at most {max}")));
100        }
101    }
102    Ok(n as u128)
103}
104
105/// Parses a non-negative safe integer (heights, indices, seconds) within `[min, max]` (defaults `[0, 2^53 − 1]`,
106/// `parseInteger`).
107pub fn parse_integer(value: &Value, what: &str, min: Option<u64>, max: Option<u64>) -> Result<u64, ParalockError> {
108    let n = safe_integer(value).ok_or_else(|| bad(format!("{what} must be an integer")))?;
109    let (min, max) = (min.unwrap_or(0), max.unwrap_or(MAX_SAFE_INTEGER));
110    if n < 0 || (n as u64) < min || (n as u64) > max {
111        return Err(bad(format!("{what} is out of range")));
112    }
113    Ok(n as u64)
114}
115
116fn parse_u32(value: &Value, what: &str, min: Option<u64>, max: u64) -> Result<u32, ParalockError> {
117    let n = parse_integer(value, what, min, Some(max))?;
118    u32::try_from(n).map_err(|_| bad(format!("{what} is out of range")))
119}
120
121fn object<'a>(value: &'a Value, what: &str) -> Result<&'a Map<String, Value>, ParalockError> {
122    value.as_object().ok_or_else(|| bad(format!("{what} must be an object")))
123}
124
125/// A field as a string, if it is one.
126fn str_field<'a>(object: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
127    object.get(key).and_then(Value::as_str)
128}
129
130fn field<'a>(object: &'a Map<String, Value>, key: &str) -> &'a Value {
131    object.get(key).unwrap_or(&Value::Null)
132}
133
134/// A non-empty string of at most `max` UTF-16 code units (JavaScript's `length`).
135fn string(object: &Map<String, Value>, key: &str, what: &str, max: usize) -> Result<String, ParalockError> {
136    match str_field(object, key) {
137        Some(s) if !s.is_empty() && s.encode_utf16().count() <= max => Ok(s.to_owned()),
138        _ => Err(bad(format!("{what} must be a string"))),
139    }
140}
141
142fn hex_field(object: &Map<String, Value>, key: &str) -> Option<Hex> {
143    str_field(object, key).and_then(|s| Hex::new(s).ok())
144}
145
146fn quote_id(object: &Map<String, Value>, key: &str, message: &str) -> Result<String, ParalockError> {
147    match str_field(object, key) {
148        Some(s) if is_quote_id(s) => Ok(s.to_owned()),
149        _ => Err(bad(message)),
150    }
151}
152
153fn txid(object: &Map<String, Value>, key: &str) -> Result<String, ParalockError> {
154    match str_field(object, key) {
155        Some(s) if Txid::is_txid(s) => Ok(s.to_ascii_lowercase()),
156        _ => Err(bad(format!("{key} must be a 64-character txid"))),
157    }
158}
159
160fn evm_address(object: &Map<String, Value>, key: &str) -> Result<Hex, ParalockError> {
161    match str_field(object, key) {
162        Some(s) if is_evm_address(s) => Hex::new(s).map_err(|_| bad(format!("{key} must be a non-zero 0x address"))),
163        _ => Err(bad(format!("{key} must be a non-zero 0x address"))),
164    }
165}
166
167/// A compressed secp256k1 point as `0x` hex, as received (letter case preserved).
168fn pubkey_as_is(object: &Map<String, Value>, key: &str) -> Result<Hex, ParalockError> {
169    let invalid = || bad(format!("{key} must be a compressed secp256k1 public key"));
170    let s = str_field(object, key).ok_or_else(invalid)?;
171    if !is_hex(s, Some(33)) {
172        return Err(invalid());
173    }
174    let hex = Hex::new(s).map_err(|_| invalid())?;
175    if !PublicKey::is_valid_compressed(&hex.to_bytes()) {
176        return Err(invalid());
177    }
178    Ok(hex)
179}
180
181/// A compressed secp256k1 point as `0x` hex, lowercased.
182fn pubkey(object: &Map<String, Value>, key: &str) -> Result<Hex, ParalockError> {
183    let invalid = || bad(format!("{key} must be a compressed secp256k1 public key"));
184    let s = str_field(object, key).ok_or_else(invalid)?;
185    if !is_hex(s, Some(33)) {
186        return Err(invalid());
187    }
188    let hex = Hex::new(s.to_ascii_lowercase()).map_err(|_| invalid())?;
189    if !PublicKey::is_valid_compressed(&hex.to_bytes()) {
190        return Err(invalid());
191    }
192    Ok(hex)
193}
194
195/// The optional `fundingExpiryHeight` (0.1 and 0.2 takers don't send it). Only its shape is checked here; the maker
196/// checks it against its tip with [`super::assert_funding_expiry`]. `null` is not "absent" and is rejected.
197fn optional_funding_expiry(object: &Map<String, Value>) -> Result<Option<u32>, ParalockError> {
198    match object.get("fundingExpiryHeight") {
199        None => Ok(None),
200        Some(value) => Ok(Some(parse_u32(value, "fundingExpiryHeight", Some(1), 499_999_999)?)),
201    }
202}
203
204// ---------------------------------------------------------------------------------------------------------------
205// Maker side: requests from takers
206
207/// Validates the htlc-mode accept request body (`parseAcceptRequest`).
208pub fn parse_accept_request(body: &Value) -> Result<AcceptRequest, ParalockError> {
209    let b = object(body, "request")?;
210    let quote_id = quote_id(b, "quoteId", "quoteId must be a quote id")?;
211    let redeem_script = match str_field(b, "redeemScript") {
212        Some(s) if is_hex(s, None) && s.len() <= 2 + 2 * 520 => {
213            Hex::new(s).map_err(|_| bad("redeemScript must be hex"))?
214        }
215        _ => return Err(bad("redeemScript must be hex")),
216    };
217    let funding_txid = txid(b, "fundingTxid")?;
218    let taker_evm_address = evm_address(b, "takerEvmAddress")?;
219    let funding_vout = parse_u32(field(b, "fundingVout"), "fundingVout", None, 10_000)?;
220    let funding_expiry_height = optional_funding_expiry(b)?;
221    Ok(AcceptRequest { quote_id, redeem_script, funding_txid, funding_vout, taker_evm_address, funding_expiry_height })
222}
223
224/// Validates an adaptor-mode proposal body against `network` (`parseAdaptorProposal`).
225pub fn parse_adaptor_proposal(body: &Value, network: Network) -> Result<AdaptorProposal, ParalockError> {
226    adaptor_proposal(body, Some(network))
227}
228
229/// [`parse_adaptor_proposal`], with the refund address checked against `network` when one is given (`None` only for
230/// `Deserialize`, which has no network: the consumers check the network again).
231pub(crate) fn adaptor_proposal(body: &Value, network: Option<Network>) -> Result<AdaptorProposal, ParalockError> {
232    let b = object(body, "proposal")?;
233    let quote_id = quote_id(b, "quoteId", "quoteId must be a quote id")?;
234    let taker_zcash_pubkey = pubkey(b, "takerZcashPubkey")?;
235    let adaptor_point = pubkey(b, "adaptorPoint")?;
236    let funding_txid = txid(b, "fundingTxid")?;
237    let refund_address = transparent_address(b, "refundAddress", network)?;
238    let taker_evm_recipient = evm_address(b, "takerEvmRecipient")?;
239    let funding_vout = parse_u32(field(b, "fundingVout"), "fundingVout", None, 10_000)?;
240    let amount = parse_amount(field(b, "amountZat"), "amountZat", None, Some(u128::from(MAX_ZAT)))?;
241    let refund_height = parse_u32(field(b, "refundHeight"), "refundHeight", Some(1), 499_999_999)?;
242    let funding_expiry_height = optional_funding_expiry(b)?;
243    Ok(AdaptorProposal {
244        quote_id,
245        taker_zcash_pubkey,
246        adaptor_point,
247        funding_txid,
248        funding_vout,
249        amount_zat: u64::try_from(amount).map_err(|_| bad("amountZat is out of range"))?,
250        refund_height,
251        refund_address,
252        taker_evm_recipient,
253        funding_expiry_height,
254    })
255}
256
257fn transparent_address(
258    object: &Map<String, Value>,
259    key: &str,
260    network: Option<Network>,
261) -> Result<String, ParalockError> {
262    match str_field(object, key) {
263        Some(s) if is_transparent_address(s, network) => Ok(s.to_owned()),
264        _ => Err(bad(match network {
265            Some(network) => format!("{key} must be a {network} transparent address"),
266            None => format!("{key} must be a transparent address"),
267        })),
268    }
269}
270
271/// Validates the maker's answer to an adaptor proposal (the taker's side of the trust boundary): `swapId` is a quote
272/// id, `makerRefundSignature` is hex of 9–73 bytes (DER plus the sighash byte), `claimTo` is a transparent address of
273/// `network`, `branchId` is a non-zero 32-bit integer, `signer` a non-zero EVM address and `signerPubkey` a 33-byte
274/// compressed point. Letter case is preserved. Only the shape is checked here: the taker then runs
275/// [`super::verify_proposal_accepted`], which checks the signature, the branch id against its node and the signer.
276pub fn parse_adaptor_proposal_accepted(
277    body: &Value,
278    network: Network,
279) -> Result<AdaptorProposalAccepted, ParalockError> {
280    adaptor_proposal_accepted(body, Some(network))
281}
282
283pub(crate) fn adaptor_proposal_accepted(
284    body: &Value,
285    network: Option<Network>,
286) -> Result<AdaptorProposalAccepted, ParalockError> {
287    let b = object(body, "answer")?;
288    let swap_id = quote_id(b, "swapId", "swapId must be a quote id")?;
289    let maker_refund_signature = match hex_field(b, "makerRefundSignature") {
290        Some(sig) if (9..=73).contains(&sig.byte_len()) => sig,
291        _ => return Err(bad("makerRefundSignature must be a DER signature followed by its sighash byte")),
292    };
293    let claim_to = transparent_address(b, "claimTo", network)?;
294    let branch_id = parse_u32(field(b, "branchId"), "branchId", Some(1), u64::from(u32::MAX))?;
295    let signer = evm_address(b, "signer")?;
296    let signer_pubkey = pubkey_as_is(b, "signerPubkey")?;
297    Ok(AdaptorProposalAccepted { swap_id, maker_refund_signature, claim_to, branch_id, signer, signer_pubkey })
298}
299
300/// Validates an adaptor pre-signature body: 162 bytes of hex, with or without `0x`, returned lowercase without the
301/// prefix (`parsePreSignatureRequest`). The pre-signature itself must still be checked with
302/// [`crate::adaptor::pre_verify`].
303pub fn parse_pre_signature_request(body: &Value) -> Result<AdaptorPreSignatureRequest, ParalockError> {
304    let b = object(body, "request")?;
305    let invalid = || bad("takerPreSignature must be a 162-byte hex pre-signature");
306    let pre = str_field(b, "takerPreSignature").ok_or_else(invalid)?;
307    let digits = pre.strip_prefix("0x").unwrap_or(pre);
308    if digits.len() != 324 || !digits.bytes().all(|c| c.is_ascii_hexdigit()) {
309        return Err(invalid());
310    }
311    Ok(AdaptorPreSignatureRequest { taker_pre_signature: digits.to_ascii_lowercase() })
312}
313
314/// The amount a quote request asks for.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum QuoteAmount {
317    /// `?zat=`: the taker sends exactly this many zatoshi.
318    ExactIn(u64),
319    /// `?usd=`: the recipient gets exactly this many token base units (a payment).
320    ExactOut(u128),
321}
322
323/// What a taker asks a maker for (`GET /quote?zat=&usd=&chain=&mode=`). The maker validates the query with
324/// [`parse_quote_request`]; the taker checks the answer against the same request with [`validate_quote`].
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub struct QuoteRequest {
327    /// What is being quoted.
328    pub amount: QuoteAmount,
329    /// The chain (default `tempo`).
330    pub chain: EvmChain,
331    /// The mode (default `htlc`).
332    pub mode: SwapMode,
333}
334
335/// Validates the (already URL-decoded) query parameters of `GET /quote?zat=&usd=&chain=&mode=` the way the reference
336/// maker does: `chain` defaults to `tempo` and `mode` to `htlc`, `usd` wins over `zat`, and the amount must be 1–30
337/// decimal digits within bounds (at most `MAX_ZAT` zatoshi, or 10^30 token units).
338pub fn parse_quote_request(
339    zat: Option<&str>,
340    usd: Option<&str>,
341    chain: Option<&str>,
342    mode: Option<&str>,
343) -> Result<QuoteRequest, ParalockError> {
344    let chain = EvmChain::parse(chain.unwrap_or("tempo")).ok_or_else(|| bad("unknown chain"))?;
345    let mode = SwapMode::parse(mode.unwrap_or("htlc")).ok_or_else(|| bad("mode must be htlc or adaptor"))?;
346    let raw = usd.or(zat).unwrap_or("");
347    if raw.is_empty() || raw.len() > 30 || !raw.bytes().all(|b| b.is_ascii_digit()) {
348        return Err(bad("amount must be a positive integer"));
349    }
350    let value = Value::String(raw.to_owned());
351    let amount = if usd.is_some() {
352        QuoteAmount::ExactOut(parse_amount(&value, "amount", None, Some(10u128.pow(30)))?)
353    } else {
354        let zat = parse_amount(&value, "amount", None, Some(u128::from(MAX_ZAT)))?;
355        QuoteAmount::ExactIn(u64::try_from(zat).map_err(|_| bad("amount is out of range"))?)
356    };
357    Ok(QuoteRequest { amount, chain, mode })
358}
359
360/// Per-swap and capacity limits of a maker (`MakerLimits`).
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub struct MakerLimits {
363    /// Smallest swap, in zatoshi. Default 0.01 ZEC.
364    pub min_zec_amount_zat: u64,
365    /// Largest swap, in zatoshi. Default 10 ZEC.
366    pub max_zec_amount_zat: u64,
367    /// Swaps in flight at once. Default 10.
368    pub max_concurrent_swaps: u32,
369    /// Unexpired quotes held at once. Default 1 000.
370    pub max_open_quotes: u32,
371    /// How long a quote is honored, in seconds. Default 60.
372    pub quote_ttl_seconds: u64,
373    /// Furthest a taker's `fundingExpiryHeight` may be above the tip. Default 45.
374    pub max_funding_expiry_blocks: u32,
375    /// Least EVM chain time, in seconds, the lock must still have left when the maker sends it; with less it abandons
376    /// the swap instead of locking (a taker could not claim in time). Default 30 (`minEvmLockSecondsLeft`).
377    pub min_evm_lock_seconds_left: u64,
378    /// ZEC the maker spends on its own claim (ZIP 317: 10 000 zat), priced into every quote.
379    pub zcash_fee_zat: u64,
380    /// Check the stablecoin inventory before quoting and locking. Default true.
381    pub check_inventory: bool,
382}
383
384/// Defaults of [`MakerLimits`].
385pub const DEFAULT_MAKER_LIMITS: MakerLimits = MakerLimits {
386    min_zec_amount_zat: 1_000_000,
387    max_zec_amount_zat: 1_000_000_000,
388    max_concurrent_swaps: 10,
389    max_open_quotes: 1_000,
390    quote_ttl_seconds: 60,
391    max_funding_expiry_blocks: super::policy::MAX_FUNDING_EXPIRY_BLOCKS,
392    min_evm_lock_seconds_left: 30,
393    zcash_fee_zat: 10_000,
394    check_inventory: true,
395};
396
397impl Default for MakerLimits {
398    fn default() -> Self {
399        DEFAULT_MAKER_LIMITS
400    }
401}
402
403/// The maker's per-swap bounds on a quoted amount: `limit_exceeded` below the minimum or above the maximum.
404pub fn assert_quote_amount(zat: u64, limits: &MakerLimits) -> Result<(), ParalockError> {
405    if zat < limits.min_zec_amount_zat {
406        return Err(ParalockError::new(
407            ParalockErrorCode::LimitExceeded,
408            format!("minimum swap is {} zat", limits.min_zec_amount_zat),
409        ));
410    }
411    if zat > limits.max_zec_amount_zat {
412        return Err(ParalockError::new(
413            ParalockErrorCode::LimitExceeded,
414            format!("maximum swap is {} zat", limits.max_zec_amount_zat),
415        ));
416    }
417    Ok(())
418}
419
420// ---------------------------------------------------------------------------------------------------------------
421// Taker side: quotes from makers
422
423/// Parses a quote from untrusted JSON (bigints as decimal strings). Checks shape only; see [`validate_quote`]
424/// (`parseQuote`).
425pub fn parse_quote(body: &Value) -> Result<Quote, ParalockError> {
426    let q = object(body, "quote")?;
427    let id = quote_id(q, "id", "quote.id must be a quote id")?;
428    if str_field(q, "direction") != Some("ZEC_TO_USD") {
429        return Err(bad("quote.direction must be ZEC_TO_USD"));
430    }
431    let mode =
432        str_field(q, "mode").and_then(SwapMode::parse).ok_or_else(|| bad("quote.mode must be htlc or adaptor"))?;
433    let chain =
434        str_field(q, "chain").and_then(EvmChain::parse).ok_or_else(|| bad("quote.chain is not a known chain"))?;
435    let token =
436        hex_field(q, "token").filter(|h| h.byte_len() == 20).ok_or_else(|| bad("quote.token must be an address"))?;
437    let maker_zcash_pubkey = hex_field(q, "makerZcashPubkey")
438        .filter(|h| h.byte_len() == 33)
439        .ok_or_else(|| bad("quote.makerZcashPubkey must be 33 bytes of hex"))?;
440    let maker_evm_address = hex_field(q, "makerEvmAddress")
441        .filter(|h| h.byte_len() == 20)
442        .ok_or_else(|| bad("quote.makerEvmAddress must be an address"))?;
443    let rate = match q.get("rate").and_then(Value::as_f64) {
444        Some(r) if r.is_finite() && r > 0.0 => r,
445        _ => return Err(bad("quote.rate must be a positive number")),
446    };
447    let zec_amount_zat =
448        parse_amount(field(q, "zecAmountZat"), "quote.zecAmountZat", None, Some(u128::from(MAX_ZAT)))? as u64;
449    let usd_amount = parse_amount(field(q, "usdAmount"), "quote.usdAmount", None, Some(10u128.pow(30)))?;
450    let token_symbol = string(q, "tokenSymbol", "quote.tokenSymbol", 32)?;
451    let zcash_lock_blocks = parse_u32(field(q, "zcashLockBlocks"), "quote.zcashLockBlocks", Some(1), 100_000)?;
452    let evm_lock_seconds = parse_u32(field(q, "evmLockSeconds"), "quote.evmLockSeconds", Some(1), 10_000_000)?;
453    let expires_at = parse_integer(field(q, "expiresAt"), "quote.expiresAt", None, None)?;
454    let chain_id = match q.get("chainId") {
455        None => None,
456        Some(v) => Some(parse_integer(v, "quote.chainId", Some(1), None)?),
457    };
458    let exact_out = q.get("exactOut") == Some(&Value::Bool(true));
459    Ok(Quote {
460        id,
461        direction: SwapDirection::ZecToUsd,
462        mode,
463        chain,
464        chain_id,
465        zec_amount_zat,
466        usd_amount,
467        token,
468        token_symbol,
469        rate,
470        maker_zcash_pubkey,
471        maker_evm_address,
472        zcash_lock_blocks,
473        evm_lock_seconds,
474        expires_at,
475        exact_out,
476    })
477}
478
479/// Bounds a taker enforces on every quote before locking anything.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub struct QuoteLimits {
482    /// Longest Zcash refund window accepted (blocks): caps how long a silent maker can strand your ZEC. Default 288.
483    pub max_zcash_lock_blocks: u32,
484    /// Shortest Zcash refund window accepted (blocks). Default 10.
485    pub min_zcash_lock_blocks: u32,
486    /// Shortest EVM lock accepted (seconds): you need time to claim. Default 30.
487    pub min_evm_lock_seconds: u32,
488    /// Longest EVM lock accepted (seconds). Default 86 400.
489    pub max_evm_lock_seconds: u32,
490    /// Largest swap accepted, in zatoshi. Default 1 000 ZEC.
491    pub max_zec_amount_zat: u64,
492    /// Reject quotes that expire within this many seconds (time to fund). Default 5.
493    pub expiry_margin_seconds: u64,
494    /// Maximum shortfall versus your own price, in basis points (with a price). Default 200 (2 %).
495    pub max_slippage_bps: u32,
496}
497
498/// Defaults of [`QuoteLimits`].
499pub const DEFAULT_QUOTE_LIMITS: QuoteLimits = QuoteLimits {
500    max_zcash_lock_blocks: 288,
501    min_zcash_lock_blocks: 10,
502    min_evm_lock_seconds: 30,
503    max_evm_lock_seconds: 86_400,
504    max_zec_amount_zat: 1_000 * 100_000_000,
505    expiry_margin_seconds: 5,
506    max_slippage_bps: 200,
507};
508
509impl Default for QuoteLimits {
510    fn default() -> Self {
511        DEFAULT_QUOTE_LIMITS
512    }
513}
514
515/// The taker's floors on the time left to claim and on the EVM lock's confirmations (spec §7.3, §7.4, §7.6).
516/// Constructed with [`ClaimLimits::new`], which refuses values below [`MIN_CLAIM_WINDOW_SECONDS`] and
517/// [`MIN_EVM_CONFIRMATIONS`]; going below them takes the explicit [`ClaimLimits::new_below_floor`].
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub struct ClaimLimits {
520    min_claim_window_seconds: u64,
521    min_evm_confirmations: u64,
522}
523
524/// Default and floor of the time the maker's lock must still have left when the taker claims
525/// (`minClaimWindowSeconds`, 30 s). A claim mined at or after the expiry reverts but still publishes the secret.
526pub const MIN_CLAIM_WINDOW_SECONDS: u64 = 30;
527/// Default and floor of the confirmations the maker's EVM lock needs before the taker reveals anything
528/// (`minEvmConfirmations`, 1: never reveal against a lock that isn't mined).
529pub const MIN_EVM_CONFIRMATIONS: u64 = 1;
530
531/// The spec's defaults (`DEFAULT_TAKER_LIMITS.minClaimWindowSeconds` / `.minEvmConfirmations`).
532pub const DEFAULT_CLAIM_LIMITS: ClaimLimits =
533    ClaimLimits { min_claim_window_seconds: MIN_CLAIM_WINDOW_SECONDS, min_evm_confirmations: MIN_EVM_CONFIRMATIONS };
534
535impl ClaimLimits {
536    /// Limits at or above the floors (larger windows suit slow or congested chains). Fails with `invalid_request`
537    /// below a floor.
538    pub fn new(min_claim_window_seconds: u64, min_evm_confirmations: u64) -> Result<Self, ParalockError> {
539        if min_claim_window_seconds < MIN_CLAIM_WINDOW_SECONDS {
540            return Err(bad(format!(
541                "minClaimWindowSeconds of {min_claim_window_seconds}s is below the {MIN_CLAIM_WINDOW_SECONDS}s floor"
542            )));
543        }
544        if min_evm_confirmations < MIN_EVM_CONFIRMATIONS {
545            return Err(bad(format!(
546                "minEvmConfirmations of {min_evm_confirmations} is below the floor of {MIN_EVM_CONFIRMATIONS}"
547            )));
548        }
549        Ok(Self { min_claim_window_seconds, min_evm_confirmations })
550    }
551
552    /// Limits below the floors, for local devnets with instant blocks and tests. **Unsafe on a real network:** a
553    /// claim that lands after the expiry reverts and still reveals the secret, and an unconfirmed lock can vanish in a
554    /// reorg after the secret is out.
555    pub const fn new_below_floor(min_claim_window_seconds: u64, min_evm_confirmations: u64) -> Self {
556        Self { min_claim_window_seconds, min_evm_confirmations }
557    }
558
559    /// Seconds the lock must still have left when the taker claims.
560    pub const fn min_claim_window_seconds(&self) -> u64 {
561        self.min_claim_window_seconds
562    }
563
564    /// Confirmations the lock needs before the taker reveals anything.
565    pub const fn min_evm_confirmations(&self) -> u64 {
566        self.min_evm_confirmations
567    }
568}
569
570impl Default for ClaimLimits {
571    fn default() -> Self {
572        DEFAULT_CLAIM_LIMITS
573    }
574}
575
576/// A chain the taker is configured for.
577#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct ChainConfig {
579    /// Expected chain id.
580    pub chain_id: u64,
581    /// Accepted stablecoins (`0x` addresses, any case).
582    pub tokens: Vec<String>,
583    /// True if private mode (the adaptor escrow) is configured.
584    pub adaptor: bool,
585    /// Token decimals, for the slippage check. Default 6.
586    pub decimals: Option<u32>,
587}
588
589/// Whether [`validate_quote`] bounds the quoted exchange rate.
590#[derive(Debug, Clone, Copy, PartialEq)]
591pub enum PriceCheck {
592    /// Your own USD/ZEC price: the quote may be at most `max_slippage_bps` worse than it (spec §8.4). The maker's
593    /// fixed Zcash claim fee, which it prices into every quote (spec §8.3), counts as slippage here, so small swaps
594    /// fail the check (with 200 bps, below about 0.0067 ZEC); see [`PriceCheck::ReferenceWithFee`].
595    Reference(f64),
596    /// [`PriceCheck::Reference`], with the maker's fixed Zcash claim fee allowed on top of the slippage, as spec §8.3
597    /// prices it: an exact-in quote is compared with the value of `zecAmountZat − fixed_fee_zat`, an exact-out quote
598    /// with `usdUnitsToZat(usdAmount) + fixed_fee_zat`. A maker charging more than `fixed_fee_zat` still has to stay
599    /// within the slippage for the excess.
600    ReferenceWithFee {
601        /// Your USD/ZEC price.
602        price: f64,
603        /// The fixed fee allowed, in zatoshi (the reference maker's `zcashFeeZat`: 10 000).
604        fixed_fee_zat: u64,
605    },
606    /// No price check. **Risky:** nothing then bounds the rate, and the maker chooses the other side of the trade
607    /// freely (any stablecoin amount, even one cent, for the ZEC you asked to sell, or any amount of ZEC for the
608    /// dollars you asked for). Use it only when a person reviews the quoted amounts before anything is locked.
609    Disabled,
610}
611
612/// What [`validate_quote`] checks a quote against.
613#[derive(Debug, Clone, PartialEq)]
614pub struct QuoteContext {
615    /// Bounds.
616    pub limits: QuoteLimits,
617    /// Chains the taker is configured for.
618    pub chains: BTreeMap<EvmChain, ChainConfig>,
619    /// Your own USD/ZEC price, for the slippage check, or an explicit opt-out.
620    pub price: PriceCheck,
621    /// Unix seconds now (this crate has no clock).
622    pub now: u64,
623}
624
625/// Checks a maker's quote before the taker commits anything (spec §6.7, `validateQuote` plus the taker's request
626/// check): it answers `request` (the same exact-in zatoshi amount, or the same exact-out token amount with
627/// `exactOut`, on the requested chain and in the requested mode), the maker's Zcash key is a valid point, the chain,
628/// chain id and token are the ones you configured, the lock windows are within your bounds, it hasn't expired, and
629/// (with [`PriceCheck::Reference`]) the rate is within your slippage. Fails with `quote_rejected` (or
630/// `invalid_request` for an invalid price).
631pub fn validate_quote(quote: &Quote, request: &QuoteRequest, ctx: &QuoteContext) -> Result<(), ParalockError> {
632    let same_amount = match request.amount {
633        QuoteAmount::ExactIn(zat) => !quote.exact_out && quote.zec_amount_zat == zat,
634        QuoteAmount::ExactOut(usd) => quote.exact_out && quote.usd_amount == usd,
635    };
636    if !same_amount || quote.chain != request.chain || quote.mode != request.mode {
637        return Err(reject("the maker quoted different terms than requested"));
638    }
639    let limits = &ctx.limits;
640    let chain = ctx
641        .chains
642        .get(&quote.chain)
643        .ok_or_else(|| reject(format!("you have no {} account configured", quote.chain)))?;
644    if let Some(id) = quote.chain_id {
645        if id != chain.chain_id {
646            return Err(reject(format!(
647                "quote is for chain id {id}, your {} client is on {}",
648                quote.chain, chain.chain_id
649            )));
650        }
651    }
652    if quote.mode == SwapMode::Adaptor && !chain.adaptor {
653        return Err(reject(format!("private mode is not configured on {}", quote.chain)));
654    }
655    let token = quote.token.as_str().to_ascii_lowercase();
656    if !chain.tokens.iter().any(|t| t.to_ascii_lowercase() == token) {
657        return Err(reject(format!(
658            "quote pays {} at {}, which is not an accepted stablecoin on {}",
659            quote.token_symbol, quote.token, quote.chain
660        )));
661    }
662    if !is_hex(quote.maker_zcash_pubkey.as_str(), Some(33))
663        || !PublicKey::is_valid_compressed(&quote.maker_zcash_pubkey.to_bytes())
664    {
665        return Err(reject("maker's Zcash public key is not a valid curve point"));
666    }
667    if !is_evm_address(quote.maker_evm_address.as_str()) {
668        return Err(reject("maker's EVM address is invalid"));
669    }
670    if quote.zec_amount_zat == 0 || quote.usd_amount == 0 {
671        return Err(reject("quote amounts must be positive"));
672    }
673    if quote.zec_amount_zat > limits.max_zec_amount_zat {
674        return Err(reject(format!("quote exceeds your maximum of {} zat", limits.max_zec_amount_zat)));
675    }
676    if quote.zcash_lock_blocks < limits.min_zcash_lock_blocks || quote.zcash_lock_blocks > limits.max_zcash_lock_blocks
677    {
678        return Err(reject(format!(
679            "Zcash lock of {} blocks is outside {}\u{2013}{}",
680            quote.zcash_lock_blocks, limits.min_zcash_lock_blocks, limits.max_zcash_lock_blocks
681        )));
682    }
683    if quote.evm_lock_seconds < limits.min_evm_lock_seconds || quote.evm_lock_seconds > limits.max_evm_lock_seconds {
684        return Err(reject(format!(
685            "EVM lock of {}s is outside {}\u{2013}{}s",
686            quote.evm_lock_seconds, limits.min_evm_lock_seconds, limits.max_evm_lock_seconds
687        )));
688    }
689    if u128::from(quote.expires_at) < u128::from(ctx.now) + u128::from(limits.expiry_margin_seconds) {
690        return Err(reject("quote has expired"));
691    }
692    let (price, fee) = match ctx.price {
693        PriceCheck::Reference(price) => (Some(price), 0),
694        PriceCheck::ReferenceWithFee { price, fixed_fee_zat } => (Some(price), fixed_fee_zat),
695        PriceCheck::Disabled => (None, 0),
696    };
697    if let Some(price) = price {
698        let decimals = chain.decimals.unwrap_or(6);
699        let slip = u128::from(limits.max_slippage_bps);
700        if quote.exact_out {
701            let fair = usd_units_to_zat(quote.usd_amount, price, decimals)?.saturating_add(u128::from(fee));
702            // Saturation keeps the comparison exact: a saturated right side exceeds any possible left side.
703            if u128::from(quote.zec_amount_zat) * 10_000 > fair.saturating_mul(10_000 + slip) {
704                return Err(reject(format!("quote costs more than {} bps above your price", limits.max_slippage_bps)));
705            }
706        } else if slip < 10_000 {
707            let fair = zat_to_usd_units(quote.zec_amount_zat.saturating_sub(fee), price, decimals)?;
708            if quote.usd_amount.saturating_mul(10_000) < fair.saturating_mul(10_000 - slip) {
709                return Err(reject(format!("quote pays more than {} bps below your price", limits.max_slippage_bps)));
710            }
711        } else {
712            // A slippage of 100 % or more accepts any price (the bound is ≤ 0), but the price must still be valid.
713            zat_to_usd_units(quote.zec_amount_zat, price, decimals)?;
714        }
715    }
716    Ok(())
717}
718
719// ---------------------------------------------------------------------------------------------------------------
720// Deserialization goes through the validating parsers, so a message can't skip them by being decoded with serde.
721
722impl TryFrom<Value> for Quote {
723    type Error = ParalockError;
724
725    fn try_from(value: Value) -> Result<Self, Self::Error> {
726        parse_quote(&value)
727    }
728}
729
730impl TryFrom<Value> for AcceptRequest {
731    type Error = ParalockError;
732
733    fn try_from(value: Value) -> Result<Self, Self::Error> {
734        parse_accept_request(&value)
735    }
736}
737
738impl TryFrom<Value> for AdaptorProposal {
739    type Error = ParalockError;
740
741    /// [`parse_adaptor_proposal`] without a network (any transparent address): check the network with
742    /// [`parse_adaptor_proposal`] at the trust boundary; [`super::verify_adaptor_proposal`] and
743    /// [`super::verify_proposal_accepted`] check it again.
744    fn try_from(value: Value) -> Result<Self, Self::Error> {
745        adaptor_proposal(&value, None)
746    }
747}
748
749impl TryFrom<Value> for AdaptorProposalAccepted {
750    type Error = ParalockError;
751
752    /// [`parse_adaptor_proposal_accepted`] without a network; [`super::verify_proposal_accepted`] checks it.
753    fn try_from(value: Value) -> Result<Self, Self::Error> {
754        adaptor_proposal_accepted(&value, None)
755    }
756}