Skip to main content

hopper_schema/
accounts.rs

1//! Context-level schema metadata for the Account DSL.
2//!
3//! Provides descriptor types that capture context (instruction account struct)
4//! metadata for inclusion in ProgramManifest, HopperIdl, and CLI explain output.
5//! These are the schema-layer counterparts of the runtime AccountFieldSchema
6//! and ContextSchema types in hopper-core.
7
8use core::fmt;
9
10/// Schema descriptor for a single account field within a context.
11///
12/// Richer than the basic `AccountEntry` -- captures the full Account DSL
13/// surface including kind, layout, policy, seeds, optionality, and the
14/// Anchor-grade lifecycle flags (`init`/`close`/`realloc`/`has_one`) that
15/// the Hopper Safety Audit's ST2 closure requires client generators to
16/// consume.
17#[derive(Clone, Copy)]
18pub struct ContextAccountDescriptor {
19    /// Field name in the struct (e.g. "vault", "authority").
20    pub name: &'static str,
21    /// Account wrapper kind (e.g. "HopperAccount", "Signer", "ProgramRef").
22    pub kind: &'static str,
23    /// Whether the account is writable.
24    pub writable: bool,
25    /// Whether the account must be a signer.
26    pub signer: bool,
27    /// Layout name bound via `layout = T`, if any (empty string if none).
28    pub layout_ref: &'static str,
29    /// Policy pack name bound via `policy = P`, if any (empty string if none).
30    pub policy_ref: &'static str,
31    /// PDA seed expressions as string representations.
32    pub seeds: &'static [&'static str],
33    /// Whether the account is optional (may be omitted by the caller).
34    pub optional: bool,
35    /// Lifecycle role the account plays in this instruction. Clients
36    /// use this to synthesize appropriate builder helpers (`findPda`,
37    /// `initAccount`, `closeTo`, etc.).
38    pub lifecycle: AccountLifecycle,
39    /// Name of the field whose key pays CPI fees / rent top-up for
40    /// `init` or `realloc`. Empty if not applicable.
41    pub payer: &'static str,
42    /// Byte count required for `init`. `None` (represented as 0) if
43    /// not applicable.
44    pub init_space: u32,
45    /// Fields listed in `has_one = ...`, required to equal the
46    /// corresponding layout field by public key.
47    pub has_one: &'static [&'static str],
48    /// Address the caller must provide, if pinned via `address = EXPR`
49    /// (base58 form for pubkey literals; empty string if not pinned).
50    pub expected_address: &'static str,
51    /// Program owner the account must be owned by, if pinned via
52    /// `owner = EXPR`. Empty string means "owned by the current program".
53    pub expected_owner: &'static str,
54}
55
56/// Lifecycle role an account plays in one instruction.
57///
58/// Closes the audit's ST2 schema-metadata gap: clients consuming the
59/// manifest need to know which accounts are created/closed/resized so
60/// they can synthesize correct builder UX (prompt for payer, compute
61/// required rent, wire a close-recipient, etc.).
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum AccountLifecycle {
64    /// Account exists before the instruction and is only read/mutated.
65    Existing,
66    /// Account is created fresh this instruction (`#[account(init, ...)]`).
67    Init,
68    /// Account data is resized this instruction.
69    Realloc,
70    /// Account is drained and reassigned to the System Program.
71    Close,
72}
73
74impl AccountLifecycle {
75    pub const fn as_str(&self) -> &'static str {
76        match self {
77            AccountLifecycle::Existing => "existing",
78            AccountLifecycle::Init => "init",
79            AccountLifecycle::Realloc => "realloc",
80            AccountLifecycle::Close => "close",
81        }
82    }
83}
84
85impl fmt::Display for ContextAccountDescriptor {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(f, "{}: {}", self.name, self.kind)?;
88        if self.writable {
89            write!(f, " [mut]")?;
90        }
91        if self.signer {
92            write!(f, " [signer]")?;
93        }
94        if !self.layout_ref.is_empty() {
95            write!(f, " layout={}", self.layout_ref)?;
96        }
97        if !self.policy_ref.is_empty() {
98            write!(f, " policy={}", self.policy_ref)?;
99        }
100        if self.optional {
101            write!(f, " [optional]")?;
102        }
103        if !self.seeds.is_empty() {
104            write!(f, " seeds=[")?;
105            for (i, s) in self.seeds.iter().enumerate() {
106                if i > 0 {
107                    write!(f, ", ")?;
108                }
109                write!(f, "{}", s)?;
110            }
111            write!(f, "]")?;
112        }
113        Ok(())
114    }
115}
116
117/// Schema descriptor for an entire instruction context (account struct).
118///
119/// Captures the full Account DSL metadata for a single instruction's
120/// account requirements. Used for explain output, schema comparison,
121/// and manifest inclusion.
122#[derive(Clone, Copy)]
123pub struct ContextDescriptor {
124    /// Context struct name (e.g. "Deposit", "Withdraw").
125    pub name: &'static str,
126    /// Per-account field descriptors.
127    pub accounts: &'static [ContextAccountDescriptor],
128    /// Policy pack names used by this context.
129    pub policies: &'static [&'static str],
130    /// Whether receipts are expected from this instruction.
131    pub receipts_expected: bool,
132    /// Mutation class names (e.g. "Financial", "InPlace").
133    pub mutation_classes: &'static [&'static str],
134}
135
136impl ContextDescriptor {
137    /// Number of accounts in this context.
138    pub const fn account_count(&self) -> usize {
139        self.accounts.len()
140    }
141
142    /// Number of signer accounts.
143    pub fn signer_count(&self) -> usize {
144        let mut count = 0;
145        let mut i = 0;
146        while i < self.accounts.len() {
147            if self.accounts[i].signer {
148                count += 1;
149            }
150            i += 1;
151        }
152        count
153    }
154
155    /// Number of writable accounts.
156    pub fn writable_count(&self) -> usize {
157        let mut count = 0;
158        let mut i = 0;
159        while i < self.accounts.len() {
160            if self.accounts[i].writable {
161                count += 1;
162            }
163            i += 1;
164        }
165        count
166    }
167
168    /// Find an account descriptor by field name.
169    pub fn find_account(&self, name: &str) -> Option<&ContextAccountDescriptor> {
170        let mut i = 0;
171        while i < self.accounts.len() {
172            if str_eq(self.accounts[i].name, name) {
173                return Some(&self.accounts[i]);
174            }
175            i += 1;
176        }
177        None
178    }
179}
180
181impl fmt::Display for ContextDescriptor {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        writeln!(f, "Context: {}", self.name)?;
184        for acct in self.accounts {
185            writeln!(f, "  {}", acct)?;
186        }
187        if !self.policies.is_empty() {
188            write!(f, "  Policies:")?;
189            for p in self.policies {
190                write!(f, " {}", p)?;
191            }
192            writeln!(f)?;
193        }
194        if self.receipts_expected {
195            writeln!(f, "  Receipts: expected")?;
196        }
197        if !self.mutation_classes.is_empty() {
198            write!(f, "  Mutations:")?;
199            for m in self.mutation_classes {
200                write!(f, " {}", m)?;
201            }
202            writeln!(f)?;
203        }
204        Ok(())
205    }
206}
207
208/// Byte-by-byte string equality for const-compatible contexts.
209#[inline]
210fn str_eq(a: &str, b: &str) -> bool {
211    let a = a.as_bytes();
212    let b = b.as_bytes();
213    if a.len() != b.len() {
214        return false;
215    }
216    let mut i = 0;
217    while i < a.len() {
218        if a[i] != b[i] {
219            return false;
220        }
221        i += 1;
222    }
223    true
224}
225
226#[cfg(test)]
227mod tests {
228    extern crate alloc;
229    use super::*;
230    use alloc::format;
231
232    static TEST_ACCOUNTS: &[ContextAccountDescriptor] = &[
233        ContextAccountDescriptor {
234            name: "authority",
235            kind: "Signer",
236            writable: true,
237            signer: true,
238            layout_ref: "",
239            policy_ref: "",
240            seeds: &[],
241            optional: false,
242            lifecycle: AccountLifecycle::Existing,
243            payer: "",
244            init_space: 0,
245            has_one: &[],
246            expected_address: "",
247            expected_owner: "",
248        },
249        ContextAccountDescriptor {
250            name: "vault",
251            kind: "HopperAccount",
252            writable: true,
253            signer: false,
254            layout_ref: "VaultState",
255            policy_ref: "TREASURY_WRITE",
256            seeds: &["b\"vault\"", "authority"],
257            optional: false,
258            lifecycle: AccountLifecycle::Existing,
259            payer: "",
260            init_space: 0,
261            has_one: &["authority"],
262            expected_address: "",
263            expected_owner: "",
264        },
265        ContextAccountDescriptor {
266            name: "system_program",
267            kind: "ProgramRef",
268            writable: false,
269            signer: false,
270            layout_ref: "",
271            policy_ref: "",
272            seeds: &[],
273            optional: false,
274            lifecycle: AccountLifecycle::Existing,
275            payer: "",
276            init_space: 0,
277            has_one: &[],
278            expected_address: "",
279            expected_owner: "",
280        },
281    ];
282
283    static TEST_CTX: ContextDescriptor = ContextDescriptor {
284        name: "Deposit",
285        accounts: TEST_ACCOUNTS,
286        policies: &["TREASURY_WRITE"],
287        receipts_expected: true,
288        mutation_classes: &["Financial"],
289    };
290
291    #[test]
292    fn context_descriptor_counts() {
293        assert_eq!(TEST_CTX.account_count(), 3);
294        assert_eq!(TEST_CTX.signer_count(), 1);
295        assert_eq!(TEST_CTX.writable_count(), 2);
296    }
297
298    #[test]
299    fn context_descriptor_find() {
300        let found = TEST_CTX.find_account("vault");
301        assert!(found.is_some());
302        let vault = found.unwrap();
303        assert_eq!(vault.kind, "HopperAccount");
304        assert_eq!(vault.layout_ref, "VaultState");
305        assert_eq!(vault.seeds.len(), 2);
306        assert!(vault.writable);
307        assert!(!vault.signer);
308
309        assert!(TEST_CTX.find_account("nonexistent").is_none());
310    }
311
312    #[test]
313    fn context_descriptor_display() {
314        let s = format!("{}", TEST_CTX);
315        assert!(s.contains("Context: Deposit"));
316        assert!(s.contains("authority: Signer"));
317        assert!(s.contains("[mut]"));
318        assert!(s.contains("[signer]"));
319        assert!(s.contains("layout=VaultState"));
320        assert!(s.contains("policy=TREASURY_WRITE"));
321        assert!(s.contains("seeds=["));
322        assert!(s.contains("Policies: TREASURY_WRITE"));
323        assert!(s.contains("Mutations: Financial"));
324    }
325
326    #[test]
327    fn account_descriptor_display() {
328        let s = format!("{}", TEST_ACCOUNTS[2]);
329        assert!(s.contains("system_program: ProgramRef"));
330        assert!(!s.contains("[mut]"));
331        assert!(!s.contains("[signer]"));
332    }
333
334    #[test]
335    fn optional_account_display() {
336        let opt = ContextAccountDescriptor {
337            name: "extra",
338            kind: "Unchecked",
339            writable: false,
340            signer: false,
341            layout_ref: "",
342            policy_ref: "",
343            seeds: &[],
344            optional: true,
345            lifecycle: AccountLifecycle::Existing,
346            payer: "",
347            init_space: 0,
348            has_one: &[],
349            expected_address: "",
350            expected_owner: "",
351        };
352        let s = format!("{}", opt);
353        assert!(s.contains("[optional]"));
354    }
355
356    #[test]
357    fn lifecycle_as_str_roundtrips_all_variants() {
358        assert_eq!(AccountLifecycle::Existing.as_str(), "existing");
359        assert_eq!(AccountLifecycle::Init.as_str(), "init");
360        assert_eq!(AccountLifecycle::Realloc.as_str(), "realloc");
361        assert_eq!(AccountLifecycle::Close.as_str(), "close");
362    }
363
364    #[test]
365    fn init_account_descriptor_carries_lifecycle_metadata() {
366        let init_acc = ContextAccountDescriptor {
367            name: "position",
368            kind: "InitAccount",
369            writable: true,
370            signer: false,
371            layout_ref: "Position",
372            policy_ref: "",
373            seeds: &["b\"position\"", "authority.key()"],
374            optional: false,
375            lifecycle: AccountLifecycle::Init,
376            payer: "authority",
377            init_space: 128,
378            has_one: &[],
379            expected_address: "",
380            expected_owner: "",
381        };
382        assert_eq!(init_acc.lifecycle, AccountLifecycle::Init);
383        assert_eq!(init_acc.payer, "authority");
384        assert_eq!(init_acc.init_space, 128);
385        assert_eq!(init_acc.seeds.len(), 2);
386    }
387
388    #[test]
389    fn close_account_descriptor_roundtrips() {
390        let close_acc = ContextAccountDescriptor {
391            name: "vault",
392            kind: "HopperAccount",
393            writable: true,
394            signer: false,
395            layout_ref: "Vault",
396            policy_ref: "",
397            seeds: &[],
398            optional: false,
399            lifecycle: AccountLifecycle::Close,
400            payer: "",
401            init_space: 0,
402            has_one: &[],
403            expected_address: "",
404            expected_owner: "",
405        };
406        assert_eq!(close_acc.lifecycle.as_str(), "close");
407    }
408}