Skip to main content

miden_usdcx/xreserve/encoding/
deposit_intent.rs

1//! Circle's DepositIntent: the message that authorizes one mint, and the bytes it travels in.
2//!
3//! The wire form is a fixed 240-byte big-endian header followed by variable-length hookData. On
4//! Miden it is carried as u32-little-endian-packed field elements, four wire bytes per element, so
5//! the header occupies exactly 60 of them. Every field sits at a fixed offset, which is what lets
6//! the MASM writer address fields directly instead of encoding sequentially.
7//!
8//! This module owns the BYTE format, in both directions: [`Serializable`] writes it and
9//! [`Deserializable`] reads it. Its sibling `mint_intent` owns the felt format, and the two
10//! conversions between the types. Nothing outside this module reads a wire offset — every access
11//! goes through [`DepositIntentField::offset`].
12//!
13//! Decoding owns the STRUCTURAL checks — the ones answering "is this a well-formed DepositIntent
14//! at all": the magic sentinel, the version, that the payload is not truncated, that the declared
15//! total length equals 240 plus the declared hookData length, and that the amount, `localToken`
16//! and `localDepositor` fields are non-zero.
17//!
18//! Validation order matters and is fixed, because the faucet's checks run in the same order and the
19//! two must reject identically — a payload that fails here must fail on-chain for the same reason,
20//! or off-chain pre-validation would pass work to the chain that then fails.
21//!
22//! Decoding also NARROWS every field to the domain type it has to hold for the deposit to be
23//! mintable at all: the bytes32 identifiers to account ids, and the two uint256 amounts to the
24//! units the faucet mints in. Circle's encoding leaves the identifier fields open — `remoteToken`
25//! and `remoteRecipient` are opaque bytes32 there, and how a Miden AccountId packs into one is
26//! still an open decision (`DEV-10`). That stays open as a LAYOUT question. What is not open is
27//! that a deposit whose identifiers do not read as account ids under the shipped layout cannot be
28//! minted under any of them: the faucet mints to an account id or not at all. Refusing it here
29//! costs nothing and gives the rejection a name, while a later layout decision changes only the
30//! packaging these conversions apply.
31//!
32//! WARNING: Do not deposit with more than 3,840 bytes of `hookData`. The deposit cannot be claimed
33//! on Miden, and the USDC remains locked on the source chain.
34
35use miden_protocol::account::{AccountId, StorageMapKey};
36use miden_protocol::asset::AssetAmount;
37use miden_protocol::utils::bytes_to_packed_u32_elements;
38use miden_protocol::utils::serde::{
39    ByteReader,
40    ByteWriter,
41    Deserializable,
42    DeserializationError,
43    Serializable,
44    SliceReader,
45};
46use miden_protocol::{Felt, MAX_NOTE_STORAGE_ITEMS, Word};
47use miden_standards::interop::eth::{EthAmount, EthEmbeddedAccountId};
48
49use super::account_id::EthEmbeddedAccountIdExt;
50use super::amount::uint256_to_asset_amount;
51use super::bytes32::{bytes32_to_packed_felts, bytes32_to_storage_map_key};
52use super::domain::CircleDomain;
53use super::error::EncodingError;
54use crate::note::xreserve_mint::XUSDC_MINT_TRANSPORT_HOOK_DATA_MAX_LEN;
55
56// WIRE-SHAPE CONSTANTS
57// ================================================================================================
58
59/// Bytes per u32-LE-packed field element.
60pub const BYTES_PER_PACKED_FELT: usize = 4;
61
62/// A bytes32 wire field, and the widths of the values carried right-aligned inside one: an
63/// AccountId as two big-endian u64s, an `AssetAmount` as a big-endian u64.
64pub const BYTES32_LEN: usize = 32;
65pub const ACCOUNT_ID_BYTES: usize = 16;
66pub const ASSET_AMOUNT_BYTES: usize = AssetAmount::SERIALIZED_SIZE;
67
68/// A whole bytes32 as packed field elements — the form the mint note's carried payload uses.
69pub const BYTES32_PACKED_LIMBS: usize = BYTES32_LEN / BYTES_PER_PACKED_FELT;
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum DepositIntentField {
73    Magic,
74    Version,
75    Amount,
76    RemoteDomain,
77    RemoteToken,
78    RemoteRecipient,
79    LocalToken,
80    LocalDepositor,
81    MaxFee,
82    Nonce,
83    HookDataLen,
84    HookData,
85}
86
87impl DepositIntentField {
88    /// The field's byte position on the wire.
89    pub const fn offset(self) -> usize {
90        match self {
91            Self::Magic => 0,
92            Self::Version => 4,
93            Self::Amount => 8,
94            Self::RemoteDomain => 40,
95            Self::RemoteToken => 44,
96            Self::RemoteRecipient => 76,
97            Self::LocalToken => 108,
98            Self::LocalDepositor => 140,
99            Self::MaxFee => 172,
100            Self::Nonce => 204,
101            Self::HookDataLen => 236,
102            Self::HookData => 240,
103        }
104    }
105}
106
107// DEPOSIT NONCE
108// ================================================================================================
109
110/// Circle's unique per-deposit nonce.
111///
112/// It drives two derived values and nothing else: the `usedNonces` replay-guard key and the
113/// attested output note's serial number, which are the same Word. Both go through the shared
114/// `DC-4` hashing routine, so this type only names the value and delegates.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct DepositNonce([u8; BYTES32_LEN]);
117
118impl DepositNonce {
119    /// Wraps a raw nonce. Any 32 bytes are a valid nonce — the hashing keeps it total.
120    pub const fn new(bytes: [u8; BYTES32_LEN]) -> Self {
121        Self(bytes)
122    }
123
124    /// The raw 32 bytes.
125    pub const fn as_bytes(&self) -> &[u8; BYTES32_LEN] {
126        &self.0
127    }
128
129    /// The 8 u32-LE-packed limbs the mint note's payload carries.
130    pub fn to_packed_felts(&self) -> [Felt; BYTES32_PACKED_LIMBS] {
131        bytes32_to_packed_felts(&self.0)
132    }
133
134    /// The replay-guard key and output-note serial (`DC-4`).
135    pub fn to_word(&self) -> Word {
136        self.to_storage_map_key().as_word()
137    }
138
139    /// The replay-guard key and output-note serial (`DC-4`).
140    pub fn to_storage_map_key(&self) -> StorageMapKey {
141        bytes32_to_storage_map_key(&self.0)
142    }
143}
144
145// FOREIGN CHAIN ADDRESS
146// ================================================================================================
147
148/// An address on a foreign chain — the deposited token, the depositor or a contract address.
149///
150/// These are opaque 32 bytes.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct ForeignChainAddress([u8; BYTES32_LEN]);
153
154impl ForeignChainAddress {
155    /// Wraps a raw address. Any 32 bytes are one — the faucet places no structure on them.
156    pub const fn new(bytes: [u8; BYTES32_LEN]) -> Self {
157        Self(bytes)
158    }
159
160    /// The raw 32 bytes.
161    pub const fn as_bytes(&self) -> &[u8; BYTES32_LEN] {
162        &self.0
163    }
164
165    /// The 8 u32-LE-packed limbs the mint note's payload carries.
166    pub fn to_packed_felts(&self) -> [Felt; BYTES32_PACKED_LIMBS] {
167        bytes32_to_packed_felts(&self.0)
168    }
169}
170
171// HOOK DATA
172// ================================================================================================
173
174/// The DepositIntent's opaque trailing payload.
175///
176/// It is carried because the signature covers it, and for no other reason — nothing on-chain reads
177/// it for effects. The length bound is checked here so that everything downstream, including the
178/// faucet's fixed staging region, can treat it as already-bounded.
179#[derive(Debug, Clone, Default, PartialEq, Eq)]
180pub struct HookData(Vec<u8>);
181
182impl HookData {
183    /// Maximum hookData length supported by the mint note, in bytes.
184    pub const MAX_LEN: usize = XUSDC_MINT_TRANSPORT_HOOK_DATA_MAX_LEN;
185
186    /// Wraps hookData bytes.
187    ///
188    /// # Errors
189    ///
190    /// [`EncodingError::HookDataTooLarge`] past [`Self::MAX_LEN`].
191    pub fn new(bytes: Vec<u8>) -> Result<Self, EncodingError> {
192        if bytes.len() > Self::MAX_LEN {
193            return Err(EncodingError::HookDataTooLarge);
194        }
195        Ok(Self(bytes))
196    }
197
198    /// The raw bytes.
199    pub fn as_bytes(&self) -> &[u8] {
200        &self.0
201    }
202
203    /// The declared wire length. Fits a u32 by the constructor's bound.
204    pub fn len_u32(&self) -> u32 {
205        u32::try_from(self.0.len()).expect("hook data length is bounded by HookData::MAX_LEN")
206    }
207
208    /// Whether there is any hookData at all (the common case is none).
209    pub fn is_empty(&self) -> bool {
210        self.0.is_empty()
211    }
212
213    /// The `ceil(len / 4)` u32-LE-packed felts the mint note's payload carries, trailing bytes
214    /// zero-filled.
215    pub fn to_packed_elements(&self) -> Vec<Felt> {
216        bytes_to_packed_u32_elements(&self.0)
217    }
218}
219
220// The transport bound above is only the tighter ceiling as long as the rebuilt preimage — a longer
221// fixed prefix than the transport's, plus the same hookData — still fits the faucet's staging
222// region. If a future header or transport change inverts that, `MAX_LEN` is on the wrong side.
223const _: () = assert!(
224    DepositIntent::HEADER_NUM_FELTS + HookData::MAX_LEN / BYTES_PER_PACKED_FELT
225        <= MAX_NOTE_STORAGE_ITEMS,
226    "the rebuilt preimage of a max-length hookData must fit the protocol's note-storage limit"
227);
228
229// DEPOSIT INTENT HEADER
230// ================================================================================================
231
232/// The fixed header's fields, each in the domain type the deposit must hold for it to be mintable.
233///
234/// - `magic` and `version` are absent because they are scheme constants: decoding checks them and
235///   encoding writes them.
236/// - `hookDataLen` is derived from the hookData.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, bon::Builder)]
238pub struct DepositIntentHeader {
239    amount: AssetAmount,
240    remote_domain: CircleDomain,
241    remote_token: AccountId,
242    remote_recipient: AccountId,
243    local_token: ForeignChainAddress,
244    local_depositor: ForeignChainAddress,
245    max_fee: AssetAmount,
246    nonce: DepositNonce,
247}
248
249impl DepositIntentHeader {
250    /// The scheme sentinel and version the header carries.
251    pub const MAGIC: u32 = 0x5a2e_0acd;
252    pub const VERSION: u32 = 1;
253
254    /// What [`Serializable`] writes: the header's own fields, `magic` through `nonce`. Circle's
255    /// fixed prefix is four bytes longer — see [`DepositIntent::HEADER_SIZE`].
256    pub const SERIALIZED_SIZE: usize = DepositIntentField::HookDataLen.offset();
257
258    /// The deposit amount, in the units the faucet mints.
259    pub fn amount(&self) -> AssetAmount {
260        self.amount
261    }
262
263    /// The destination domain the consuming faucet must have configured.
264    pub fn remote_domain(&self) -> CircleDomain {
265        self.remote_domain
266    }
267
268    /// The destination token — the faucet the deposit is addressed to.
269    pub fn remote_token(&self) -> AccountId {
270        self.remote_token
271    }
272
273    /// The account the minted asset is destined for.
274    pub fn remote_recipient(&self) -> AccountId {
275        self.remote_recipient
276    }
277
278    /// The deposited token on the source chain.
279    pub fn local_token(&self) -> ForeignChainAddress {
280        self.local_token
281    }
282
283    /// The depositor on the source chain.
284    pub fn local_depositor(&self) -> ForeignChainAddress {
285        self.local_depositor
286    }
287
288    /// The depositor-authorized fee ceiling, in the same units as [`Self::amount`].
289    pub fn max_fee(&self) -> AssetAmount {
290        self.max_fee
291    }
292
293    /// The per-deposit nonce.
294    pub fn nonce(&self) -> DepositNonce {
295        self.nonce
296    }
297
298    /// The typed decode of the header block, leaving the reader on `hookDataLen`.
299    fn read<R: ByteReader>(source: &mut R) -> Result<Self, EncodingError> {
300        // the bounds guard necessarily precedes any field read (the TruncatedHeader case)
301        let bytes: [u8; Self::SERIALIZED_SIZE] =
302            source.read_array().map_err(|_| EncodingError::TruncatedHeader)?;
303
304        if be_u32(&bytes, DepositIntentField::Magic) != Self::MAGIC {
305            return Err(EncodingError::BadMagic);
306        }
307        if be_u32(&bytes, DepositIntentField::Version) != Self::VERSION {
308            return Err(EncodingError::BadVersion);
309        }
310
311        // the zero checks run on the raw bytes and before the narrowings, because zero is a
312        // perfectly valid amount and a perfectly valid address: only the wire form distinguishes
313        // "the depositor sent nothing" from "this field was never filled in"
314        let amount = non_zero_bytes32(&bytes, DepositIntentField::Amount)?;
315        let local_token = non_zero_bytes32(&bytes, DepositIntentField::LocalToken)?;
316        let local_depositor = non_zero_bytes32(&bytes, DepositIntentField::LocalDepositor)?;
317
318        Ok(Self {
319            amount: reduce(amount, DepositIntentField::Amount)?,
320            remote_domain: CircleDomain::new(be_u32(&bytes, DepositIntentField::RemoteDomain)),
321            remote_token: account_id(&bytes, DepositIntentField::RemoteToken)?,
322            remote_recipient: account_id(&bytes, DepositIntentField::RemoteRecipient)?,
323            local_token: ForeignChainAddress::new(local_token),
324            local_depositor: ForeignChainAddress::new(local_depositor),
325            max_fee: reduce(
326                bytes32_at(&bytes, DepositIntentField::MaxFee),
327                DepositIntentField::MaxFee,
328            )?,
329            nonce: DepositNonce::new(bytes32_at(&bytes, DepositIntentField::Nonce)),
330        })
331    }
332}
333
334impl Serializable for DepositIntentHeader {
335    /// Writes the header's fields at their frozen offsets. The `hookDataLen` that closes Circle's
336    /// 240-byte prefix belongs to [`DepositIntent`], which appends it and the hookData itself.
337    fn write_into<W: ByteWriter>(&self, target: &mut W) {
338        let mut bytes = [0u8; Self::SERIALIZED_SIZE];
339
340        write_u32(&mut bytes, DepositIntentField::Magic, Self::MAGIC);
341        write_u32(&mut bytes, DepositIntentField::Version, Self::VERSION);
342        write_bytes32(&mut bytes, DepositIntentField::Amount, &widen(self.amount));
343        write_u32(&mut bytes, DepositIntentField::RemoteDomain, self.remote_domain.as_u32());
344        write_bytes32(
345            &mut bytes,
346            DepositIntentField::RemoteToken,
347            &EthEmbeddedAccountId::from_account_id(self.remote_token).to_bytes32(),
348        );
349        write_bytes32(
350            &mut bytes,
351            DepositIntentField::RemoteRecipient,
352            &EthEmbeddedAccountId::from_account_id(self.remote_recipient).to_bytes32(),
353        );
354        write_bytes32(&mut bytes, DepositIntentField::LocalToken, self.local_token.as_bytes());
355        write_bytes32(
356            &mut bytes,
357            DepositIntentField::LocalDepositor,
358            self.local_depositor.as_bytes(),
359        );
360        write_bytes32(&mut bytes, DepositIntentField::MaxFee, &widen(self.max_fee));
361        write_bytes32(&mut bytes, DepositIntentField::Nonce, self.nonce.as_bytes());
362
363        target.write_bytes(&bytes);
364    }
365
366    fn get_size_hint(&self) -> usize {
367        Self::SERIALIZED_SIZE
368    }
369}
370
371impl Deserializable for DepositIntentHeader {
372    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
373        Self::read(source).map_err(DeserializationError::from)
374    }
375}
376
377// DEPOSIT INTENT
378// ================================================================================================
379
380/// A decoded Circle DepositIntent: the typed header plus its trailing hookData.
381///
382/// Every instance has been through [`Deserializable`] or was assembled from values that already
383/// were, so holding one means the structural checks passed and every field is representable.
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct DepositIntent {
386    header: DepositIntentHeader,
387    hook_data: HookData,
388}
389
390impl DepositIntent {
391    /// Circle's fixed prefix: the header's fields plus the `hookDataLen` that closes it.
392    pub const HEADER_SIZE: usize = DepositIntentField::HookData.offset();
393
394    /// The same prefix as u32-LE-packed field elements (four wire bytes per element, so 60 — not
395    /// 30).
396    pub const HEADER_NUM_FELTS: usize = Self::HEADER_SIZE / BYTES_PER_PACKED_FELT;
397
398    /// Pairs a header with its hookData.
399    pub fn new(header: DepositIntentHeader, hook_data: HookData) -> Self {
400        Self { header, hook_data }
401    }
402
403    /// The header fields.
404    pub fn header(&self) -> &DepositIntentHeader {
405        &self.header
406    }
407
408    /// The opaque trailing payload.
409    pub fn hook_data(&self) -> &HookData {
410        &self.hook_data
411    }
412
413    /// The felt count of the u32-LE-packed preimage: `60 + ceil(hookDataLen / 4)`.
414    pub fn preimage_felt_len(&self) -> usize {
415        Self::HEADER_NUM_FELTS + self.hook_data.as_bytes().len().div_ceil(BYTES_PER_PACKED_FELT)
416    }
417
418    /// The u32-LE-packed on-chain preimage — the felts the attestation's digest is taken over.
419    /// Within the protocol's `MAX_NOTE_STORAGE_ITEMS` bound by [`HookData`]'s own invariant.
420    pub fn to_preimage_felts(&self) -> Vec<Felt> {
421        bytes_to_packed_u32_elements(&self.to_bytes())
422    }
423
424    /// The shared decoding implementation.
425    ///
426    /// Trailing bytes are rejected: the payload declares its own total length, so anything past it
427    /// means the sender and this decoder disagree about what was signed.
428    fn read<R: ByteReader>(source: &mut R) -> Result<Self, EncodingError> {
429        let header = DepositIntentHeader::read(source)?;
430
431        // hookDataLen closes Circle's fixed 240-byte prefix, so a payload that stops inside it is
432        // a truncated header rather than a length disagreement
433        let hook_data_len = source
434            .read_array::<BYTES_PER_PACKED_FELT>()
435            .map(u32::from_be_bytes)
436            .map_err(|_| EncodingError::TruncatedHeader)? as usize;
437
438        // the bound is applied to the DECLARED length, before the tail is read, so an adversarial
439        // length cannot drive an allocation
440        if hook_data_len > HookData::MAX_LEN {
441            return Err(EncodingError::HookDataTooLarge);
442        }
443        let hook_data =
444            source.read_vec(hook_data_len).map_err(|_| EncodingError::LengthMismatch)?;
445        if source.has_more_bytes() {
446            return Err(EncodingError::LengthMismatch);
447        }
448
449        Ok(Self::new(header, HookData::new(hook_data)?))
450    }
451}
452
453impl Serializable for DepositIntent {
454    /// Writes the canonical message the attestation signs: the header at its frozen offsets, then
455    /// the declared hookData length, then hookData.
456    fn write_into<W: ByteWriter>(&self, target: &mut W) {
457        self.header.write_into(target);
458        target.write_bytes(&self.hook_data.len_u32().to_be_bytes());
459        target.write_bytes(self.hook_data.as_bytes());
460    }
461
462    fn get_size_hint(&self) -> usize {
463        Self::HEADER_SIZE + self.hook_data.as_bytes().len()
464    }
465}
466
467impl Deserializable for DepositIntent {
468    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
469        Self::read(source).map_err(DeserializationError::from)
470    }
471}
472
473impl TryFrom<&[u8]> for DepositIntent {
474    type Error = EncodingError;
475
476    /// The entry point every off-chain caller takes, because it keeps the specific
477    /// [`EncodingError`] the payload earned rather than the protocol trait's flattened spelling.
478    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
479        Self::read(&mut SliceReader::new(bytes))
480    }
481}
482
483// HELPERS
484// ================================================================================================
485
486/// Reads a big-endian u32 wire field.
487fn be_u32(bytes: &[u8; DepositIntentHeader::SERIALIZED_SIZE], field: DepositIntentField) -> u32 {
488    let offset = field.offset();
489    u32::from_be_bytes(
490        bytes[offset..offset + BYTES_PER_PACKED_FELT].try_into().expect("4-byte window"),
491    )
492}
493
494/// Copies a 32-byte wire field.
495fn bytes32_at(
496    bytes: &[u8; DepositIntentHeader::SERIALIZED_SIZE],
497    field: DepositIntentField,
498) -> [u8; BYTES32_LEN] {
499    let offset = field.offset();
500    bytes[offset..offset + BYTES32_LEN].try_into().expect("32-byte window")
501}
502
503/// Copies a 32-byte wire field the scheme requires to be non-zero.
504fn non_zero_bytes32(
505    bytes: &[u8; DepositIntentHeader::SERIALIZED_SIZE],
506    field: DepositIntentField,
507) -> Result<[u8; BYTES32_LEN], EncodingError> {
508    let value = bytes32_at(bytes, field);
509    if value.iter().all(|byte| *byte == 0) {
510        return Err(EncodingError::ZeroField { field });
511    }
512    Ok(value)
513}
514
515/// Reduces one uint256 wire field to the `AssetAmount` it must hold, naming which of the two
516/// amount-shaped fields failed rather than only why: the relayer has to tell an unmintable
517/// `amount` from an unmintable `maxFee`.
518fn reduce(
519    value: [u8; BYTES32_LEN],
520    field: DepositIntentField,
521) -> Result<AssetAmount, EncodingError> {
522    uint256_to_asset_amount(EthAmount::new(value))
523        .map_err(|_| EncodingError::FieldNotAssetAmount { field })
524}
525
526/// Zero-extends a reduced amount back into the 32 big-endian bytes of its uint256 wire field.
527/// Exact, because the reduction that produced it ran at a scale of zero.
528fn widen(amount: AssetAmount) -> [u8; BYTES32_LEN] {
529    let mut bytes = [0u8; BYTES32_LEN];
530    bytes[BYTES32_LEN - ASSET_AMOUNT_BYTES..].copy_from_slice(&amount.as_u64().to_be_bytes());
531    bytes
532}
533
534/// Decodes a bytes32 identifier as the packaged Miden account id it has to be for the deposit to be
535/// mintable at all (`DEV-10`).
536fn account_id(
537    bytes: &[u8; DepositIntentHeader::SERIALIZED_SIZE],
538    field: DepositIntentField,
539) -> Result<AccountId, EncodingError> {
540    Ok(EthEmbeddedAccountId::try_from_bytes32(bytes32_at(bytes, field))?.into_account_id())
541}
542
543/// Writes a 4-byte big-endian wire field at its layout offset.
544fn write_u32(
545    out: &mut [u8; DepositIntentHeader::SERIALIZED_SIZE],
546    field: DepositIntentField,
547    value: u32,
548) {
549    let offset = field.offset();
550    out[offset..offset + BYTES_PER_PACKED_FELT].copy_from_slice(&value.to_be_bytes());
551}
552
553/// Writes a value into a bytes32 wire field, right-aligned behind a leading zero pad. A full
554/// 32-byte value fills the field; a narrower one (an amount, a nonce) lands at the end, which is
555/// the packaging every one of those fields uses.
556fn write_bytes32(
557    out: &mut [u8; DepositIntentHeader::SERIALIZED_SIZE],
558    field: DepositIntentField,
559    value: &[u8],
560) {
561    let start = field.offset() + BYTES32_LEN - value.len();
562    out[start..start + value.len()].copy_from_slice(value);
563}
564
565// TESTS — TV-DI-1..9
566// ================================================================================================
567
568#[cfg(test)]
569mod tests {
570    use assert_matches::assert_matches;
571    use rstest::rstest;
572
573    use super::*;
574    use crate::vectors::load;
575
576    /// TV-DI-1 (happy path, written first): a full header + hookData decodes to the exact per-field
577    /// values at the frozen wire offsets.
578    ///
579    /// The bytes32 fields are checked through the re-encoding rather than by comparing the decoded
580    /// domain types against the vector's hex: the wire bytes are the contract, and asserting them
581    /// at their offsets pins placement and value at once without re-deriving the packaging the
582    /// decoder just applied.
583    #[test]
584    fn tv_di_1_positive_parse() {
585        let v = load();
586        for vec in v.families.di.iter().filter(|v| v.kind == "accept") {
587            let bytes = vec.bytes();
588            let intent = DepositIntent::try_from(bytes.as_slice())
589                .unwrap_or_else(|e| panic!("vector {}: must parse, got {e}", vec.id));
590            let header = intent.header();
591            let f = vec.fields.as_ref().expect("accept vector carries fields");
592
593            assert_eq!(header.remote_domain(), f.remote_domain, "vector {}: remoteDomain", vec.id);
594            assert_eq!(
595                intent.hook_data().len_u32(),
596                f.hook_data_len,
597                "vector {}: hookDataLen",
598                vec.id
599            );
600            assert_eq!(u64::from(header.amount()), f.amount, "vector {}: amount", vec.id);
601            assert_eq!(u64::from(header.max_fee()), f.max_fee, "vector {}: maxFee", vec.id);
602
603            let written = intent.to_bytes();
604            for (name, field, label) in [
605                ("amount", DepositIntentField::Amount, "amount"),
606                ("remote_token", DepositIntentField::RemoteToken, "remoteToken"),
607                ("remote_recipient", DepositIntentField::RemoteRecipient, "remoteRecipient"),
608                ("local_token", DepositIntentField::LocalToken, "localToken"),
609                ("local_depositor", DepositIntentField::LocalDepositor, "localDepositor"),
610                ("max_fee", DepositIntentField::MaxFee, "maxFee"),
611                ("nonce", DepositIntentField::Nonce, "nonce"),
612            ] {
613                let offset = field.offset();
614                assert_eq!(
615                    &written[offset..offset + BYTES32_LEN],
616                    &f.bytes32(name),
617                    "vector {}: {label}",
618                    vec.id
619                );
620            }
621        }
622    }
623
624    /// The decode and the encode are inverses over every accept vector: what Circle signed is
625    /// exactly what this crate writes back. Everything downstream rests on it, because the faucet
626    /// verifies the signature over bytes it rebuilds rather than over bytes it was handed.
627    #[test]
628    fn round_trip_is_byte_exact() {
629        let v = load();
630        for vec in v.families.di.iter().filter(|v| v.kind == "accept") {
631            let bytes = vec.bytes();
632            let intent = DepositIntent::try_from(bytes.as_slice()).expect("accept vector decodes");
633            assert_eq!(intent.to_bytes(), bytes, "vector {}: round trip", vec.id);
634        }
635    }
636
637    /// TV-DI-2..6 (negative, parametrized): each structural violation rejects with its
638    /// SPECIFIC variant (one named case per frozen harness row).
639    #[rstest]
640    #[case::tv_di_2_bad_magic("di-rej-bad-magic")]
641    #[case::tv_di_3_bad_version("di-rej-bad-version")]
642    #[case::tv_di_4_zero_amount("di-rej-zero-amount")]
643    #[case::tv_di_5_zero_local_token("di-rej-zero-local-token")]
644    #[case::tv_di_5_zero_local_depositor("di-rej-zero-local-depositor")]
645    #[case::tv_di_6_length_mismatch("di-rej-length-mismatch")]
646    #[case::tv_di_6_truncated_header("di-rej-truncated")]
647    fn tv_di_rejects(#[case] id: &str) {
648        let v = load();
649        let vec = v.families.di.iter().find(|v| v.id == id).expect("vector present");
650        let result = DepositIntent::try_from(vec.bytes().as_slice());
651        match vec.expected_variant.as_deref() {
652            Some("BadMagic") => assert_matches!(result, Err(EncodingError::BadMagic), "{id}"),
653            Some("BadVersion") => assert_matches!(result, Err(EncodingError::BadVersion), "{id}"),
654            Some("ZeroField:Amount") => assert_matches!(
655                result,
656                Err(EncodingError::ZeroField { field: DepositIntentField::Amount }),
657                "{id}"
658            ),
659            Some("ZeroField:LocalToken") => assert_matches!(
660                result,
661                Err(EncodingError::ZeroField { field: DepositIntentField::LocalToken }),
662                "{id}"
663            ),
664            Some("ZeroField:LocalDepositor") => assert_matches!(
665                result,
666                Err(EncodingError::ZeroField {
667                    field: DepositIntentField::LocalDepositor
668                }),
669                "{id}"
670            ),
671            Some("LengthMismatch") => {
672                assert_matches!(result, Err(EncodingError::LengthMismatch), "{id}")
673            },
674            Some("TruncatedHeader") => {
675                assert_matches!(result, Err(EncodingError::TruncatedHeader), "{id}")
676            },
677            other => panic!("vector {id}: unexpected expected_variant {other:?}"),
678        }
679    }
680
681    /// A payload that stops inside Circle's fixed 240-byte prefix is a truncated header, whichever
682    /// side of the header/hookDataLen seam it stops on — the two are one block on the wire.
683    #[test]
684    fn a_payload_short_of_the_fixed_prefix_is_truncated() {
685        let v = load();
686        let vec = v.families.di.iter().find(|v| v.id == "di-pos-empty-hookdata").expect("vector");
687        let bytes = vec.bytes();
688        for len in [DepositIntentHeader::SERIALIZED_SIZE, DepositIntent::HEADER_SIZE - 1] {
689            assert_matches!(
690                DepositIntent::try_from(&bytes[..len]),
691                Err(EncodingError::TruncatedHeader),
692                "a {len}-byte payload stops inside the fixed prefix"
693            );
694        }
695    }
696
697    /// A payload followed by bytes that are not part of it is refused rather than silently
698    /// truncated to the message it claims to be — on both decode paths, since the check sits in the
699    /// codec rather than in one entry point.
700    #[test]
701    fn trailing_bytes_reject() {
702        let v = load();
703        let vec = v.families.di.iter().find(|v| v.id == "di-pos-hookdata").expect("vector");
704        let mut bytes = vec.bytes();
705        bytes.push(0x00);
706        assert_matches!(
707            DepositIntent::try_from(bytes.as_slice()),
708            Err(EncodingError::LengthMismatch)
709        );
710        assert_eq!(
711            DepositIntent::read_from_bytes(&bytes)
712                .expect_err("the standard reader refuses the trailing byte too")
713                .to_string(),
714            DeserializationError::from(EncodingError::LengthMismatch).to_string()
715        );
716    }
717
718    /// TV-DI-7 (boundary): the packed header is exactly 60 felts, equals the committed preimage,
719    /// stays within the 1024-felt bound, and the overflow vector rejects with `HookDataTooLarge`
720    /// (the hookData cap stays OPEN with Circle).
721    #[test]
722    fn tv_di_7_sixty_felts_and_1024_bound() {
723        let v = load();
724        for vec in v.families.di.iter().filter(|v| v.kind == "accept") {
725            let intent =
726                DepositIntent::try_from(vec.bytes().as_slice()).expect("accept vector decodes");
727            let felts = intent.to_preimage_felts();
728            let expected = vec.preimage_values();
729            assert_eq!(felts.len(), expected.len(), "vector {}: total felts", vec.id);
730            assert_eq!(
731                felts.len(),
732                intent.preimage_felt_len(),
733                "vector {}: derived felt length",
734                vec.id
735            );
736            assert_eq!(
737                &felts[..DepositIntent::HEADER_NUM_FELTS],
738                &expected[..DepositIntent::HEADER_NUM_FELTS],
739                "vector {}: 60-felt header",
740                vec.id
741            );
742            assert_eq!(felts, expected, "vector {}: full preimage", vec.id);
743            assert!(felts.len() <= MAX_NOTE_STORAGE_ITEMS, "vector {}: NoteStorage bound", vec.id);
744        }
745        let overflow = v
746            .families
747            .di
748            .iter()
749            .find(|v| v.id == "di-rej-hookdata-overflow")
750            .expect("vector");
751        assert_matches!(
752            DepositIntent::try_from(overflow.bytes().as_slice()),
753            Err(EncodingError::HookDataTooLarge),
754            "hookData past the 1024-felt bound must reject"
755        );
756    }
757
758    /// TV-DI-8 (determinism/immutability): decoding borrows the input immutably and the
759    /// bytes are unchanged afterwards (note-input immutability analogue).
760    #[test]
761    fn tv_di_8_input_immutable() {
762        let v = load();
763        let vec = v.families.di.iter().find(|v| v.id == "di-pos-hookdata").expect("vector");
764        let bytes = vec.bytes();
765        let before = bytes.clone();
766        let _ = DepositIntent::try_from(bytes.as_slice());
767        assert_eq!(bytes, before, "input must be unchanged by parsing");
768    }
769
770    /// TV-DI-9 (layout table): every field offset equals the wire-format byte offset.
771    #[test]
772    fn tv_di_9_offsets_table() {
773        let expected: [(DepositIntentField, usize); 12] = [
774            (DepositIntentField::Magic, 0),
775            (DepositIntentField::Version, 4),
776            (DepositIntentField::Amount, 8),
777            (DepositIntentField::RemoteDomain, 40),
778            (DepositIntentField::RemoteToken, 44),
779            (DepositIntentField::RemoteRecipient, 76),
780            (DepositIntentField::LocalToken, 108),
781            (DepositIntentField::LocalDepositor, 140),
782            (DepositIntentField::MaxFee, 172),
783            (DepositIntentField::Nonce, 204),
784            (DepositIntentField::HookDataLen, 236),
785            (DepositIntentField::HookData, 240),
786        ];
787        for (field, off) in expected {
788            assert_eq!(field.offset(), off, "wire offset of {field:?}");
789        }
790    }
791
792    /// hookData past the bound is refused at construction, so nothing downstream — including the
793    /// faucet's fixed staging region — has to re-check it.
794    #[test]
795    fn hook_data_bound_is_enforced_at_construction() {
796        assert_matches!(
797            HookData::new(vec![0u8; HookData::MAX_LEN + 1]),
798            Err(EncodingError::HookDataTooLarge)
799        );
800        assert!(HookData::new(vec![0u8; HookData::MAX_LEN]).is_ok());
801    }
802}