Skip to main content

hopper_runtime/
behavior.rs

1//! Reusable, parameterized account lifecycle behaviors.
2//!
3//! # What this is
4//!
5//! A protocol can implement a behavior once, provide per-use arguments, and
6//! call the typed phase helpers for each account. A proposed context-attribute
7//! surface is documented in `docs/design/BEHAVIORS_RFC.md`; it is not part of
8//! the current macro API:
9//!
10//! ```ignore
11//! #[hopper::context(strict_writes)]
12//! struct Collect<'info> {
13//!     #[account(mut(collected), behavior(fee_vault, max_bps = 30))]
14//!     vault: Vault,
15//!     ...
16//! }
17//! ```
18//!
19//! The runtime traits and helper functions are available for explicit use
20//! today. The example attribute above remains design work tracked in the RFC.
21//!
22//! # Runtime contracts
23//!
24//! 1. **Proof tokens.** A successful [`run_check`] returns
25//!    [`BehaviorChecked<B>`] (plus a behavior-defined payload), which
26//!    composes with the existing [`AccountProof`](crate::proof::AccountProof)
27//!    capability chain. APIs can require that token as evidence that the
28//!    check helper completed successfully.
29//! 2. **Write-set contribution.** A behavior declares the byte ranges
30//!    its `update`/`exit` phases write ([`HopperBehavior::WRITES`],
31//!    field-relative). Explicit callers and future code generation can add
32//!    these descriptors to a context's write policy. This module does not
33//!    install a context policy automatically.
34//!
35//! # Phase model
36//!
37//! Phases mirror the account lifecycle. Associated constants state which
38//! helpers a behavior enables:
39//!
40//! ```text
41//! phase   const        runs                          receives
42//! ------- ------------ ----------------------------- -----------------
43//! check   RUN_CHECK    after load, before handler    &view, &state
44//! update  RUN_UPDATE   after check (mut fields)      &view, &mut state
45//! exit    RUN_EXIT     epilogue (mut fields)         &view
46//! ```
47
48use core::marker::PhantomData;
49
50use crate::account::AccountView;
51use crate::error::ProgramError;
52use crate::layout::LayoutContract;
53use crate::ProgramResult;
54
55/// A field-relative byte range a behavior writes during `update`/`exit`.
56///
57/// Offsets are relative to the attached account's data start (the same
58/// absolute-offset convention the segment primitives use once the macro
59/// knows the account); the macro resolves the account index and folds
60/// the range into the context's write-policy `WritePolicy` as
61/// `WriteRange::new(index, offset, size)`.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub struct BehaviorWrite {
64    /// Byte offset within the attached account's data.
65    pub offset: u32,
66    /// Byte size of the written range.
67    pub size: u32,
68}
69
70impl BehaviorWrite {
71    /// Declare a write range at `offset` spanning `size` bytes.
72    #[inline(always)]
73    pub const fn new(offset: u32, size: u32) -> Self {
74        Self { offset, size }
75    }
76}
77
78/// A packageable per-field lifecycle plugin over layout type `T`.
79///
80/// Implement on a unit struct; attach per-field with parameterized args.
81/// All phase methods default to no-ops so a behavior overrides only what
82/// it needs. See the module docs for the phase table and proof-token and
83/// write-range contracts.
84pub trait HopperBehavior<T: LayoutContract> {
85    /// Per-use parameters, such as a `max_bps` value. Callers construct this
86    /// value and pass it to the phase helpers.
87    type Args;
88
89    /// Behavior-defined proof payload returned by a successful `check`
90    /// (use `()` when the token alone is enough). Carried inside
91    /// [`BehaviorChecked<B>`].
92    type CheckOutput;
93
94    /// Whether `check` runs after load. The default is enabled; a behavior that
95    /// validates nothing should say so explicitly).
96    const RUN_CHECK: bool = true;
97
98    /// Whether `update` runs after validation. Requires a `mut` field.
99    const RUN_UPDATE: bool = false;
100
101    /// Whether `exit` runs in the epilogue. Requires a `mut` field.
102    const RUN_EXIT: bool = false;
103
104    /// Whether the attached field must be mutable.
105    const REQUIRES_MUT: bool = Self::RUN_UPDATE || Self::RUN_EXIT;
106
107    /// Field-relative byte ranges `update`/`exit` may write. Callers or code
108    /// generation can incorporate these ranges into a context `WritePolicy`.
109    /// An empty slice declares no behavior-owned write ranges.
110    const WRITES: &'static [BehaviorWrite] = &[];
111
112    /// Validate the loaded state; return the proof payload.
113    fn check(
114        view: &AccountView<'_>,
115        state: &T,
116        args: &Self::Args,
117    ) -> Result<Self::CheckOutput, ProgramError> {
118        let _ = (view, state, args);
119        Err(ProgramError::InvalidArgument)
120    }
121
122    /// Mutate state after validation (only with `RUN_UPDATE`).
123    fn update(view: &AccountView<'_>, state: &mut T, args: &Self::Args) -> ProgramResult {
124        let _ = (view, state, args);
125        Ok(())
126    }
127
128    /// Epilogue hook (only with `RUN_EXIT`).
129    fn exit(view: &AccountView<'_>, args: &Self::Args) -> ProgramResult {
130        let _ = (view, args);
131        Ok(())
132    }
133}
134
135/// Proof token: behavior `B` ran its `check` phase against an account
136/// and succeeded, yielding `B::CheckOutput`.
137///
138/// The `B` type parameter identifies the behavior that performed the check.
139/// APIs that require behavior-validated accounts can
140/// take this token (or an [`AccountProof`](crate::proof::AccountProof)
141/// composed with it) instead of a bare view.
142pub struct BehaviorChecked<B, O> {
143    /// The behavior-defined check payload.
144    pub output: O,
145    _behavior: PhantomData<B>,
146}
147
148impl<B, O> BehaviorChecked<B, O> {
149    #[inline(always)]
150    fn new(output: O) -> Self {
151        Self {
152            output,
153            _behavior: PhantomData,
154        }
155    }
156}
157
158/// Run behavior `B`'s `check` phase against `view`, loading the typed
159/// state through the normal validated path, and mint the proof token.
160///
161/// This is the explicit form that future context integration can call for
162/// each `behavior(...)` attachment. `RUN_CHECK = false` behaviors
163/// yield an error here rather than a vacuous proof: a token must mean
164/// the check actually ran.
165#[inline]
166pub fn run_check<B, T>(
167    view: &AccountView<'_>,
168    args: &B::Args,
169) -> Result<BehaviorChecked<B, B::CheckOutput>, ProgramError>
170where
171    T: LayoutContract + crate::Pod,
172    B: HopperBehavior<T>,
173{
174    if !B::RUN_CHECK {
175        return Err(ProgramError::InvalidArgument);
176    }
177    let state = view.load::<T>()?;
178    let output = B::check(view, &state, args)?;
179    Ok(BehaviorChecked::new(output))
180}
181
182/// Run behavior `B`'s `update` phase through the typed mutable path.
183///
184/// Requires the proof token from [`run_check`], so this helper cannot run an
185/// update without a successful check token of the same behavior type.
186#[inline]
187pub fn run_update<B, T>(
188    view: &AccountView<'_>,
189    args: &B::Args,
190    _proof: &BehaviorChecked<B, B::CheckOutput>,
191) -> ProgramResult
192where
193    T: LayoutContract + crate::Pod,
194    B: HopperBehavior<T>,
195{
196    if !B::RUN_UPDATE {
197        return Ok(());
198    }
199    let mut state = view.load_mut::<T>()?;
200    B::update(view, &mut state, args)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::layout::HopperHeader;
207    use hopper_native::{
208        AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount, NOT_BORROWED,
209    };
210
211    // A vault whose `collected_bps` must stay under a per-attachment cap.
212    #[repr(C)]
213    #[derive(Clone, Copy)]
214    struct FeeVault {
215        collected_bps: [u8; 2],
216        _pad: [u8; 6],
217    }
218    // SAFETY: repr(C), byte-array fields, align 1, no padding, all
219    // patterns valid.
220    unsafe impl crate::Zeroable for FeeVault {}
221    // SAFETY: as above.
222    unsafe impl crate::Pod for FeeVault {}
223    impl crate::field_map::FieldMap for FeeVault {
224        const FIELDS: &'static [crate::field_map::FieldInfo] = &[crate::field_map::FieldInfo::new(
225            "collected_bps",
226            HopperHeader::SIZE,
227            2,
228        )];
229    }
230    impl LayoutContract for FeeVault {
231        const DISC: u8 = 42;
232        const VERSION: u8 = 1;
233        const LAYOUT_ID: [u8; 8] = [0x42; 8];
234        const SIZE: usize = HopperHeader::SIZE + core::mem::size_of::<Self>();
235    }
236
237    /// The packageable plugin: "fee take must not exceed `max_bps`".
238    struct FeeCap;
239    struct FeeCapArgs {
240        max_bps: u16,
241    }
242    impl HopperBehavior<FeeVault> for FeeCap {
243        type Args = FeeCapArgs;
244        /// Check returns the observed bps so downstream code can use it
245        /// without re-reading state.
246        type CheckOutput = u16;
247        const WRITES: &'static [BehaviorWrite] = &[BehaviorWrite::new(
248            HopperHeader::SIZE as u32,
249            2, // collected_bps
250        )];
251        const RUN_UPDATE: bool = true;
252
253        fn check(
254            _view: &AccountView<'_>,
255            state: &FeeVault,
256            args: &Self::Args,
257        ) -> Result<u16, ProgramError> {
258            let bps = u16::from_le_bytes(state.collected_bps);
259            if bps > args.max_bps {
260                return Err(ProgramError::InvalidAccountData);
261            }
262            Ok(bps)
263        }
264
265        fn update(
266            _view: &AccountView<'_>,
267            state: &mut FeeVault,
268            args: &Self::Args,
269        ) -> ProgramResult {
270            // Clamp to the cap, a deliberate, declared write to the
271            // `collected_bps` range in `WRITES`.
272            let bps = u16::from_le_bytes(state.collected_bps).min(args.max_bps);
273            state.collected_bps = bps.to_le_bytes();
274            Ok(())
275        }
276    }
277
278    fn make_vault(bps: u16) -> (std::vec::Vec<u64>, AccountView<'static>) {
279        let data_len = FeeVault::SIZE;
280        let mut backing = std::vec![0u64; (RuntimeAccount::SIZE + data_len).div_ceil(8)];
281        let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
282        // SAFETY: backing is sized for header + data and outlives the view.
283        unsafe {
284            raw.write(RuntimeAccount {
285                borrow_state: NOT_BORROWED,
286                is_signer: 0,
287                is_writable: 1,
288                executable: 0,
289                resize_delta: 0,
290                address: NativeAddress::new_from_array([1; 32]),
291                owner: NativeAddress::new_from_array([2; 32]),
292                lamports: 1,
293                data_len: data_len as u64,
294            });
295        }
296        // SAFETY: raw points at a fully initialized RuntimeAccount.
297        let backend = unsafe { NativeAccountView::new_unchecked(raw) };
298        let view = AccountView::from_backend(backend);
299        {
300            let mut d = view.try_borrow_mut().unwrap();
301            crate::layout::init_header::<FeeVault>(&mut d).unwrap();
302            d[HopperHeader::SIZE..HopperHeader::SIZE + 2].copy_from_slice(&bps.to_le_bytes());
303        }
304        (backing, view)
305    }
306
307    #[test]
308    fn check_mints_proof_with_payload_and_rejects_violations() {
309        let (_b, vault) = make_vault(25);
310        let args = FeeCapArgs { max_bps: 30 };
311
312        let proof = run_check::<FeeCap, FeeVault>(&vault, &args).unwrap();
313        assert_eq!(proof.output, 25);
314
315        // Over the cap: no proof is minted.
316        let (_b2, hot) = make_vault(31);
317        assert!(run_check::<FeeCap, FeeVault>(&hot, &args).is_err());
318    }
319
320    #[test]
321    fn update_requires_the_proof_and_applies_declared_writes() {
322        let (_b, vault) = make_vault(30);
323        let args = FeeCapArgs { max_bps: 30 };
324
325        // The signature makes ordering structural: update takes the
326        // token check minted; there is no way to call it first.
327        let proof = run_check::<FeeCap, FeeVault>(&vault, &args).unwrap();
328        run_update::<FeeCap, FeeVault>(&vault, &args, &proof).unwrap();
329
330        let state = vault.load::<FeeVault>().unwrap();
331        assert_eq!(u16::from_le_bytes(state.collected_bps), 30);
332    }
333
334    #[test]
335    fn write_contribution_is_declared_for_strict_writes_folding() {
336        // The macro folds these into the context's static WritePolicy;
337        // pin the shape the FeeCap plugin declares.
338        assert_eq!(
339            <FeeCap as HopperBehavior<FeeVault>>::WRITES,
340            &[BehaviorWrite::new(HopperHeader::SIZE as u32, 2)]
341        );
342        const { assert!(<FeeCap as HopperBehavior<FeeVault>>::REQUIRES_MUT) };
343    }
344}