Skip to main content

hopper_schema/
python_client.rs

1//! # Python client emitter
2//!
3//! Produces a standalone Python module from a `ProgramManifest` that mirrors
4//! the TypeScript emitter in `clientgen.rs`. The generated Python has no
5//! runtime dependency outside of the standard library. everything decodes
6//! through `struct` (the stdlib module).
7//!
8//! ## What gets emitted
9//!
10//! - One dataclass per account layout (`Vault`, `Config`, …) with a
11//!   `decode(bytes) -> Self` classmethod that verifies the layout_id and
12//!   reads field offsets directly from the raw bytes.
13//! - One dataclass per event with a `decode(bytes) -> Self` classmethod
14//!   keyed off the 1-byte event tag.
15//! - `build_<instruction>` helper functions that return the raw `bytes`
16//!   instruction payload. The caller wires the returned bytes into their
17//!   preferred Solana client (solders, solana-py, …).
18//! - A `DISCRIMINATORS` dict mapping layout name to `(disc, layout_id)`.
19//!
20//! ## Design notes
21//!
22//! Hopper emits Python that:
23//!   1. Verifies the `layout_id` fingerprint before decoding (impossible in
24//!      Anchor because Anchor has no layout fingerprint).
25//!   2. Honors `FieldIntent` by emitting typed `int` / `bytes` / `bool`
26//!      field types that match the field's semantic role, not just the
27//!      underlying u8/u64.
28//!   3. Ships segment-aware partial readers (`Vault.read_balance(buf)`)
29//!      parallel to the zero-copy on-chain side.
30
31use core::fmt;
32
33extern crate alloc;
34use alloc::string::{String, ToString};
35
36use crate::{EventDescriptor, InstructionDescriptor, LayoutManifest, ProgramManifest};
37
38fn py_type(canonical: &str) -> &'static str {
39    match canonical {
40        "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => "int",
41        "u64" | "u128" | "i64" | "i128" => "int",
42        "bool" => "bool",
43        "Pubkey" => "bytes",
44        _ => "bytes",
45    }
46}
47
48fn struct_format(canonical: &str, size: u16) -> String {
49    match canonical {
50        "u8" => "<B".to_string(),
51        "u16" => "<H".to_string(),
52        "u32" => "<I".to_string(),
53        "u64" => "<Q".to_string(),
54        "i8" => "<b".to_string(),
55        "i16" => "<h".to_string(),
56        "i32" => "<i".to_string(),
57        "i64" => "<q".to_string(),
58        "bool" => "<?".to_string(),
59        _ => {
60            let mut s = String::from("<");
61            let n = size.to_string();
62            s.push_str(&n);
63            s.push('s');
64            s
65        }
66    }
67}
68
69fn write_snake(f: &mut fmt::Formatter<'_>, name: &str) -> fmt::Result {
70    for c in name.chars() {
71        if c == '-' {
72            f.write_str("_")?;
73        } else {
74            for lc in c.to_lowercase() {
75                write!(f, "{}", lc)?;
76            }
77        }
78    }
79    Ok(())
80}
81
82fn write_pascal(f: &mut fmt::Formatter<'_>, name: &str) -> fmt::Result {
83    let mut cap = true;
84    for c in name.chars() {
85        if c == '_' || c == '-' {
86            cap = true;
87        } else if cap {
88            for uc in c.to_uppercase() {
89                write!(f, "{}", uc)?;
90            }
91            cap = false;
92        } else {
93            write!(f, "{}", c)?;
94        }
95    }
96    Ok(())
97}
98
99// ---------------------------------------------------------------------------
100// Accounts emitter (`accounts.py`)
101// ---------------------------------------------------------------------------
102
103/// Generates `accounts.py` content from a `ProgramManifest`.
104pub struct PyAccounts<'a>(pub &'a ProgramManifest);
105
106impl<'a> fmt::Display for PyAccounts<'a> {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        writeln!(
109            f,
110            "\"\"\"Hopper account decoders for program `{}`.",
111            self.0.name
112        )?;
113        writeln!(f)?;
114        writeln!(f, "Auto-generated. Do not edit.")?;
115        writeln!(f, "\"\"\"")?;
116        writeln!(f, "from __future__ import annotations")?;
117        writeln!(f, "from dataclasses import dataclass")?;
118        writeln!(f, "import struct")?;
119        writeln!(f)?;
120        writeln!(
121            f,
122            "LAYOUT_ID_OFFSET = 4  # bytes [4..12] of the Hopper header"
123        )?;
124        writeln!(f)?;
125
126        for layout in self.0.layouts {
127            fmt_layout(f, layout)?;
128            writeln!(f)?;
129        }
130
131        // DISCRIMINATORS map (one-per-layout)
132        writeln!(f, "DISCRIMINATORS: dict[str, tuple[int, bytes]] = {{")?;
133        for layout in self.0.layouts {
134            write!(f, "    \"")?;
135            write_pascal(f, layout.name)?;
136            write!(f, "\": ({}, bytes([", layout.disc)?;
137            for (i, b) in layout.layout_id.iter().enumerate() {
138                if i > 0 {
139                    write!(f, ", ")?;
140                }
141                write!(f, "0x{:02x}", b)?;
142            }
143            writeln!(f, "])),")?;
144        }
145        writeln!(f, "}}")?;
146        Ok(())
147    }
148}
149
150fn fmt_layout(f: &mut fmt::Formatter<'_>, layout: &LayoutManifest) -> fmt::Result {
151    writeln!(f, "@dataclass(frozen=True, slots=True)")?;
152    write!(f, "class ")?;
153    write_pascal(f, layout.name)?;
154    writeln!(f, ":")?;
155    writeln!(
156        f,
157        "    \"\"\"Decoder for the `{}` account. total_size={}\"\"\"",
158        layout.name, layout.total_size
159    )?;
160
161    // Layout-id constant
162    write!(f, "    LAYOUT_ID: bytes = bytes([")?;
163    for (i, b) in layout.layout_id.iter().enumerate() {
164        if i > 0 {
165            write!(f, ", ")?;
166        }
167        write!(f, "0x{:02x}", b)?;
168    }
169    writeln!(f, "])")?;
170    writeln!(f, "    DISC: int = {}", layout.disc)?;
171    writeln!(f, "    VERSION: int = {}", layout.version)?;
172    writeln!(f, "    TOTAL_SIZE: int = {}", layout.total_size)?;
173    writeln!(f)?;
174
175    // Typed fields (dataclass attributes)
176    for fd in layout.fields {
177        write!(f, "    ")?;
178        write_snake(f, fd.name)?;
179        writeln!(f, ": {}", py_type(fd.canonical_type))?;
180    }
181
182    writeln!(f)?;
183    writeln!(f, "    @classmethod")?;
184    write!(f, "    def decode(cls, buf: bytes) -> \"")?;
185    write_pascal(f, layout.name)?;
186    writeln!(f, "\":")?;
187    writeln!(f, "        if len(buf) < cls.TOTAL_SIZE:")?;
188    writeln!(f, "            raise ValueError(f\"buffer too short: need {{cls.TOTAL_SIZE}}, got {{len(buf)}}\")")?;
189    writeln!(
190        f,
191        "        actual_id = bytes(buf[LAYOUT_ID_OFFSET:LAYOUT_ID_OFFSET + 8])"
192    )?;
193    writeln!(f, "        if actual_id != cls.LAYOUT_ID:")?;
194    writeln!(f, "            raise ValueError(f\"layout_id mismatch: expected {{cls.LAYOUT_ID.hex()}}, got {{actual_id.hex()}}\")")?;
195
196    for fd in layout.fields {
197        let fmt = struct_format(fd.canonical_type, fd.size);
198        write!(f, "        ")?;
199        write_snake(f, fd.name)?;
200        writeln!(
201            f,
202            " = struct.unpack_from(\"{}\", buf, {})[0]",
203            fmt, fd.offset
204        )?;
205    }
206
207    write!(f, "        return cls(")?;
208    for (i, fd) in layout.fields.iter().enumerate() {
209        if i > 0 {
210            write!(f, ", ")?;
211        }
212        write_snake(f, fd.name)?;
213        write!(f, "=")?;
214        write_snake(f, fd.name)?;
215    }
216    writeln!(f, ")")?;
217
218    // Partial reader helpers: `Vault.read_balance(buf) -> int`. these are
219    // the segment-aware equivalent of hopper-sdk's `SegmentReader::read_u64`.
220    writeln!(f)?;
221    for fd in layout.fields {
222        let fmt = struct_format(fd.canonical_type, fd.size);
223        writeln!(f, "    @classmethod")?;
224        write!(f, "    def read_")?;
225        write_snake(f, fd.name)?;
226        writeln!(f, "(cls, buf: bytes) -> {}:", py_type(fd.canonical_type))?;
227        writeln!(f, "        \"\"\"Partial read of `{}` (size={}, offset={}). Does NOT verify layout_id; call decode() for full verification.\"\"\"", fd.name, fd.size, fd.offset)?;
228        writeln!(
229            f,
230            "        return struct.unpack_from(\"{}\", buf, {})[0]",
231            fmt, fd.offset
232        )?;
233    }
234
235    Ok(())
236}
237
238// ---------------------------------------------------------------------------
239// Instructions emitter (`instructions.py`)
240// ---------------------------------------------------------------------------
241
242/// Generates `instructions.py` content from a `ProgramManifest`.
243pub struct PyInstructions<'a>(pub &'a ProgramManifest);
244
245impl<'a> fmt::Display for PyInstructions<'a> {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        writeln!(
248            f,
249            "\"\"\"Instruction builders for program `{}`.\"\"\"",
250            self.0.name
251        )?;
252        writeln!(f, "from __future__ import annotations")?;
253        writeln!(f, "import struct")?;
254        writeln!(f)?;
255        for ix in self.0.instructions {
256            fmt_instruction(f, ix)?;
257            writeln!(f)?;
258        }
259        Ok(())
260    }
261}
262
263fn fmt_instruction(f: &mut fmt::Formatter<'_>, ix: &InstructionDescriptor) -> fmt::Result {
264    write!(f, "def build_")?;
265    write_snake(f, ix.name)?;
266    write!(f, "(")?;
267    for (i, a) in ix.args.iter().enumerate() {
268        if i > 0 {
269            write!(f, ", ")?;
270        }
271        write_snake(f, a.name)?;
272        write!(f, ": {}", py_type(a.canonical_type))?;
273    }
274    writeln!(f, ") -> bytes:")?;
275    writeln!(
276        f,
277        "    \"\"\"Assemble the raw instruction data for `{}`. tag={}\"\"\"",
278        ix.name, ix.tag
279    )?;
280    writeln!(f, "    parts: list[bytes] = [bytes([{}])]", ix.tag)?;
281    for a in ix.args {
282        let fmt = struct_format(a.canonical_type, a.size);
283        write!(f, "    parts.append(struct.pack(\"{}\", ", fmt)?;
284        write_snake(f, a.name)?;
285        writeln!(f, "))")?;
286    }
287    writeln!(f, "    return b\"\".join(parts)")?;
288
289    // Account ordering doc. helpful for consumers since Python has no
290    // statically typed AccountMeta.
291    if !ix.accounts.is_empty() {
292        writeln!(f, "\nbuild_")?;
293        write_snake(f, ix.name)?;
294        writeln!(f, ".ACCOUNT_ORDER = (")?;
295        for ae in ix.accounts {
296            writeln!(
297                f,
298                "    (\"{}\", {{\"writable\": {}, \"signer\": {}, \"layout\": \"{}\"}}),",
299                ae.name,
300                if ae.writable { "True" } else { "False" },
301                if ae.signer { "True" } else { "False" },
302                ae.layout_ref,
303            )?;
304        }
305        writeln!(f, ")")?;
306    }
307    Ok(())
308}
309
310// ---------------------------------------------------------------------------
311// Events emitter (`events.py`)
312// ---------------------------------------------------------------------------
313
314/// Generates `events.py` content from a `ProgramManifest`.
315pub struct PyEvents<'a>(pub &'a ProgramManifest);
316
317impl<'a> fmt::Display for PyEvents<'a> {
318    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319        writeln!(
320            f,
321            "\"\"\"Event decoders for program `{}`.\"\"\"",
322            self.0.name
323        )?;
324        writeln!(f, "from __future__ import annotations")?;
325        writeln!(f, "from dataclasses import dataclass")?;
326        writeln!(f, "import struct")?;
327        writeln!(f)?;
328        for e in self.0.events {
329            fmt_event(f, e)?;
330            writeln!(f)?;
331        }
332
333        // Event tag → decoder table.
334        writeln!(f, "EVENT_DECODERS: dict[int, type] = {{")?;
335        for e in self.0.events {
336            write!(f, "    {}: ", e.tag)?;
337            write_pascal(f, e.name)?;
338            writeln!(f, ",")?;
339        }
340        writeln!(f, "}}")?;
341        Ok(())
342    }
343}
344
345fn fmt_event(f: &mut fmt::Formatter<'_>, e: &EventDescriptor) -> fmt::Result {
346    writeln!(f, "@dataclass(frozen=True, slots=True)")?;
347    write!(f, "class ")?;
348    write_pascal(f, e.name)?;
349    writeln!(f, ":")?;
350    writeln!(f, "    \"\"\"Event {} (tag={})\"\"\"", e.name, e.tag)?;
351    writeln!(f, "    TAG: int = {}", e.tag)?;
352    for fd in e.fields {
353        write!(f, "    ")?;
354        write_snake(f, fd.name)?;
355        writeln!(f, ": {}", py_type(fd.canonical_type))?;
356    }
357
358    writeln!(f)?;
359    writeln!(f, "    @classmethod")?;
360    write!(f, "    def decode(cls, buf: bytes) -> \"")?;
361    write_pascal(f, e.name)?;
362    writeln!(f, "\":")?;
363    writeln!(f, "        if not buf or buf[0] != cls.TAG:")?;
364    writeln!(f, "            raise ValueError(\"event tag mismatch\")")?;
365    writeln!(f, "        p = 1")?;
366    for fd in e.fields {
367        let fmt = struct_format(fd.canonical_type, fd.size);
368        write!(f, "        ")?;
369        write_snake(f, fd.name)?;
370        writeln!(
371            f,
372            " = struct.unpack_from(\"{}\", buf, p)[0]; p += {}",
373            fmt, fd.size
374        )?;
375    }
376    write!(f, "        return cls(")?;
377    for (i, fd) in e.fields.iter().enumerate() {
378        if i > 0 {
379            write!(f, ", ")?;
380        }
381        write_snake(f, fd.name)?;
382        write!(f, "=")?;
383        write_snake(f, fd.name)?;
384    }
385    writeln!(f, ")")?;
386    Ok(())
387}
388
389// ---------------------------------------------------------------------------
390// Types emitter (`types.py`. shared scaffolding)
391// ---------------------------------------------------------------------------
392
393/// Generates the shared `types.py` content: header parser, fingerprint
394/// assertion helper, and a single source-of-truth `DECODERS` union table.
395pub struct PyTypes<'a>(pub &'a ProgramManifest);
396
397impl<'a> fmt::Display for PyTypes<'a> {
398    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399        writeln!(
400            f,
401            "\"\"\"Shared Hopper client primitives for program `{}`.\"\"\"",
402            self.0.name
403        )?;
404        writeln!(f, "from __future__ import annotations")?;
405        writeln!(f, "from dataclasses import dataclass")?;
406        writeln!(f)?;
407        writeln!(
408            f,
409            "HEADER_LEN = 16  # disc(1) + version(1) + flags(2) + layout_id(8) + reserved(4)"
410        )?;
411        writeln!(f, "LAYOUT_ID_OFFSET = 4")?;
412        writeln!(f, "LAYOUT_ID_LENGTH = 8")?;
413        writeln!(f)?;
414        writeln!(f, "@dataclass(frozen=True, slots=True)")?;
415        writeln!(f, "class HopperHeader:")?;
416        writeln!(f, "    disc: int")?;
417        writeln!(f, "    version: int")?;
418        writeln!(f, "    flags: int")?;
419        writeln!(f, "    layout_id: bytes")?;
420        writeln!(f, "    reserved: bytes")?;
421        writeln!(f)?;
422        writeln!(f, "    @classmethod")?;
423        writeln!(f, "    def decode(cls, buf: bytes) -> \"HopperHeader\":")?;
424        writeln!(f, "        if len(buf) < HEADER_LEN:")?;
425        writeln!(
426            f,
427            "            raise ValueError(\"account too short for Hopper header\")"
428        )?;
429        writeln!(f, "        return cls(")?;
430        writeln!(f, "            disc=buf[0],")?;
431        writeln!(f, "            version=buf[1],")?;
432        writeln!(f, "            flags=int.from_bytes(buf[2:4], \"little\"),")?;
433        writeln!(f, "            layout_id=bytes(buf[LAYOUT_ID_OFFSET:LAYOUT_ID_OFFSET + LAYOUT_ID_LENGTH]),")?;
434        writeln!(f, "            reserved=bytes(buf[12:16]),")?;
435        writeln!(f, "        )")?;
436        writeln!(f)?;
437        writeln!(
438            f,
439            "def assert_layout_id(buf: bytes, expected: bytes) -> None:"
440        )?;
441        writeln!(
442            f,
443            "    \"\"\"Raise if the account header's layout_id doesn't match `expected`.\"\"\""
444        )?;
445        writeln!(f, "    header = HopperHeader.decode(buf)")?;
446        writeln!(f, "    if header.layout_id != expected:")?;
447        writeln!(f, "        raise ValueError(f\"layout_id mismatch: expected {{expected.hex()}}, got {{header.layout_id.hex()}}\")")?;
448        Ok(())
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Package-level bundle (`__init__.py`)
454// ---------------------------------------------------------------------------
455
456/// Generates an `__init__.py` that re-exports the public surface of the
457/// generated package.
458pub struct PyIndex<'a>(pub &'a ProgramManifest);
459
460impl<'a> fmt::Display for PyIndex<'a> {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        writeln!(
463            f,
464            "\"\"\"Auto-generated Python client for `{}`.\"\"\"",
465            self.0.name
466        )?;
467        writeln!(f, "from .accounts import *  # noqa: F401,F403")?;
468        writeln!(f, "from .instructions import *  # noqa: F401,F403")?;
469        writeln!(f, "from .events import *  # noqa: F401,F403")?;
470        writeln!(
471            f,
472            "from .types import HopperHeader, assert_layout_id  # noqa: F401"
473        )?;
474        Ok(())
475    }
476}
477
478// ---------------------------------------------------------------------------
479// Grand bundle: all-in-one emitter
480// ---------------------------------------------------------------------------
481
482/// Convenience emitter that produces a single concatenated Python file
483/// combining every section above. Useful for CLI users who want one flat
484/// file they can `cp` into their project.
485pub struct PyClientGen<'a>(pub &'a ProgramManifest);
486
487impl<'a> fmt::Display for PyClientGen<'a> {
488    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
489        write!(f, "{}", PyTypes(self.0))?;
490        writeln!(f)?;
491        write!(f, "{}", PyAccounts(self.0))?;
492        writeln!(f)?;
493        write!(f, "{}", PyInstructions(self.0))?;
494        writeln!(f)?;
495        write!(f, "{}", PyEvents(self.0))?;
496        Ok(())
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::{
504        AccountEntry, ArgDescriptor, EventDescriptor, FieldDescriptor, FieldIntent,
505        InstructionDescriptor, LayoutManifest,
506    };
507
508    fn sample_manifest() -> ProgramManifest {
509        static LAYOUTS: [LayoutManifest; 1] = [sample_layout_static()];
510        static ACCTS: [AccountEntry; 1] = [AccountEntry {
511            name: "vault",
512            writable: true,
513            signer: false,
514            layout_ref: "vault",
515        }];
516        static ARGS: [ArgDescriptor; 1] = [ArgDescriptor {
517            name: "amount",
518            canonical_type: "u64",
519            size: 8,
520        }];
521        static IX: [InstructionDescriptor; 1] = [InstructionDescriptor {
522            name: "deposit",
523            tag: 3,
524            args: &ARGS,
525            accounts: &ACCTS,
526            capabilities: &[],
527            policy_pack: "",
528            receipt_expected: true,
529        }];
530        static EV_F: [FieldDescriptor; 1] = [FieldDescriptor {
531            name: "amount",
532            canonical_type: "u64",
533            size: 8,
534            offset: 1,
535            intent: FieldIntent::Balance,
536        }];
537        static EVENTS: [EventDescriptor; 1] = [EventDescriptor {
538            name: "deposited",
539            tag: 1,
540            fields: &EV_F,
541        }];
542
543        ProgramManifest {
544            name: "vault_program",
545            version: "0.1.0",
546            description: "",
547            layouts: &LAYOUTS,
548            layout_metadata: &[],
549            instructions: &IX,
550            events: &EVENTS,
551            policies: &[],
552            compatibility_pairs: &[],
553            tooling_hints: &[],
554            contexts: &[],
555        }
556    }
557
558    const fn sample_layout_static() -> LayoutManifest {
559        const F: [FieldDescriptor; 2] = [
560            FieldDescriptor {
561                name: "authority",
562                canonical_type: "Pubkey",
563                size: 32,
564                offset: 16,
565                intent: FieldIntent::Authority,
566            },
567            FieldDescriptor {
568                name: "balance",
569                canonical_type: "u64",
570                size: 8,
571                offset: 48,
572                intent: FieldIntent::Balance,
573            },
574        ];
575        LayoutManifest {
576            name: "vault",
577            disc: 5,
578            version: 1,
579            layout_id: [1, 2, 3, 4, 5, 6, 7, 8],
580            total_size: 64,
581            field_count: 2,
582            fields: &F,
583        }
584    }
585
586    #[test]
587    fn accounts_mentions_layout_id_and_fields() {
588        let m = sample_manifest();
589        let out = alloc::format!("{}", PyAccounts(&m));
590        assert!(out.contains("class Vault"));
591        assert!(out.contains("LAYOUT_ID"));
592        assert!(out.contains("authority"));
593        assert!(out.contains("balance"));
594        assert!(out.contains("read_balance"));
595    }
596
597    #[test]
598    fn instructions_pack_tag_byte() {
599        let m = sample_manifest();
600        let out = alloc::format!("{}", PyInstructions(&m));
601        assert!(out.contains("def build_deposit"));
602        assert!(out.contains("bytes([3])"));
603        assert!(out.contains("amount"));
604    }
605
606    #[test]
607    fn events_decoder_table_present() {
608        let m = sample_manifest();
609        let out = alloc::format!("{}", PyEvents(&m));
610        assert!(out.contains("class Deposited"));
611        assert!(out.contains("EVENT_DECODERS"));
612        assert!(out.contains("1: Deposited"));
613    }
614
615    #[test]
616    fn types_header_matches_runtime_offsets() {
617        let m = sample_manifest();
618        let out = alloc::format!("{}", PyTypes(&m));
619        assert!(out.contains("HEADER_LEN = 16"));
620        assert!(out.contains("LAYOUT_ID_OFFSET = 4"));
621        assert!(out.contains("LAYOUT_ID_LENGTH = 8"));
622        assert!(out.contains("flags=int.from_bytes(buf[2:4], \"little\")"));
623        assert!(out.contains(
624            "layout_id=bytes(buf[LAYOUT_ID_OFFSET:LAYOUT_ID_OFFSET + LAYOUT_ID_LENGTH])"
625        ));
626        assert!(out.contains("reserved=bytes(buf[12:16])"));
627        assert!(!out.contains("HEADER_LEN = 12"));
628        assert!(!out.contains("layout_id=bytes(buf[4:12])"));
629    }
630}