Skip to main content

miden_tx_script_args/
lib.rs

1//! Typed transaction-script argument transport via the `TX_SCRIPT_ARGS` word.
2//!
3//! The transaction kernel hands a transaction script a single [`Word`] of arguments. This crate
4//! defines how typed argument values travel through that word: short, statically sized encodings
5//! are packed into the word itself, while longer or variable-length encodings are committed to by
6//! hash and passed through the advice provider.
7//!
8//! The crate is shared between on-chain and off-chain code: [`ScriptArgs::encode`],
9//! [`decode_preimage`], and word-mode [`ScriptArgs::decode`] are pure and run anywhere, while
10//! the advice-provider transport sits behind the `miden-vm-guest` feature (enabled by the
11//! `miden` SDK crate) — no host build, native or wasm, depends on the on-chain SDK bindings.
12
13#![no_std]
14#![deny(warnings)]
15
16extern crate alloc;
17
18use alloc::vec::Vec;
19
20use miden_field::{Felt, Word};
21use miden_field_repr::{FeltReader, FeltReprError, FromFeltRepr, ToFeltRepr};
22
23/// Number of felts packed into a [`Word`].
24const WORD_FELTS: usize = Word::NUM_ELEMENTS;
25
26/// Encoded transaction-script arguments produced by [`ScriptArgs::encode`].
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum EncodedScriptArgs {
29    /// The encoding statically fits the `TX_SCRIPT_ARGS` word and is packed into it directly,
30    /// zero-padded.
31    Word(Word),
32    /// The type uses commitment mode (a longer or variable-length encoding): these felts
33    /// (zero-padded to a whole number of words) are the advice-map preimage of the args word.
34    /// The host hashes them with `Poseidon2::hash_elements` — the Miden VM's native hash — to
35    /// obtain the args word, and registers `(args_word, felts)` in the advice map.
36    Preimage(Vec<Felt>),
37}
38
39/// Failure decoding transaction-script arguments from the `TX_SCRIPT_ARGS` word.
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum ScriptArgsError {
43    /// A value failed to decode from its felt representation.
44    Decode(FeltReprError),
45    /// A felt in the zero-padding region of the encoding was not zero.
46    NonZeroPadding,
47    /// A whole word or more of data remained after decoding the value (commitment mode).
48    TrailingData,
49    /// The advice value's length was not a whole number of words (commitment mode).
50    NonWordMultipleLength,
51    /// Commitment-mode decoding was attempted without the Miden VM's advice provider
52    /// (i.e. off-chain). Word-mode decoding and [`decode_preimage`] work everywhere.
53    AdviceProviderUnavailable,
54}
55
56impl From<FeltReprError> for ScriptArgsError {
57    fn from(err: FeltReprError) -> Self {
58        Self::Decode(err)
59    }
60}
61
62impl core::fmt::Display for ScriptArgsError {
63    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
64        match self {
65            Self::Decode(err) => write!(f, "failed to decode tx script args: {err}"),
66            Self::NonZeroPadding => f.write_str("non-zero padding felt in tx script args"),
67            Self::TrailingData => f.write_str("trailing data after tx script args"),
68            Self::NonWordMultipleLength => {
69                f.write_str("tx script args advice value is not a whole number of words")
70            }
71            Self::AdviceProviderUnavailable => {
72                f.write_str("commitment-mode tx script args can only be decoded on the Miden VM")
73            }
74        }
75    }
76}
77
78/// Convenience alias for results returned by script-args decoding.
79pub type ScriptArgsResult<T> = core::result::Result<T, ScriptArgsError>;
80
81mod sealed {
82    /// Seals [`ScriptArgs`](super::ScriptArgs) to its blanket implementation.
83    pub trait Sealed {}
84    impl<T: super::FromFeltRepr + super::ToFeltRepr> Sealed for T {}
85}
86
87/// Transaction-script arguments transported through the `TX_SCRIPT_ARGS` word.
88///
89/// Every type that implements both [`FromFeltRepr`] and [`ToFeltRepr`] is a `ScriptArgs`
90/// automatically. The felt-repr encoding selects one of two transport modes at compile time:
91///
92/// - **Word mode** ([`FIXED_LEN`](Self::FIXED_LEN) of at most 4 felts): the encoding is packed
93///   directly into the args word; the unused felts are zero, and [`decode`](Self::decode) rejects
94///   anything else.
95/// - **Commitment mode** (longer or variable-length encodings): the args word is the Poseidon2
96///   hash of the zero-padded encoding. [`decode`](Self::decode) fetches the preimage from the
97///   advice provider and the hash is verified in-VM, so the host cannot substitute values.
98///
99/// The mode is a compile-time property of the argument type, so within one type definition the
100/// encoder and decoder always agree on the transport mode. The word carries no field layout —
101/// host-side mirrors of a guest type must reproduce its felt-repr wire sequence (see the
102/// migration guide for how to pin that).
103///
104/// The trait is sealed: the blanket `FromFeltRepr + ToFeltRepr` implementation is the only one,
105/// which is what makes the documented transport guarantees hold for every implementor.
106pub trait ScriptArgs: Sized + sealed::Sealed {
107    /// Total encoded length in felts, when statically known.
108    const FIXED_LEN: Option<usize>;
109
110    /// Decodes the arguments from the `TX_SCRIPT_ARGS` word.
111    ///
112    /// Returns an error when the encoding is malformed: a felt-repr decode failure, non-zero
113    /// padding, or a non-canonical commitment preimage. The `#[tx_script]`-generated entrypoint
114    /// wrapper panics on the error, failing the transaction. Commitment-mode decoding requires
115    /// the Miden VM; word-mode decoding is pure.
116    fn decode(arg: Word) -> ScriptArgsResult<Self>;
117
118    /// Encodes the arguments for transaction construction on the host.
119    fn encode(&self) -> EncodedScriptArgs;
120}
121
122/// Returns whether a statically known encoded length fits the args word directly.
123const fn is_word_mode(fixed_len: Option<usize>) -> bool {
124    match fixed_len {
125        Some(len) => len <= WORD_FELTS,
126        None => false,
127    }
128}
129
130/// Ensures every felt remaining in `reader` is zero.
131#[inline(always)]
132fn check_zero_padding(reader: &mut FeltReader<'_>) -> ScriptArgsResult<()> {
133    while reader.remaining() > 0 {
134        if reader.read()? != Felt::ZERO {
135            return Err(ScriptArgsError::NonZeroPadding);
136        }
137    }
138    Ok(())
139}
140
141/// Fetches and decodes commitment-mode arguments from the advice provider.
142///
143/// The args word commits to the encoding: `adv_load_preimage` verifies the fetched preimage's
144/// hash against it in-VM.
145#[cfg(all(target_family = "wasm", miden, feature = "miden-vm-guest"))]
146#[inline(always)]
147fn decode_commitment<T: FromFeltRepr>(arg: Word) -> ScriptArgsResult<T> {
148    use miden_stdlib_sys::{adv_load_preimage, intrinsics::advice::adv_push_mapvaln};
149
150    let num_felts = adv_push_mapvaln(arg).as_canonical_u64();
151    if num_felts % WORD_FELTS as u64 != 0 {
152        return Err(ScriptArgsError::NonWordMultipleLength);
153    }
154    let num_words = Felt::new(num_felts / WORD_FELTS as u64).unwrap();
155    let preimage = adv_load_preimage(num_words, arg);
156    decode_preimage(&preimage)
157}
158
159/// Commitment-mode decoding requires the advice provider, which only exists on the Miden VM.
160#[cfg(not(all(target_family = "wasm", miden, feature = "miden-vm-guest")))]
161fn decode_commitment<T: FromFeltRepr>(_arg: Word) -> ScriptArgsResult<T> {
162    Err(ScriptArgsError::AdviceProviderUnavailable)
163}
164
165/// Decodes a value from a commitment-mode preimage, enforcing the whole-word length and the
166/// canonical zero padding.
167///
168/// This is the pure half of commitment-mode [`ScriptArgs::decode`]: it runs anywhere, so hosts
169/// can round-trip [`EncodedScriptArgs::Preimage`] bytes in tests and tooling without the VM.
170#[inline(always)]
171pub fn decode_preimage<T: FromFeltRepr>(preimage: &[Felt]) -> ScriptArgsResult<T> {
172    // Advice values are word-granular in-VM, so a non-word-multiple length can never reach the
173    // guest decoder; reject it here too so host-side validation agrees with the VM path.
174    if !preimage.len().is_multiple_of(WORD_FELTS) {
175        return Err(ScriptArgsError::NonWordMultipleLength);
176    }
177    let mut reader = FeltReader::new(preimage);
178    let value = T::from_felt_repr(&mut reader)?;
179    check_decoded_len::<T>(&reader)?;
180    // Only the zero felts padding the encoding to a whole number of words may remain.
181    if reader.remaining() >= WORD_FELTS {
182        return Err(ScriptArgsError::TrailingData);
183    }
184    check_zero_padding(&mut reader)?;
185    Ok(value)
186}
187
188/// Asserts a decoder consumed exactly its declared `FIXED_LEN` felts, catching manual
189/// implementations whose decode disagrees with the constant (the encode side is checked by
190/// [`ScriptArgs::encode`]).
191#[inline(always)]
192fn check_decoded_len<T: FromFeltRepr>(reader: &FeltReader<'_>) -> ScriptArgsResult<()> {
193    if let Some(fixed_len) = T::FIXED_LEN {
194        assert!(reader.pos() == fixed_len, "decoded length must match FIXED_LEN");
195    }
196    Ok(())
197}
198
199impl<T: FromFeltRepr + ToFeltRepr> ScriptArgs for T {
200    const FIXED_LEN: Option<usize> = <T as FromFeltRepr>::FIXED_LEN;
201
202    // Inlined so the caller's error match fuses with the decode and the `Result` never has to
203    // materialize in memory on the happy path.
204    #[inline(always)]
205    fn decode(arg: Word) -> ScriptArgsResult<Self> {
206        // A const-evaluated branch guarantees the dead transport path is never codegenned.
207        if const { is_word_mode(Self::FIXED_LEN) } {
208            let felts = [arg[0], arg[1], arg[2], arg[3]];
209            let mut reader = FeltReader::new(&felts);
210            let value = Self::from_felt_repr(&mut reader)?;
211            check_decoded_len::<Self>(&reader)?;
212            check_zero_padding(&mut reader)?;
213            Ok(value)
214        } else {
215            decode_commitment(arg)
216        }
217    }
218
219    fn encode(&self) -> EncodedScriptArgs {
220        let mut felts = self.to_felt_repr();
221        // Validate before selecting the transport: a wrong manual `FIXED_LEN` would otherwise
222        // silently truncate the args word or mis-route the encoding.
223        if let Some(fixed_len) = Self::FIXED_LEN {
224            assert!(felts.len() == fixed_len, "encoding length must match FIXED_LEN");
225        }
226        if const { is_word_mode(Self::FIXED_LEN) } {
227            felts.resize(WORD_FELTS, Felt::ZERO);
228            EncodedScriptArgs::Word(Word::new([felts[0], felts[1], felts[2], felts[3]]))
229        } else {
230            // Zero-pad to a whole number of words; the padding is part of the hashed preimage.
231            felts.resize(felts.len().next_multiple_of(WORD_FELTS), Felt::ZERO);
232            EncodedScriptArgs::Preimage(felts)
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use alloc::vec;
240
241    use super::*;
242
243    fn felt(value: u64) -> Felt {
244        Felt::new(value).unwrap()
245    }
246
247    /// Word mode packs the encoding into the args word and zero-pads the unused felts.
248    #[test]
249    fn word_mode_encode_pads_with_zeros() {
250        let EncodedScriptArgs::Word(word) = felt(7).encode() else {
251            panic!("expected word mode for a single felt");
252        };
253
254        assert_eq!(word, Word::new([felt(7), felt(0), felt(0), felt(0)]));
255    }
256
257    /// Word mode decodes straight from the args word, without the advice provider.
258    #[test]
259    fn word_mode_roundtrip() {
260        let value = felt(7);
261        let EncodedScriptArgs::Word(word) = value.encode() else {
262            panic!("expected word mode for a single felt");
263        };
264
265        assert_eq!(<Felt as ScriptArgs>::decode(word), Ok(value));
266    }
267
268    /// A full word of arguments is transported as-is.
269    #[test]
270    fn word_args_are_transported_verbatim() {
271        let word = Word::new([felt(1), felt(2), felt(3), felt(4)]);
272        let EncodedScriptArgs::Word(encoded) = word.encode() else {
273            panic!("expected word mode for a word");
274        };
275
276        assert_eq!(encoded, word);
277        assert_eq!(<Word as ScriptArgs>::decode(encoded), Ok(word));
278    }
279
280    /// Non-zero felts in the unused part of the args word fail the decode.
281    #[test]
282    fn word_mode_decode_rejects_nonzero_padding() {
283        let word = Word::new([felt(7), felt(0), felt(0), felt(1)]);
284
285        assert_eq!(<Felt as ScriptArgs>::decode(word), Err(ScriptArgsError::NonZeroPadding));
286    }
287
288    /// A word-mode decode error surfaces the underlying felt-repr failure.
289    #[test]
290    fn word_mode_decode_surfaces_felt_repr_errors() {
291        let word = Word::new([felt(2), felt(0), felt(0), felt(0)]);
292
293        assert_eq!(
294            <bool as ScriptArgs>::decode(word),
295            Err(ScriptArgsError::Decode(FeltReprError::InvalidBool {
296                pos: 0,
297                len: 4,
298                value: 2
299            }))
300        );
301    }
302
303    /// Commitment mode zero-pads the preimage to a whole number of words.
304    #[test]
305    fn commitment_mode_encode_pads_to_word_multiple() {
306        let values = vec![felt(5), felt(6)];
307        let EncodedScriptArgs::Preimage(felts) = values.encode() else {
308            panic!("expected commitment mode for a variable-length encoding");
309        };
310
311        // Length prefix, two elements, one felt of padding.
312        assert_eq!(felts, vec![felt(2), felt(5), felt(6), felt(0)]);
313    }
314
315    /// A manual implementation whose `FIXED_LEN` disagrees with its actual encoding.
316    struct LyingFixedLen;
317
318    impl FromFeltRepr for LyingFixedLen {
319        const FIXED_LEN: Option<usize> = Some(1);
320
321        fn from_felt_repr(reader: &mut FeltReader<'_>) -> miden_field_repr::FeltReprResult<Self> {
322            reader.read()?;
323            reader.read()?;
324            Ok(Self)
325        }
326    }
327
328    impl ToFeltRepr for LyingFixedLen {
329        fn write_felt_repr(&self, writer: &mut miden_field_repr::FeltWriter<'_>) {
330            writer.write(felt(1));
331            writer.write(felt(2));
332        }
333    }
334
335    /// A wrong manual `FIXED_LEN` must fail loudly instead of truncating the args word.
336    #[test]
337    #[should_panic(expected = "must match FIXED_LEN")]
338    fn encode_rejects_wrong_manual_fixed_len() {
339        let _ = LyingFixedLen.encode();
340    }
341
342    /// A decoder that consumes fewer felts than its declared `FIXED_LEN` must fail loudly
343    /// instead of mistaking argument felts for padding.
344    #[test]
345    #[should_panic(expected = "decoded length must match FIXED_LEN")]
346    fn decode_rejects_wrong_manual_fixed_len() {
347        /// Declares two felts but consumes one.
348        struct LyingDecoder;
349
350        impl FromFeltRepr for LyingDecoder {
351            const FIXED_LEN: Option<usize> = Some(2);
352
353            fn from_felt_repr(
354                reader: &mut FeltReader<'_>,
355            ) -> miden_field_repr::FeltReprResult<Self> {
356                reader.read()?;
357                Ok(Self)
358            }
359        }
360
361        impl ToFeltRepr for LyingDecoder {
362            fn write_felt_repr(&self, writer: &mut miden_field_repr::FeltWriter<'_>) {
363                writer.write(felt(1));
364                writer.write(felt(2));
365            }
366        }
367
368        let _ = LyingDecoder::decode(Word::new([felt(1), felt(2), felt(0), felt(0)]));
369    }
370
371    /// Off-VM, commitment-mode decoding reports the missing advice provider as an error.
372    #[test]
373    fn commitment_mode_decode_reports_missing_advice_provider() {
374        let word = Word::new([felt(1), felt(2), felt(3), felt(4)]);
375
376        assert_eq!(
377            <Vec<Felt> as ScriptArgs>::decode(word),
378            Err(ScriptArgsError::AdviceProviderUnavailable)
379        );
380    }
381
382    /// A commitment preimage with only zero padding decodes.
383    #[test]
384    fn decode_preimage_accepts_canonical_padding() {
385        let decoded: ScriptArgsResult<Vec<Felt>> =
386            decode_preimage(&[felt(2), felt(5), felt(6), felt(0)]);
387
388        assert_eq!(decoded, Ok(vec![felt(5), felt(6)]));
389    }
390
391    /// A non-zero felt in the padding region fails the decode.
392    #[test]
393    fn decode_preimage_rejects_nonzero_padding() {
394        let decoded: ScriptArgsResult<Vec<Felt>> =
395            decode_preimage(&[felt(2), felt(5), felt(6), felt(9)]);
396
397        assert_eq!(decoded, Err(ScriptArgsError::NonZeroPadding));
398    }
399
400    /// A non-word-multiple preimage fails the decode even when the value consumes it exactly,
401    /// matching the in-VM length check that runs before the advice value reaches the decoder.
402    #[test]
403    fn decode_preimage_rejects_non_word_multiple_length() {
404        let decoded: ScriptArgsResult<Vec<Felt>> = decode_preimage(&[felt(2), felt(5), felt(6)]);
405
406        assert_eq!(decoded, Err(ScriptArgsError::NonWordMultipleLength));
407    }
408
409    /// A whole extra word beyond the encoding fails the decode, even when it is all zeros.
410    #[test]
411    fn decode_preimage_rejects_extra_word() {
412        let decoded: ScriptArgsResult<Vec<Felt>> = decode_preimage(&[
413            felt(2),
414            felt(5),
415            felt(6),
416            felt(0),
417            felt(0),
418            felt(0),
419            felt(0),
420            felt(0),
421        ]);
422
423        assert_eq!(decoded, Err(ScriptArgsError::TrailingData));
424    }
425}