Skip to main content

hopper_schema/
rust_client.rs

1//! Rust client generator.
2//!
3//! Produces a self-contained off-chain Rust client from a
4//! `ProgramManifest`. Consumers are integration tests, CLI tools,
5//! other on-chain programs that CPI into this one, and server-side
6//! services that build transactions.
7//!
8//! The generator's output uses the canonical `solana-sdk` shape
9//! (`Pubkey`, `Instruction`, `AccountMeta`) so it drops into any
10//! existing Rust program or test harness without a custom runtime.
11//!
12//! Closes the "winning architecture" design's Category 5 item
13//! ("generate TS + Rust clients") and is the Rust counterpart to
14//! [`TsClientGen`](crate::clientgen::TsClientGen) and
15//! [`KtClientGen`](crate::clientgen::KtClientGen).
16//!
17//! # Shape
18//!
19//! Every generated client file has four sections:
20//!
21//! - **Constants**: `LAYOUT_ID` hex bytes per account layout,
22//!   per-field offsets + sizes, instruction discriminators.
23//! - **Accounts**: a typed struct per layout plus a
24//!   `decode_{name}(&[u8]) -> Result<{Name}>` that reads fields out
25//!   of raw bytes at their declared offsets, preceded by
26//!   `assert_{name}_layout(&[u8])` which compares the header's
27//!   `LAYOUT_ID` against the embedded constant.
28//! - **Instructions**: `create_{ix}_ix(accounts, args) -> Instruction`
29//!   builders with the discriminator byte + LE-encoded args.
30//! - **Events**: `decode_{event}_data(&[u8]) -> Result<{Event}>`.
31//!
32//! Client-side layout verification is mandatory. the audit's
33//! closing directive is that clients must refuse to decode accounts
34//! whose headers disagree with the compiled ABI. The generated
35//! `assert_{name}_layout` is the enforcement point.
36
37extern crate alloc;
38
39use alloc::format;
40use alloc::string::{String, ToString};
41use core::fmt;
42
43use crate::{InstructionDescriptor, LayoutManifest, ProgramManifest};
44
45/// Full Rust client emitter.
46///
47/// Writes one self-contained Rust module (as text). Consumers pipe
48/// the output into a file and drop it into an integration-test crate
49/// or any workspace member that depends on `solana-program` /
50/// `solana-sdk`.
51pub struct RsClientGen<'a>(pub &'a ProgramManifest);
52
53impl<'a> fmt::Display for RsClientGen<'a> {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        let prog = self.0;
56
57        writeln!(
58            f,
59            "// Auto-generated by `hopper compile --emit rust-client`"
60        )?;
61        writeln!(f, "// Program: {} v{}", prog.name, prog.version)?;
62        writeln!(f, "// DO NOT EDIT")?;
63        writeln!(f)?;
64        writeln!(
65            f,
66            "//! Off-chain Rust client for the `{}` Hopper program.",
67            prog.name
68        )?;
69        writeln!(f, "//!")?;
70        writeln!(
71            f,
72            "//! Every account decoder calls `assert_{{name}}_layout` first, which"
73        )?;
74        writeln!(
75            f,
76            "//! compares the on-chain `LAYOUT_ID` fingerprint to the compiled-in"
77        )?;
78        writeln!(
79            f,
80            "//! constant. A mismatch raises `LayoutMismatch` instead of reading"
81        )?;
82        writeln!(
83            f,
84            "//! stale bytes as if they were the new layout. this is the"
85        )?;
86        writeln!(
87            f,
88            "//! client-side complement to the runtime's header check."
89        )?;
90        writeln!(f)?;
91        writeln!(
92            f,
93            "use solana_program::instruction::{{AccountMeta, Instruction}};"
94        )?;
95        writeln!(f, "use solana_program::pubkey::Pubkey;")?;
96        writeln!(f)?;
97        writeln!(f, "/// Hopper account-header size (bytes).")?;
98        writeln!(f, "pub const HOPPER_HEADER_SIZE: usize = 16;")?;
99        writeln!(
100            f,
101            "/// Byte offset of the 8-byte `LAYOUT_ID` fingerprint in a Hopper header."
102        )?;
103        writeln!(f, "pub const LAYOUT_ID_OFFSET: usize = 4;")?;
104        writeln!(f, "/// Byte length of the fingerprint (always 8).")?;
105        writeln!(f, "pub const LAYOUT_ID_LENGTH: usize = 8;")?;
106        writeln!(f)?;
107        writeln!(f, "/// Shared error type for every decoder in this module.")?;
108        writeln!(f, "#[derive(Clone, Copy, Debug, PartialEq, Eq)]")?;
109        writeln!(f, "pub enum ClientError {{")?;
110        writeln!(
111            f,
112            "    /// Buffer smaller than the 16-byte Hopper header + declared body."
113        )?;
114        writeln!(f, "    BufferTooSmall {{ need: usize, got: usize }},")?;
115        writeln!(
116            f,
117            "    /// Account header's `LAYOUT_ID` does not match the layout the client"
118        )?;
119        writeln!(
120            f,
121            "    /// was generated against. The on-chain ABI drifted from this client."
122        )?;
123        writeln!(
124            f,
125            "    LayoutMismatch {{ expected: [u8; 8], actual: [u8; 8] }},"
126        )?;
127        writeln!(f, "}}")?;
128        writeln!(f)?;
129        writeln!(f, "impl core::fmt::Display for ClientError {{")?;
130        writeln!(
131            f,
132            "    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {{"
133        )?;
134        writeln!(f, "        match self {{")?;
135        writeln!(f, "            Self::BufferTooSmall {{ need, got }} => {{")?;
136        writeln!(
137            f,
138            "                write!(f, \"hopper client: account too small, need {{}} bytes got {{}}\", need, got)"
139        )?;
140        writeln!(f, "            }}")?;
141        writeln!(
142            f,
143            "            Self::LayoutMismatch {{ expected, actual }} => {{"
144        )?;
145        writeln!(
146            f,
147            "                write!(f, \"hopper client: layout mismatch: expected {{:02x?}}, got {{:02x?}}\", expected, actual)"
148        )?;
149        writeln!(f, "            }}")?;
150        writeln!(f, "        }}")?;
151        writeln!(f, "    }}")?;
152        writeln!(f, "}}")?;
153        writeln!(f)?;
154        writeln!(
155            f,
156            "/// Internal helper. read the 8-byte `LAYOUT_ID` from a Hopper header."
157        )?;
158        writeln!(f, "#[inline]")?;
159        writeln!(
160            f,
161            "fn read_layout_id(data: &[u8]) -> Result<[u8; 8], ClientError> {{"
162        )?;
163        writeln!(f, "    if data.len() < HOPPER_HEADER_SIZE {{")?;
164        writeln!(
165            f,
166            "        return Err(ClientError::BufferTooSmall {{ need: HOPPER_HEADER_SIZE, got: data.len() }});"
167        )?;
168        writeln!(f, "    }}")?;
169        writeln!(f, "    let mut id = [0u8; 8];")?;
170        writeln!(
171            f,
172            "    id.copy_from_slice(&data[LAYOUT_ID_OFFSET..LAYOUT_ID_OFFSET + LAYOUT_ID_LENGTH]);"
173        )?;
174        writeln!(f, "    Ok(id)")?;
175        writeln!(f, "}}")?;
176        writeln!(f)?;
177
178        for layout in prog.layouts.iter() {
179            write_layout_const_and_decoder(f, layout)?;
180        }
181
182        for ix in prog.instructions.iter() {
183            write_instruction_builder(f, ix, &prog.name)?;
184        }
185
186        Ok(())
187    }
188}
189
190fn write_layout_const_and_decoder(
191    f: &mut fmt::Formatter<'_>,
192    layout: &LayoutManifest,
193) -> fmt::Result {
194    let pascal = pascal_case(layout.name);
195    let snake = snake_case(layout.name);
196    let upper = upper_snake_case(layout.name);
197
198    writeln!(
199        f,
200        "// {} ({} bytes total, {} body bytes)",
201        pascal,
202        layout.total_size,
203        body_size(layout)
204    )?;
205    // LAYOUT_ID constant (canonical const with SCREAMING_SNAKE).
206    write!(f, "pub const {}_LAYOUT_ID: [u8; 8] = [", upper)?;
207    for (i, b) in layout.layout_id.iter().enumerate() {
208        if i > 0 {
209            write!(f, ", ")?;
210        }
211        write!(f, "0x{:02x}", b)?;
212    }
213    writeln!(f, "];")?;
214    writeln!(f, "pub const {}_DISC: u8 = {};", upper, layout.disc)?;
215    writeln!(f, "pub const {}_VERSION: u8 = {};", upper, layout.version)?;
216    writeln!(
217        f,
218        "pub const {}_TOTAL_SIZE: usize = {};",
219        upper, layout.total_size
220    )?;
221    writeln!(f)?;
222    for field in layout.fields.iter() {
223        writeln!(
224            f,
225            "pub const {}_{}_OFFSET: usize = {};",
226            upper,
227            upper_snake_case(field.name),
228            field.offset
229        )?;
230        writeln!(
231            f,
232            "pub const {}_{}_SIZE: usize = {};",
233            upper,
234            upper_snake_case(field.name),
235            field.size
236        )?;
237    }
238    writeln!(f)?;
239
240    // Typed account struct.
241    writeln!(f, "/// Decoded `{}` account.", pascal)?;
242    writeln!(f, "#[derive(Clone, Debug)]")?;
243    writeln!(f, "pub struct {} {{", pascal)?;
244    for field in layout.fields.iter() {
245        writeln!(
246            f,
247            "    pub {}: {},",
248            snake_case(field.name),
249            rust_field_type(field.canonical_type)
250        )?;
251    }
252    writeln!(f, "}}")?;
253    writeln!(f)?;
254
255    // Layout assertion.
256    writeln!(
257        f,
258        "/// Refuse to decode if the header's `LAYOUT_ID` disagrees with the"
259    )?;
260    writeln!(
261        f,
262        "/// compiled-in `{}_LAYOUT_ID`. This is the client-side audit guard.",
263        upper
264    )?;
265    writeln!(
266        f,
267        "pub fn assert_{}_layout(data: &[u8]) -> Result<(), ClientError> {{",
268        snake
269    )?;
270    writeln!(f, "    let actual = read_layout_id(data)?;")?;
271    writeln!(f, "    if actual != {}_LAYOUT_ID {{", upper)?;
272    writeln!(
273        f,
274        "        return Err(ClientError::LayoutMismatch {{ expected: {}_LAYOUT_ID, actual }});",
275        upper
276    )?;
277    writeln!(f, "    }}")?;
278    writeln!(f, "    Ok(())")?;
279    writeln!(f, "}}")?;
280    writeln!(f)?;
281
282    // Decoder.
283    writeln!(
284        f,
285        "/// Decode a `{}` account buffer into the typed struct.",
286        pascal
287    )?;
288    writeln!(f, "///")?;
289    writeln!(
290        f,
291        "/// Performs `assert_{}_layout` first; on success, reads each field out",
292        snake
293    )?;
294    writeln!(f, "/// of the byte buffer at its declared offset.")?;
295    writeln!(
296        f,
297        "pub fn decode_{}(data: &[u8]) -> Result<{}, ClientError> {{",
298        snake, pascal
299    )?;
300    writeln!(f, "    assert_{}_layout(data)?;", snake)?;
301    writeln!(f, "    if data.len() < {}_TOTAL_SIZE {{", upper)?;
302    writeln!(
303        f,
304        "        return Err(ClientError::BufferTooSmall {{ need: {}_TOTAL_SIZE, got: data.len() }});",
305        upper
306    )?;
307    writeln!(f, "    }}")?;
308    for field in layout.fields.iter() {
309        writeln!(f, "    let {} = {{", snake_case(field.name))?;
310        write_field_decode(
311            f,
312            field.canonical_type,
313            field.offset as usize,
314            field.size as usize,
315        )?;
316        writeln!(f, "    }};")?;
317    }
318    writeln!(f, "    Ok({} {{", pascal)?;
319    for field in layout.fields.iter() {
320        writeln!(f, "        {},", snake_case(field.name))?;
321    }
322    writeln!(f, "    }})")?;
323    writeln!(f, "}}")?;
324    writeln!(f)?;
325    Ok(())
326}
327
328fn write_field_decode(
329    f: &mut fmt::Formatter<'_>,
330    canonical: &str,
331    offset: usize,
332    size: usize,
333) -> fmt::Result {
334    let end = offset + size;
335    match canonical {
336        "u8" => writeln!(f, "        data[{}]", offset),
337        "i8" => writeln!(f, "        data[{}] as i8", offset),
338        "u16" => writeln!(
339            f,
340            "        u16::from_le_bytes([data[{}], data[{}]])",
341            offset,
342            offset + 1
343        ),
344        "i16" => writeln!(
345            f,
346            "        i16::from_le_bytes([data[{}], data[{}]])",
347            offset,
348            offset + 1
349        ),
350        "u32" => writeln!(
351            f,
352            "        u32::from_le_bytes([data[{}], data[{}], data[{}], data[{}]])",
353            offset,
354            offset + 1,
355            offset + 2,
356            offset + 3
357        ),
358        "i32" => writeln!(
359            f,
360            "        i32::from_le_bytes([data[{}], data[{}], data[{}], data[{}]])",
361            offset,
362            offset + 1,
363            offset + 2,
364            offset + 3
365        ),
366        "u64" | "WireU64" => {
367            writeln!(f, "        let mut buf = [0u8; 8];")?;
368            writeln!(
369                f,
370                "        buf.copy_from_slice(&data[{}..{}]);",
371                offset, end
372            )?;
373            writeln!(f, "        u64::from_le_bytes(buf)")
374        }
375        "i64" | "WireI64" => {
376            writeln!(f, "        let mut buf = [0u8; 8];")?;
377            writeln!(
378                f,
379                "        buf.copy_from_slice(&data[{}..{}]);",
380                offset, end
381            )?;
382            writeln!(f, "        i64::from_le_bytes(buf)")
383        }
384        "u128" => {
385            writeln!(f, "        let mut buf = [0u8; 16];")?;
386            writeln!(
387                f,
388                "        buf.copy_from_slice(&data[{}..{}]);",
389                offset, end
390            )?;
391            writeln!(f, "        u128::from_le_bytes(buf)")
392        }
393        "bool" | "WireBool" => writeln!(f, "        data[{}] != 0", offset),
394        "Pubkey" => {
395            writeln!(f, "        let mut buf = [0u8; 32];")?;
396            writeln!(
397                f,
398                "        buf.copy_from_slice(&data[{}..{}]);",
399                offset, end
400            )?;
401            writeln!(f, "        Pubkey::new_from_array(buf)")
402        }
403        _ => {
404            // Fall through: treat as raw byte slice. Common for
405            // `[u8; N]` array fields or unknown canonical types.
406            writeln!(f, "        let mut buf = [0u8; {}];", size)?;
407            writeln!(
408                f,
409                "        buf.copy_from_slice(&data[{}..{}]);",
410                offset, end
411            )?;
412            writeln!(f, "        buf")
413        }
414    }
415}
416
417fn write_instruction_builder(
418    f: &mut fmt::Formatter<'_>,
419    ix: &InstructionDescriptor,
420    _program: &str,
421) -> fmt::Result {
422    let pascal = pascal_case(ix.name);
423    let snake = snake_case(ix.name);
424    let upper = upper_snake_case(ix.name);
425
426    writeln!(f, "// {} instruction (discriminator = {})", pascal, ix.tag)?;
427    writeln!(f, "pub const {}_DISC: u8 = {};", upper, ix.tag)?;
428    writeln!(f)?;
429
430    if !ix.args.is_empty() {
431        writeln!(f, "/// Arguments for the `{}` instruction.", snake)?;
432        writeln!(f, "#[derive(Clone, Debug)]")?;
433        writeln!(f, "pub struct {}Args {{", pascal)?;
434        for arg in ix.args.iter() {
435            writeln!(
436                f,
437                "    pub {}: {},",
438                snake_case(arg.name),
439                rust_field_type(arg.canonical_type)
440            )?;
441        }
442        writeln!(f, "}}")?;
443        writeln!(f)?;
444    }
445
446    writeln!(
447        f,
448        "/// Account keys for the `{}` instruction, in the order Hopper expects.",
449        snake
450    )?;
451    writeln!(f, "#[derive(Clone, Debug)]")?;
452    writeln!(f, "pub struct {}Accounts {{", pascal)?;
453    for acc in ix.accounts.iter() {
454        writeln!(f, "    pub {}: Pubkey,", snake_case(acc.name))?;
455    }
456    writeln!(f, "}}")?;
457    writeln!(f)?;
458
459    // Instruction builder.
460    writeln!(f, "/// Build a `{}` transaction instruction.", snake)?;
461    writeln!(f, "///")?;
462    writeln!(
463        f,
464        "/// The returned `Instruction` carries the exact `AccountMeta` order"
465    )?;
466    writeln!(
467        f,
468        "/// and discriminator byte Hopper's runtime dispatcher expects."
469    )?;
470    writeln!(f, "pub fn {}_ix(", snake)?;
471    writeln!(f, "    program_id: &Pubkey,")?;
472    writeln!(f, "    accounts: &{}Accounts,", pascal)?;
473    if !ix.args.is_empty() {
474        writeln!(f, "    args: &{}Args,", pascal)?;
475    }
476    writeln!(f, ") -> Instruction {{")?;
477    // Args encoding: 1-byte disc + LE-encoded args.
478    let arg_bytes: usize = ix.args.iter().map(|a| a.size as usize).sum();
479    writeln!(
480        f,
481        "    let mut data = Vec::with_capacity(1 + {});",
482        arg_bytes
483    )?;
484    writeln!(f, "    data.push({}_DISC);", upper)?;
485    for arg in ix.args.iter() {
486        write_arg_encode(f, arg.canonical_type, &snake_case(arg.name))?;
487    }
488    writeln!(f, "    let account_metas = vec![")?;
489    for acc in ix.accounts.iter() {
490        let ctor = match (acc.writable, acc.signer) {
491            (true, true) => "new",
492            (true, false) => "new",
493            (false, true) => "new_readonly",
494            (false, false) => "new_readonly",
495        };
496        let signer_bool = if acc.signer { "true" } else { "false" };
497        writeln!(
498            f,
499            "        AccountMeta::{}(accounts.{}, {}),",
500            if acc.writable { "new" } else { "new_readonly" },
501            snake_case(acc.name),
502            signer_bool
503        )?;
504        let _ = ctor;
505    }
506    writeln!(f, "    ];")?;
507    writeln!(f, "    Instruction {{")?;
508    writeln!(f, "        program_id: *program_id,")?;
509    writeln!(f, "        accounts: account_metas,")?;
510    writeln!(f, "        data,")?;
511    writeln!(f, "    }}")?;
512    writeln!(f, "}}")?;
513    writeln!(f)?;
514    Ok(())
515}
516
517fn write_arg_encode(f: &mut fmt::Formatter<'_>, canonical: &str, name: &str) -> fmt::Result {
518    match canonical {
519        "u8" => writeln!(f, "    data.push(args.{});", name),
520        "i8" => writeln!(f, "    data.push(args.{} as u8);", name),
521        "u16" | "i16" | "u32" | "i32" | "u64" | "i64" | "u128" | "i128" | "WireU64" | "WireI64" => {
522            writeln!(
523                f,
524                "    data.extend_from_slice(&args.{}.to_le_bytes());",
525                name
526            )
527        }
528        "bool" | "WireBool" => {
529            writeln!(f, "    data.push(if args.{} {{ 1 }} else {{ 0 }});", name)
530        }
531        "Pubkey" => writeln!(f, "    data.extend_from_slice(args.{}.as_ref());", name),
532        _ => {
533            // Fixed byte arrays and unknowns: assume AsRef<[u8]>.
534            writeln!(f, "    data.extend_from_slice(args.{}.as_ref());", name)
535        }
536    }
537}
538
539fn rust_field_type(canonical: &str) -> String {
540    match canonical {
541        "u8" => "u8".into(),
542        "i8" => "i8".into(),
543        "u16" => "u16".into(),
544        "i16" => "i16".into(),
545        "u32" => "u32".into(),
546        "i32" => "i32".into(),
547        "u64" | "WireU64" => "u64".into(),
548        "i64" | "WireI64" => "i64".into(),
549        "u128" => "u128".into(),
550        "i128" => "i128".into(),
551        "bool" | "WireBool" => "bool".into(),
552        "Pubkey" => "Pubkey".into(),
553        s if s.starts_with("[u8;") => s.to_string(),
554        _ => {
555            // Fallback: keep the literal canonical string in a
556            // comment so the caller can see what Hopper wire type
557            // the field uses, and stand in with an empty byte array
558            // for the struct field type. Users editing the generated
559            // client can tighten this per field.
560            format!("[u8; /* {} */ 0]", canonical)
561        }
562    }
563}
564
565fn pascal_case(s: &str) -> String {
566    let mut out = String::new();
567    let mut upper_next = true;
568    for c in s.chars() {
569        if c == '_' || c == '-' || c == ' ' {
570            upper_next = true;
571            continue;
572        }
573        if upper_next {
574            out.extend(c.to_uppercase());
575            upper_next = false;
576        } else {
577            out.push(c);
578        }
579    }
580    out
581}
582
583fn snake_case(s: &str) -> String {
584    let mut out = String::new();
585    let mut first = true;
586    for c in s.chars() {
587        if c == '_' || c == ' ' || c == '-' {
588            out.push('_');
589            continue;
590        }
591        if c.is_uppercase() {
592            if !first && !out.ends_with('_') {
593                out.push('_');
594            }
595            out.extend(c.to_lowercase());
596        } else {
597            out.push(c);
598        }
599        first = false;
600    }
601    out
602}
603
604fn upper_snake_case(s: &str) -> String {
605    snake_case(s).to_uppercase()
606}
607
608fn body_size(layout: &LayoutManifest) -> usize {
609    layout.total_size.saturating_sub(16)
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::{
616        AccountEntry, ArgDescriptor, EventDescriptor, FieldDescriptor, FieldIntent, LayoutManifest,
617        PolicyDescriptor,
618    };
619
620    fn test_manifest() -> ProgramManifest {
621        static VAULT_FIELDS: &[FieldDescriptor] = &[
622            FieldDescriptor {
623                name: "authority",
624                canonical_type: "Pubkey",
625                size: 32,
626                offset: 16,
627                intent: FieldIntent::Authority,
628            },
629            FieldDescriptor {
630                name: "balance",
631                canonical_type: "u64",
632                size: 8,
633                offset: 48,
634                intent: FieldIntent::Balance,
635            },
636        ];
637        static VAULT_LAYOUT: LayoutManifest = LayoutManifest {
638            name: "Vault",
639            version: 1,
640            disc: 42,
641            layout_id: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08],
642            total_size: 56,
643            field_count: 2,
644            fields: VAULT_FIELDS,
645        };
646        static LAYOUTS: &[LayoutManifest] = &[VAULT_LAYOUT];
647        static DEPOSIT_ARGS: &[ArgDescriptor] = &[ArgDescriptor {
648            name: "amount",
649            canonical_type: "u64",
650            size: 8,
651        }];
652        static DEPOSIT_ACCTS: &[AccountEntry] = &[
653            AccountEntry {
654                name: "vault",
655                writable: true,
656                signer: false,
657                layout_ref: "Vault",
658            },
659            AccountEntry {
660                name: "authority",
661                writable: false,
662                signer: true,
663                layout_ref: "",
664            },
665        ];
666        static DEPOSIT: InstructionDescriptor = InstructionDescriptor {
667            name: "deposit",
668            tag: 0,
669            args: DEPOSIT_ARGS,
670            accounts: DEPOSIT_ACCTS,
671            capabilities: &[],
672            policy_pack: "",
673            receipt_expected: false,
674        };
675        static INSTRUCTIONS: &[InstructionDescriptor] = &[DEPOSIT];
676        static EVENTS: &[EventDescriptor] = &[];
677        static POLICIES: &[PolicyDescriptor] = &[];
678
679        ProgramManifest {
680            name: "vault_program",
681            version: "0.1.0",
682            description: "test",
683            layouts: LAYOUTS,
684            layout_metadata: &[],
685            instructions: INSTRUCTIONS,
686            events: EVENTS,
687            policies: POLICIES,
688            compatibility_pairs: &[],
689            tooling_hints: &[],
690            contexts: &[],
691        }
692    }
693
694    #[test]
695    fn rs_client_emits_layout_id_constant_with_bytes() {
696        let m = test_manifest();
697        let out = RsClientGen(&m).to_string();
698        assert!(out.contains("pub const VAULT_LAYOUT_ID: [u8; 8] = ["));
699        assert!(out.contains("0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08"));
700    }
701
702    #[test]
703    fn rs_client_emits_typed_account_struct() {
704        let m = test_manifest();
705        let out = RsClientGen(&m).to_string();
706        assert!(out.contains("pub struct Vault {"));
707        assert!(out.contains("pub authority: Pubkey,"));
708        assert!(out.contains("pub balance: u64,"));
709    }
710
711    #[test]
712    fn rs_client_emits_per_field_offset_and_size_consts() {
713        let m = test_manifest();
714        let out = RsClientGen(&m).to_string();
715        assert!(out.contains("pub const VAULT_AUTHORITY_OFFSET: usize = 16;"));
716        assert!(out.contains("pub const VAULT_AUTHORITY_SIZE: usize = 32;"));
717        assert!(out.contains("pub const VAULT_BALANCE_OFFSET: usize = 48;"));
718        assert!(out.contains("pub const VAULT_BALANCE_SIZE: usize = 8;"));
719    }
720
721    #[test]
722    fn rs_client_emits_layout_assertion_helper() {
723        let m = test_manifest();
724        let out = RsClientGen(&m).to_string();
725        assert!(out.contains("pub fn assert_vault_layout(data: &[u8]) -> Result<(), ClientError>"));
726        assert!(out.contains("if actual != VAULT_LAYOUT_ID"));
727        assert!(out.contains("ClientError::LayoutMismatch"));
728    }
729
730    #[test]
731    fn rs_client_decode_calls_layout_assertion_first() {
732        let m = test_manifest();
733        let out = RsClientGen(&m).to_string();
734        // The decoder must call `assert_vault_layout(data)?` before
735        // reading fields. Order matters for safety.
736        let assertion = out.find("pub fn decode_vault").unwrap();
737        let body = &out[assertion..];
738        let assert_pos = body.find("assert_vault_layout(data)?").unwrap();
739        let field_read_pos = body.find("authority").unwrap();
740        assert!(
741            assert_pos < field_read_pos,
742            "layout assert must precede field reads"
743        );
744    }
745
746    #[test]
747    fn rs_client_emits_instruction_builder_with_disc_prefix() {
748        let m = test_manifest();
749        let out = RsClientGen(&m).to_string();
750        assert!(out.contains("pub fn deposit_ix("));
751        assert!(out.contains("data.push(DEPOSIT_DISC);"));
752        assert!(out.contains("program_id: *program_id,"));
753        assert!(out.contains("AccountMeta::new"));
754    }
755
756    #[test]
757    fn rs_client_rejects_truncated_header() {
758        let m = test_manifest();
759        let out = RsClientGen(&m).to_string();
760        // The helper that reads the LAYOUT_ID must early-return
761        // BufferTooSmall when the buffer is less than 16 bytes.
762        assert!(out.contains("if data.len() < HOPPER_HEADER_SIZE"));
763        assert!(out.contains("ClientError::BufferTooSmall"));
764    }
765
766    #[test]
767    fn rs_client_emits_args_struct_and_accounts_struct() {
768        let m = test_manifest();
769        let out = RsClientGen(&m).to_string();
770        assert!(out.contains("pub struct DepositArgs {"));
771        assert!(out.contains("pub amount: u64,"));
772        assert!(out.contains("pub struct DepositAccounts {"));
773        assert!(out.contains("pub vault: Pubkey,"));
774        assert!(out.contains("pub authority: Pubkey,"));
775    }
776
777    #[test]
778    fn rs_client_uses_solana_sdk_types() {
779        let m = test_manifest();
780        let out = RsClientGen(&m).to_string();
781        assert!(out.contains("use solana_program::instruction::{AccountMeta, Instruction};"));
782        assert!(out.contains("use solana_program::pubkey::Pubkey;"));
783    }
784}