Skip to main content

hopper_runtime/
option_byte.rs

1//! Zero-copy, tag-validated optional values for instruction args.
2//!
3//! Rust's `Option<T>` has niche-optimizing layout rules that make it
4//! unsafe to pointer-cast from raw instruction bytes. `Option<u8>` is
5//! two bytes with an undefined tag range; `Option<&T>` uses null for
6//! `None`. Neither is a layout the caller controls.
7//!
8//! `OptionByte<T>` is the Hopper replacement for args. Layout:
9//!
10//! ```text
11//! #[repr(C)]
12//! { tag: u8, value: T }
13//! ```
14//!
15//! `tag == 0` is `None`, `tag == 1` is `Some`. Any other tag byte is
16//! a protocol error and [`OptionByte::get`] surfaces it as
17//! `ProgramError::InvalidInstructionData`.
18//!
19//! ## Usage
20//!
21//! ```ignore
22//! #[hopper::args]
23//! #[repr(C)]
24//! pub struct SwapArgs {
25//!     pub amount: u64,
26//!     pub referrer: OptionByte<[u8; 32]>,
27//!     pub slippage_bps: u16,
28//! }
29//!
30//! fn handler(ctx: Context<Swap>, args: &SwapArgs) -> ProgramResult {
31//!     if let Some(referrer) = args.referrer.get()? {
32//!         // referrer is &[u8; 32]
33//!     }
34//!     Ok(())
35//! }
36//! ```
37
38use crate::{error::ProgramError, result::ProgramResult};
39
40/// Zero-copy tagged optional. See module docs for the layout and
41/// usage contract.
42#[repr(C)]
43#[derive(Copy, Clone)]
44pub struct OptionByte<T: Copy> {
45    tag: u8,
46    value: T,
47}
48
49impl<T: Copy> OptionByte<T> {
50    /// Construct a `None` variant. Because the struct is `#[repr(C)]`
51    /// with a Pod value field, the `value` payload must still be
52    /// bitwise valid; the caller provides a default value that is
53    /// ignored by [`OptionByte::get`].
54    #[inline(always)]
55    pub const fn none(default_value: T) -> Self {
56        Self {
57            tag: 0,
58            value: default_value,
59        }
60    }
61
62    /// Construct a `Some(value)` variant.
63    #[inline(always)]
64    pub const fn some(value: T) -> Self {
65        Self { tag: 1, value }
66    }
67
68    /// The tag byte as the sender encoded it. Callers should never
69    /// inspect this directly; use [`OptionByte::get`] so the tag is
70    /// validated first.
71    #[inline(always)]
72    pub const fn raw_tag(&self) -> u8 {
73        self.tag
74    }
75
76    /// Validate the tag byte and return the appropriate Rust `Option`.
77    ///
78    /// Returns `Err(ProgramError::InvalidInstructionData)` when the
79    /// tag is neither `0` nor `1`. Any other byte indicates malformed
80    /// instruction data and is the exact surface a Quasar `OptionZc`
81    /// would flag in `validate_zc`.
82    #[inline]
83    pub fn get(&self) -> Result<Option<&T>, ProgramError> {
84        match self.tag {
85            0 => Ok(None),
86            1 => Ok(Some(&self.value)),
87            _ => Err(ProgramError::InvalidInstructionData),
88        }
89    }
90
91    /// Validate-only: confirms the tag byte is 0 or 1. Useful for
92    /// callers who want to reject malformed input early without
93    /// taking a reference to the payload.
94    #[inline]
95    pub fn validate_tag(&self) -> ProgramResult {
96        match self.tag {
97            0 | 1 => Ok(()),
98            _ => Err(ProgramError::InvalidInstructionData),
99        }
100    }
101}
102
103// SAFETY: `OptionByte<T>` is `repr(C) { tag: u8, value: T }`. With `T: Pod`
104// the payload has alignment 1, no padding, and no invalid bit pattern, so
105// the pair has alignment 1, no padding between or after the fields, and
106// every byte pattern is a valid value: a tag other than 0 or 1 is a
107// protocol error that `get` and `validate_tag` report, never undefined
108// behaviour.
109unsafe impl<T: crate::pod::Pod> crate::pod::Zeroable for OptionByte<T> {}
110// SAFETY: as above.
111unsafe impl<T: crate::pod::Pod> crate::pod::Pod for OptionByte<T> {}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn option_byte_over_a_pod_payload_is_pod() {
119        fn assert_pod<T: crate::pod::Pod>() {}
120        assert_pod::<OptionByte<[u8; 32]>>();
121        assert_pod::<OptionByte<u8>>();
122        assert_eq!(core::mem::align_of::<OptionByte<[u8; 32]>>(), 1);
123        assert_eq!(core::mem::size_of::<OptionByte<[u8; 32]>>(), 33);
124    }
125
126    /// Aligned, full-size scratch buffer for the pointer-cast tests
127    /// below. `OptionByte<u64>` is `#[repr(C)] { tag: u8, value: u64 }`,
128    /// which under C layout is **16 bytes** (7 padding bytes after the
129    /// tag; the value sits at offset 8) with alignment 8. The buffer
130    /// must therefore be 16 bytes and 8-aligned: a reference's referent
131    /// must be entirely in-bounds of one allocation, and an unaligned
132    /// cast trips the rustc 1.78+ debug misalignment check. This is
133    /// also why overlay-facing args use align-1 `Pod` payloads
134    /// (`OptionByte<[u8; 32]>`, wire types), a raw `u64` payload only
135    /// appears here to exercise tag validation.
136    #[repr(C, align(8))]
137    struct AlignedBuf([u8; core::mem::size_of::<OptionByte<u64>>()]);
138
139    #[test]
140    fn layout_is_c_with_value_at_align_of_t() {
141        // Pin the layout the module docs promise: tag at offset 0,
142        // value at `align_of::<T>()` under repr(C).
143        assert_eq!(core::mem::size_of::<OptionByte<u64>>(), 16);
144        assert_eq!(core::mem::size_of::<OptionByte<[u8; 32]>>(), 33);
145        assert_eq!(core::mem::align_of::<OptionByte<[u8; 32]>>(), 1);
146    }
147
148    #[test]
149    fn none_reads_as_none() {
150        let o: OptionByte<u64> = OptionByte::none(0);
151        assert!(o.get().unwrap().is_none());
152    }
153
154    #[test]
155    fn some_reads_back() {
156        let o = OptionByte::some(42u64);
157        assert_eq!(*o.get().unwrap().unwrap(), 42);
158    }
159
160    #[test]
161    fn malformed_tag_rejects() {
162        // Simulate a pointer-cast from hostile bytes: a 0xFF tag is
163        // neither 0 nor 1.
164        let mut buf = AlignedBuf([0u8; core::mem::size_of::<OptionByte<u64>>()]);
165        buf.0[0] = 0xFF;
166        // SAFETY: the buffer is 8-aligned and exactly
167        // `size_of::<OptionByte<u64>>()` bytes, so the referent is fully
168        // in-bounds; every byte pattern is inspected only through the
169        // validated `get`/`validate_tag` paths.
170        let o: &OptionByte<u64> = unsafe { &*(buf.0.as_ptr() as *const OptionByte<u64>) };
171        assert_eq!(o.get().unwrap_err(), ProgramError::InvalidInstructionData);
172        assert_eq!(
173            o.validate_tag().unwrap_err(),
174            ProgramError::InvalidInstructionData
175        );
176    }
177
178    #[test]
179    fn zero_tag_ignores_value_payload() {
180        // A None with garbage value bytes still decodes cleanly. The
181        // value field lives at offset 8 (after repr(C) padding), so the
182        // garbage goes there; not at offset 1.
183        let mut buf = AlignedBuf([0u8; core::mem::size_of::<OptionByte<u64>>()]);
184        buf.0[8..16].copy_from_slice(&0x1234_5678_9ABC_DEF0u64.to_le_bytes());
185        // SAFETY: as in `malformed_tag_rejects`, aligned, full-size,
186        // access through the validated path only.
187        let o: &OptionByte<u64> = unsafe { &*(buf.0.as_ptr() as *const OptionByte<u64>) };
188        assert!(o.get().unwrap().is_none());
189    }
190}