Skip to main content

ant_core/browser/
payment.rs

1//! Verification and payment planning shared by native and browser clients.
2
3use super::protocol::normalize_hex;
4pub use super::protocol::{BrowserCommitmentArtifact, BrowserQuoteArtifact};
5use ant_protocol::evm::{Amount, PaymentQuote, RewardsAddress};
6#[cfg(test)]
7use ant_protocol::payment::commitment::commitment_hash;
8use ant_protocol::payment::commitment::{StorageCommitment, MAX_COMMITMENT_SIDECAR_BYTES};
9use serde::{Deserialize, Serialize};
10
11/// Compute the native EVM quote identifier from its signed fields.
12pub fn payment_quote_hash(payload: &[u8], public_key: &[u8], signature: &[u8]) -> [u8; 32] {
13    PaymentQuote::hash_signed_bytes(payload, public_key, signature).into()
14}
15
16#[cfg(test)]
17const PAYMENT_MULTIPLIER: u128 = crate::payment_policy::SINGLE_NODE_PAYMENT_MULTIPLIER as u128;
18#[cfg(test)]
19fn calculate_price_wei(count: u32) -> u128 {
20    ant_protocol::payment::calculate_price(count as usize).to::<u128>()
21}
22
23/// A quote that is safe to hand to a transaction signer.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct VerifiedStorageQuote {
26    /// Original verified quote sent back to the selected storage nodes.
27    pub quote: BrowserQuoteArtifact,
28    /// Lowercase EVM quote hash without `0x`.
29    #[serde(rename = "quoteHash")]
30    pub quote_hash: String,
31    /// Checksummed-independent lowercase rewards address with `0x`.
32    #[serde(rename = "rewardsAddress")]
33    pub rewards_address: String,
34    /// Decimal amount paid after applying Autonomi's replication multiplier.
35    pub amount: String,
36}
37
38/// Storage quote validation error.
39#[derive(Debug, thiserror::Error)]
40#[error("invalid storage quote: {0}")]
41pub struct StorageQuoteError(pub String);
42
43/// Select the quote paid for one record using the native client's median policy.
44///
45/// Quotes must already have passed [`verify_storage_quote`] for distinct,
46/// eligible peers requiring payment for the same record. Input order breaks
47/// ties; one through seven quotes are supported. The selected issuer receives
48/// three times its price. Transport-specific storage-quorum checks remain
49/// with the caller.
50pub fn select_storage_quote(
51    mut quotes: Vec<VerifiedStorageQuote>,
52) -> Result<VerifiedStorageQuote, StorageQuoteError> {
53    let prices = quotes
54        .iter()
55        .map(|quote| parse_decimal_amount(&quote.quote.price, "quote price"))
56        .collect::<Result<Vec<_>, _>>()?;
57    let plan = crate::payment_policy::SingleNodePaymentPlan::from_prices(&prices)
58        .map_err(|error| StorageQuoteError(error.to_string()))?;
59    let paid = plan.paid_quote();
60    let mut selected = quotes.swap_remove(paid.quote_index);
61    selected.amount = paid.amount.to_string();
62    Ok(selected)
63}
64
65/// Sum verified decimal quote amounts without exposing integer arithmetic to
66/// JavaScript or a wallet adapter.
67pub fn storage_payment_total(quotes: &[VerifiedStorageQuote]) -> Result<String, StorageQuoteError> {
68    quotes
69        .iter()
70        .try_fold(Amount::ZERO, |total, quote| {
71            let amount = parse_decimal_amount(&quote.amount, "storage payment amount")?;
72            total
73                .checked_add(amount)
74                .ok_or_else(|| StorageQuoteError("storage payment total overflow".to_string()))
75        })
76        .map(|total| total.to_string())
77}
78
79/// Fully verify a quote, its commitment, peer binding, price, and EVM hash.
80pub fn verify_storage_quote(
81    mut quote: BrowserQuoteArtifact,
82    expected_address: &str,
83    expected_peer_id: &str,
84) -> Result<VerifiedStorageQuote, StorageQuoteError> {
85    let expected_address = normalize_hex(expected_address, 32).map_err(StorageQuoteError)?;
86    let expected_peer_id = normalize_hex(expected_peer_id, 32).map_err(StorageQuoteError)?;
87    quote.content = normalize_hex(&quote.content, 32).map_err(StorageQuoteError)?;
88    quote.peer_id = normalize_hex(&quote.peer_id, 32).map_err(StorageQuoteError)?;
89    if quote.content != expected_address {
90        return Err(StorageQuoteError(
91            "storage quote is for a different chunk".to_string(),
92        ));
93    }
94    if quote.peer_id != expected_peer_id {
95        return Err(StorageQuoteError(
96            "storage quote belongs to a different WebRtcDirect peer".to_string(),
97        ));
98    }
99    let public_key = decode_unbounded_hex(&quote.public_key, "quote public key")?;
100    let signature = decode_unbounded_hex(&quote.signature, "quote signature")?;
101    let price = parse_decimal_amount(&quote.price, "quote price")?;
102    let rewards = normalize_hex(&quote.rewards_address, 20).map_err(StorageQuoteError)?;
103    quote.rewards_address.clone_from(&rewards);
104    let commitment_pin = quote
105        .commitment_pin
106        .as_deref()
107        .map(|pin| normalize_hex(pin, 32).map_err(StorageQuoteError))
108        .transpose()?;
109    quote.commitment_pin.clone_from(&commitment_pin);
110    let native_quote = PaymentQuote {
111        content: xor_name::XorName(decode_hex_array(&quote.content, "quote content")?),
112        timestamp: std::time::UNIX_EPOCH
113            .checked_add(std::time::Duration::from_secs(quote.timestamp_secs))
114            .ok_or_else(|| StorageQuoteError("quote timestamp out of range".into()))?,
115        price,
116        rewards_address: RewardsAddress::from(decode_hex_array::<20>(
117            &rewards,
118            "quote rewards address",
119        )?),
120        pub_key: public_key.clone(),
121        signature: signature.clone(),
122        committed_key_count: quote.committed_key_count,
123        commitment_pin: commitment_pin
124            .as_deref()
125            .map(|pin| decode_hex_array(pin, "commitment pin"))
126            .transpose()?,
127    };
128    let sidecar = if quote.committed_key_count > 0 {
129        quote
130            .commitment
131            .as_ref()
132            .map(|artifact| decode_unbounded_hex(&artifact.encoded, "storage commitment sidecar"))
133            .transpose()?
134    } else {
135        None
136    };
137    crate::quote_validation::validate_quote::<_, StorageCommitment>(
138        &decode_hex_array(&expected_peer_id, "peer ID")?,
139        &decode_hex_array(&expected_address, "content address")?,
140        &crate::quote_validation::QuoteFields {
141            public_key: &public_key,
142            content: &decode_hex_array(&quote.content, "quote content")?,
143            price,
144            committed_key_count: quote.committed_key_count,
145            commitment_pin: commitment_pin
146                .as_deref()
147                .map(|pin| decode_hex_array(pin, "commitment pin"))
148                .transpose()?,
149        },
150        || ant_protocol::payment::verify_quote_signature(&native_quote),
151        sidecar.as_deref(),
152    )
153    .map_err(|error| StorageQuoteError(error.to_string()))?;
154    let quote_hash = hex::encode(native_quote.hash());
155    if normalize_hex(&quote.quote_hash, 32).map_err(StorageQuoteError)? != quote_hash {
156        return Err(StorageQuoteError(
157            "storage quote hash does not match its signed fields".to_string(),
158        ));
159    }
160    quote.quote_hash.clone_from(&quote_hash);
161
162    if quote.committed_key_count > 0 {
163        // The browser wire duplicates the sidecar fields; only this envelope
164        // consistency check is adapter-specific. Admission was checked above.
165        if let Some(artifact) = quote.commitment.as_mut() {
166            normalize_commitment_artifact(artifact)?;
167        }
168    }
169
170    let amount = crate::payment_policy::enhanced_payment_amount(price)
171        .map_err(|error| StorageQuoteError(error.to_string()))?;
172    Ok(VerifiedStorageQuote {
173        quote,
174        quote_hash,
175        rewards_address: format!("0x{rewards}"),
176        amount: amount.to_string(),
177    })
178}
179
180#[cfg(test)]
181fn canonical_quote_bytes(
182    quote: &BrowserQuoteArtifact,
183    price: u128,
184    rewards: &str,
185    commitment_pin: Option<&str>,
186) -> Result<Vec<u8>, StorageQuoteError> {
187    let content = decode_hex_array::<32>(&quote.content, "quote content")?;
188    let rewards = decode_hex_array::<20>(rewards, "quote rewards address")?;
189    let commitment_pin = commitment_pin
190        .map(|pin| decode_hex_array::<32>(pin, "storage commitment pin"))
191        .transpose()?;
192    Ok(PaymentQuote::bytes_for_signing(
193        xor_name::XorName(content),
194        std::time::UNIX_EPOCH + std::time::Duration::from_secs(quote.timestamp_secs),
195        &Amount::from(price),
196        &RewardsAddress::from(rewards),
197        quote.committed_key_count,
198        &commitment_pin,
199    ))
200}
201
202fn normalize_commitment_artifact(
203    artifact: &mut BrowserCommitmentArtifact,
204) -> Result<(), StorageQuoteError> {
205    let encoded = decode_unbounded_hex(&artifact.encoded, "storage commitment sidecar")?;
206    if encoded.len() > MAX_COMMITMENT_SIDECAR_BYTES {
207        return Err(StorageQuoteError(
208            "storage commitment sidecar exceeds the protocol limit".to_string(),
209        ));
210    }
211    let commitment: StorageCommitment = rmp_serde::from_slice(&encoded).map_err(|error| {
212        StorageQuoteError(format!(
213            "storage commitment sidecar is not valid MessagePack: {error}"
214        ))
215    })?;
216    let root = normalize_hex(&artifact.root, 32).map_err(StorageQuoteError)?;
217    let peer_id = normalize_hex(&artifact.sender_peer_id, 32).map_err(StorageQuoteError)?;
218    let public_key =
219        decode_unbounded_hex(&artifact.sender_public_key, "storage commitment public key")?;
220    let signature = decode_unbounded_hex(&artifact.signature, "storage commitment signature")?;
221    if commitment.root != decode_hex_array::<32>(&root, "storage commitment root")?
222        || commitment.key_count != artifact.key_count
223        || commitment.sender_peer_id
224            != decode_hex_array::<32>(&peer_id, "storage commitment peer ID")?
225        || commitment.sender_public_key != public_key
226        || commitment.signature != signature
227    {
228        return Err(StorageQuoteError(
229            "storage commitment sidecar differs from the verified commitment".to_string(),
230        ));
231    }
232    artifact.root = root;
233    artifact.sender_peer_id = peer_id.clone();
234    artifact.sender_public_key = hex::encode(&public_key);
235    artifact.signature = hex::encode(&signature);
236    artifact.encoded = hex::encode(&encoded);
237    Ok(())
238}
239
240fn parse_decimal_amount(value: &str, label: &str) -> Result<Amount, StorageQuoteError> {
241    if value.is_empty()
242        || (value.len() > 1 && value.starts_with('0'))
243        || !value.bytes().all(|byte| byte.is_ascii_digit())
244    {
245        return Err(StorageQuoteError(format!("invalid {label}")));
246    }
247    value
248        .parse::<Amount>()
249        .map_err(|_| StorageQuoteError(format!("{label} exceeds the supported protocol range")))
250}
251
252fn decode_unbounded_hex(value: &str, label: &str) -> Result<Vec<u8>, StorageQuoteError> {
253    let value = value.strip_prefix("0x").unwrap_or(value);
254    if (value.len() & 1) != 0 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
255        return Err(StorageQuoteError(format!("invalid {label}")));
256    }
257    hex::decode(value).map_err(|error| StorageQuoteError(format!("invalid {label}: {error}")))
258}
259
260fn decode_hex_array<const LENGTH: usize>(
261    value: &str,
262    label: &str,
263) -> Result<[u8; LENGTH], StorageQuoteError> {
264    let decoded = hex::decode(value).map_err(|error| StorageQuoteError(error.to_string()))?;
265    decoded.try_into().map_err(|bytes: Vec<u8>| {
266        StorageQuoteError(format!(
267            "expected {LENGTH} bytes for {label}, received {}",
268            bytes.len()
269        ))
270    })
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use ant_protocol::payment::commitment::{
277        commitment_signed_payload as storage_commitment_bytes_for_signing, DOMAIN_COMMITMENT,
278    };
279    use ant_protocol::pqc::api::ml_dsa_65;
280
281    fn baseline_quote() -> (BrowserQuoteArtifact, String, String) {
282        let content = [0x31; 32];
283        let rewards = [0x44; 20];
284        let timestamp = 1_775_000_000;
285        let (public_key, secret_key) = ml_dsa_65().generate_keypair().expect("keypair");
286        let public_key = public_key.to_bytes();
287        let peer_id = blake3::hash(&public_key).to_hex().to_string();
288        let mut quote = BrowserQuoteArtifact {
289            peer_id: peer_id.clone(),
290            content: hex::encode(content),
291            timestamp_secs: timestamp,
292            price: calculate_price_wei(0).to_string(),
293            rewards_address: hex::encode(rewards),
294            public_key: hex::encode(&public_key),
295            signature: String::new(),
296            committed_key_count: 0,
297            commitment_pin: None,
298            quote_hash: String::new(),
299            commitment: None,
300        };
301        let payload =
302            canonical_quote_bytes(&quote, calculate_price_wei(0), &hex::encode(rewards), None)
303                .expect("payload");
304        let signature = ml_dsa_65()
305            .sign(&secret_key, &payload)
306            .expect("signature")
307            .to_bytes();
308        quote.signature = hex::encode(&signature);
309        quote.quote_hash = hex::encode(payment_quote_hash(&payload, &public_key, &signature));
310        (quote, hex::encode(content), peer_id)
311    }
312
313    fn bound_quote(key_count: u32) -> (BrowserQuoteArtifact, String, String) {
314        let content = [0x31; 32];
315        let rewards = [0x42; 20];
316        let root = [0x53; 32];
317        let timestamp = 1_775_000_001;
318        let (public_key, secret_key) = ml_dsa_65().generate_keypair().expect("keypair");
319        let public_key = public_key.to_bytes();
320        let peer_id = blake3::hash(&public_key).into();
321        let mut commitment = StorageCommitment {
322            root,
323            key_count,
324            sender_peer_id: peer_id,
325            sender_public_key: public_key.clone(),
326            signature: Vec::new(),
327        };
328        let commitment_payload = storage_commitment_bytes_for_signing(
329            &commitment.root,
330            commitment.key_count,
331            &commitment.sender_peer_id,
332            &commitment.sender_public_key,
333        );
334        commitment.signature = ml_dsa_65()
335            .sign_with_context(&secret_key, &commitment_payload, DOMAIN_COMMITMENT)
336            .expect("commitment signature")
337            .to_bytes();
338        let native_commitment = ant_protocol::payment::StorageCommitment {
339            root: commitment.root,
340            key_count: commitment.key_count,
341            sender_peer_id: commitment.sender_peer_id,
342            sender_public_key: commitment.sender_public_key.clone(),
343            signature: commitment.signature.clone(),
344        };
345        assert!(ant_protocol::payment::verify_commitment_signature(
346            &native_commitment
347        ));
348        assert_eq!(
349            commitment_hash(&commitment),
350            ant_protocol::payment::commitment_hash(&native_commitment)
351        );
352        let encoded = rmp_serde::to_vec(&commitment).expect("MessagePack commitment");
353        assert_eq!(
354            encoded,
355            rmp_serde::to_vec(&native_commitment).expect("native MessagePack commitment")
356        );
357        let pin = hex::encode(commitment_hash(&commitment).expect("commitment hash"));
358        let peer_id = hex::encode(peer_id);
359        let price = calculate_price_wei(key_count);
360        assert_eq!(
361            ant_protocol::evm::Amount::from(price),
362            ant_protocol::payment::calculate_price(key_count as usize)
363        );
364        let mut quote = BrowserQuoteArtifact {
365            peer_id: peer_id.clone(),
366            content: hex::encode(content),
367            timestamp_secs: timestamp,
368            price: price.to_string(),
369            rewards_address: hex::encode(rewards),
370            public_key: hex::encode(&public_key),
371            signature: String::new(),
372            committed_key_count: key_count,
373            commitment_pin: Some(pin.clone()),
374            quote_hash: String::new(),
375            commitment: Some(BrowserCommitmentArtifact {
376                encoded: hex::encode(encoded),
377                root: hex::encode(commitment.root),
378                key_count,
379                sender_peer_id: peer_id.clone(),
380                sender_public_key: hex::encode(&public_key),
381                signature: hex::encode(&commitment.signature),
382            }),
383        };
384        let payload = canonical_quote_bytes(&quote, price, &hex::encode(rewards), Some(&pin))
385            .expect("quote payload");
386        let native_quote = ant_protocol::evm::PaymentQuote {
387            content: xor_name::XorName(content),
388            timestamp: std::time::SystemTime::UNIX_EPOCH
389                + std::time::Duration::from_secs(timestamp),
390            price: ant_protocol::evm::Amount::from(price),
391            rewards_address: ant_protocol::evm::RewardsAddress::from(rewards),
392            pub_key: public_key.clone(),
393            signature: Vec::new(),
394            committed_key_count: key_count,
395            commitment_pin: Some(
396                hex::decode(&pin)
397                    .expect("pin")
398                    .try_into()
399                    .expect("32-byte pin"),
400            ),
401        };
402        assert_eq!(payload, native_quote.bytes_for_sig());
403        let signature = ml_dsa_65()
404            .sign(&secret_key, &payload)
405            .expect("quote signature")
406            .to_bytes();
407        quote.signature = hex::encode(&signature);
408        quote.quote_hash = hex::encode(payment_quote_hash(&payload, &public_key, &signature));
409        let native_quote = ant_protocol::evm::PaymentQuote {
410            signature: signature.clone(),
411            ..native_quote
412        };
413        assert_eq!(quote.quote_hash, hex::encode(native_quote.hash()));
414        (quote, hex::encode(content), peer_id)
415    }
416
417    #[test]
418    fn payment_hash_matches_evmlib_vector() {
419        assert_eq!(
420            hex::encode(payment_quote_hash(&[0, 1], &[2], &[3])),
421            "d98f2e8134922f73748703c8e7084d42f13d2fa1439936ef5a3abcf5646fe83f"
422        );
423    }
424
425    #[test]
426    fn verifies_baseline_quote_and_rejects_tampering() {
427        let (quote, content, peer_id) = baseline_quote();
428        let verified =
429            verify_storage_quote(quote.clone(), &content, &peer_id).expect("valid quote");
430        assert_eq!(verified.amount, (calculate_price_wei(0) * 3).to_string());
431        let mut tampered = quote;
432        tampered.price = (calculate_price_wei(0) + 1).to_string();
433        assert!(verify_storage_quote(tampered, &content, &peer_id).is_err());
434    }
435
436    #[test]
437    fn verifies_bound_commitment_and_exact_native_sidecar() {
438        let (quote, content, peer_id) = bound_quote(23);
439        let verified =
440            verify_storage_quote(quote.clone(), &content, &peer_id).expect("valid bound quote");
441        assert_eq!(
442            storage_payment_total(&[verified]).expect("payment total"),
443            (calculate_price_wei(23) * PAYMENT_MULTIPLIER).to_string()
444        );
445
446        let mut tampered = quote;
447        tampered.commitment.as_mut().expect("commitment").root = hex::encode([0x99; 32]);
448        let error = verify_storage_quote(tampered, &content, &peer_id)
449            .expect_err("sidecar mismatch must fail");
450        assert!(error.to_string().contains("sidecar differs"));
451    }
452
453    fn native_quote(quote: &BrowserQuoteArtifact) -> ant_protocol::evm::PaymentQuote {
454        use ant_protocol::evm::{Amount, PaymentQuote, RewardsAddress};
455        PaymentQuote {
456            content: xor_name::XorName(decode_hex_array(&quote.content, "content").unwrap()),
457            timestamp: std::time::SystemTime::UNIX_EPOCH
458                + std::time::Duration::from_secs(quote.timestamp_secs),
459            price: Amount::from(quote.price.parse::<Amount>().unwrap()),
460            rewards_address: RewardsAddress::from(
461                decode_hex_array::<20>(&quote.rewards_address, "rewards").unwrap(),
462            ),
463            pub_key: hex::decode(&quote.public_key).unwrap(),
464            signature: hex::decode(&quote.signature).unwrap(),
465            committed_key_count: quote.committed_key_count,
466            commitment_pin: quote
467                .commitment_pin
468                .as_deref()
469                .map(|pin| decode_hex_array(pin, "pin").unwrap()),
470        }
471    }
472
473    #[test]
474    fn native_and_browser_validation_accept_and_reject_identical_signed_artifacts() {
475        use ant_protocol::transport::PeerId;
476        let (baseline, content, peer) = baseline_quote();
477        let (bound, _, bound_peer) = bound_quote(23);
478        let mut extra_baseline_sidecar = baseline.clone();
479        extra_baseline_sidecar.commitment = bound.commitment.clone();
480        extra_baseline_sidecar.commitment.as_mut().unwrap().encoded = "c1".into();
481        let mut missing = bound.clone();
482        missing.commitment = None;
483        let mut corrupt = bound.clone();
484        corrupt.commitment.as_mut().unwrap().encoded = "c1".into();
485        let mut wrong_key = baseline.clone();
486        wrong_key.public_key = bound.public_key.clone();
487        let mut bad_signature = baseline.clone();
488        bad_signature.signature = "00".repeat(3309);
489        let mut wrong_content = baseline.clone();
490        wrong_content.content = "01".repeat(32);
491        for (quote, peer, expected) in [
492            (baseline, peer.clone(), true),
493            (extra_baseline_sidecar, peer.clone(), true),
494            (bound, bound_peer.clone(), true),
495            (missing, bound_peer.clone(), false),
496            (corrupt, bound_peer, false),
497            (wrong_key, peer.clone(), false),
498            (bad_signature, peer.clone(), false),
499            (wrong_content, peer, false),
500        ] {
501            let native = native_quote(&quote);
502            let sidecar = quote
503                .commitment
504                .as_ref()
505                .map(|artifact| hex::decode(&artifact.encoded).unwrap());
506            let native_result = crate::data::client::quote::classify_quote_response(
507                &PeerId::from_bytes(decode_hex_array(&peer, "peer").unwrap()),
508                &decode_hex_array(&content, "content").unwrap(),
509                &rmp_serde::to_vec(&native).unwrap(),
510                false,
511                sidecar,
512            );
513            assert_eq!(native_result.is_ok(), expected);
514            assert_eq!(
515                verify_storage_quote(quote, &content, &peer).is_ok(),
516                expected
517            );
518        }
519    }
520
521    #[test]
522    fn browser_and_native_adapters_select_the_same_signed_quote_and_payment() {
523        use crate::data::client::batch::SingleNodeQuotePayment;
524        for (counts, expected_index) in [
525            (vec![1_000_000, 0, 0, 0, 0, 0, 0], 4),
526            (vec![0, 6000, 6000, 6000, 6000, 6000, 6000], 3),
527            (vec![6000, 1000, 4000, 2000], 2),
528            (vec![23, 23, 23, 23], 2),
529            (vec![23], 0),
530        ] {
531            let verified = counts
532                .iter()
533                .map(|&count| {
534                    let (quote, address, peer) = if count == 0 {
535                        baseline_quote()
536                    } else {
537                        bound_quote(count)
538                    };
539                    verify_storage_quote(quote, &address, &peer).expect("valid signed quote")
540                })
541                .collect::<Vec<_>>();
542            let expected_hash = verified[expected_index].quote_hash.clone();
543            let native_quotes = verified
544                .iter()
545                .map(|verified| native_quote(&verified.quote))
546                .collect();
547            let native = SingleNodeQuotePayment::from_quotes(native_quotes).expect("native plan");
548            let browser = select_storage_quote(verified).expect("browser plan");
549            let paid = native
550                .quotes
551                .iter()
552                .filter(|quote| !quote.amount.is_zero())
553                .collect::<Vec<_>>();
554            assert_eq!(paid.len(), 1);
555            assert_eq!(hex::encode(paid[0].quote_hash), expected_hash);
556            assert_eq!(browser.quote_hash, expected_hash);
557            assert_eq!(browser.amount, native.total_amount().to_string());
558            assert_eq!(
559                browser.amount,
560                (calculate_price_wei(counts[expected_index]) * 3).to_string()
561            );
562            assert_eq!(native.quotes.len(), counts.len());
563        }
564    }
565}
566
567/// Native Merkle vault transaction exposed to an external browser wallet.
568#[derive(Debug, Clone, Serialize, Deserialize)]
569#[serde(rename_all = "camelCase")]
570pub struct MerklePaymentRequest {
571    /// Canonical vault calldata generated by evmlib.
572    pub calldata: String,
573    /// Maximum spend across possible winning pools, for token approval.
574    pub maximum_amount: String,
575    /// Expected tree depth in the confirmed event.
576    pub depth: u8,
577    /// Expected payment timestamp in the confirmed event.
578    pub timestamp: u64,
579    /// Eligible winning pool hashes.
580    pub pool_hashes: Vec<String>,
581}
582
583impl MerklePaymentRequest {
584    /// Build a wallet request from the ordinary native prepared batch.
585    pub fn from_batch(batch: &crate::data::client::merkle::PreparedMerkleBatch) -> Self {
586        Self {
587            calldata: format!(
588                "0x{}",
589                hex::encode(
590                    ant_protocol::evm::contract::payment_vault::encode_merkle_payment(
591                        batch.depth,
592                        batch.pool_commitments.clone(),
593                        batch.merkle_payment_timestamp
594                    )
595                )
596            ),
597            maximum_amount: ant_protocol::evm::Network::default()
598                .estimate_merkle_payment_cost(batch.depth, &batch.pool_commitments)
599                .to_string(),
600            depth: batch.depth,
601            timestamp: batch.merkle_payment_timestamp,
602            pool_hashes: batch
603                .pool_commitments
604                .iter()
605                .map(|pool| hex::encode(pool.pool_hash))
606                .collect(),
607        }
608    }
609}
610
611/// JSON representation of an EVM transaction log.
612#[derive(Deserialize)]
613pub struct PaymentLog {
614    /// Emitting contract.
615    pub address: String,
616    /// Event topics.
617    pub topics: Vec<String>,
618    /// Encoded event fields.
619    pub data: String,
620}
621
622/// Confirmed Merkle event, decoded with the native vault ABI.
623#[derive(Serialize)]
624#[serde(rename_all = "camelCase")]
625pub struct MerkleSettlement {
626    /// Selected pool from the prepared request.
627    pub winner_pool_hash: String,
628    /// Actual token spend.
629    pub total_amount: String,
630}
631
632/// Validate a receipt's vault event against the prepared Merkle request.
633pub fn decode_merkle_receipt(
634    request: &MerklePaymentRequest,
635    vault: &str,
636    logs: &[PaymentLog],
637) -> Result<MerkleSettlement, StorageQuoteError> {
638    let vault = normalize_hex(vault, 20).map_err(StorageQuoteError)?;
639    for log in logs {
640        if normalize_hex(&log.address, 20).map_err(StorageQuoteError)? != vault {
641            continue;
642        }
643        let topics = log
644            .topics
645            .iter()
646            .map(|topic| decode_hex_array(topic.strip_prefix("0x").unwrap_or(topic), "event topic"))
647            .collect::<Result<Vec<[u8; 32]>, _>>()?;
648        let data = decode_unbounded_hex(&log.data, "event data")?;
649        let Ok(event) =
650            ant_protocol::evm::contract::payment_vault::decode_merkle_payment_event(&topics, &data)
651        else {
652            continue;
653        };
654        let winner = hex::encode(event.winnerPoolHash);
655        if event.depth != request.depth
656            || event.merklePaymentTimestamp != request.timestamp
657            || !request.pool_hashes.contains(&winner)
658        {
659            continue;
660        }
661        if event.totalAmount
662            > parse_decimal_amount(&request.maximum_amount, "maximum Merkle payment")?
663        {
664            return Err(StorageQuoteError(
665                "Merkle event exceeds authorized amount".into(),
666            ));
667        }
668        return Ok(MerkleSettlement {
669            winner_pool_hash: winner,
670            total_amount: event.totalAmount.to_string(),
671        });
672    }
673    Err(StorageQuoteError(
674        "receipt has no matching Merkle payment event".into(),
675    ))
676}