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}