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) + schema_epoch(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, "    schema_epoch: int")?;
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!(
435            f,
436            "            schema_epoch=int.from_bytes(buf[12:16], \"little\"),"
437        )?;
438        writeln!(f, "        )")?;
439        writeln!(f)?;
440        writeln!(
441            f,
442            "def assert_layout_id(buf: bytes, expected: bytes) -> None:"
443        )?;
444        writeln!(
445            f,
446            "    \"\"\"Raise if the account header's layout_id doesn't match `expected`.\"\"\""
447        )?;
448        writeln!(f, "    header = HopperHeader.decode(buf)")?;
449        writeln!(f, "    if header.layout_id != expected:")?;
450        writeln!(f, "        raise ValueError(f\"layout_id mismatch: expected {{expected.hex()}}, got {{header.layout_id.hex()}}\")")?;
451        Ok(())
452    }
453}
454
455// ---------------------------------------------------------------------------
456// Package-level bundle (`__init__.py`)
457// ---------------------------------------------------------------------------
458
459/// Generates an `__init__.py` that re-exports the public surface of the
460/// generated package.
461pub struct PyIndex<'a>(pub &'a ProgramManifest);
462
463impl<'a> fmt::Display for PyIndex<'a> {
464    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
465        writeln!(
466            f,
467            "\"\"\"Auto-generated Python client for `{}`.\"\"\"",
468            self.0.name
469        )?;
470        writeln!(f, "from .accounts import *  # noqa: F401,F403")?;
471        writeln!(f, "from .instructions import *  # noqa: F401,F403")?;
472        writeln!(f, "from .events import *  # noqa: F401,F403")?;
473        writeln!(
474            f,
475            "from .types import HopperHeader, assert_layout_id  # noqa: F401"
476        )?;
477        Ok(())
478    }
479}
480
481// ---------------------------------------------------------------------------
482// Grand bundle: all-in-one emitter
483// ---------------------------------------------------------------------------
484
485/// Convenience emitter that produces a single concatenated Python file
486/// combining every section above. Useful for CLI users who want one flat
487/// file they can `cp` into their project.
488pub struct PyClientGen<'a>(pub &'a ProgramManifest);
489
490impl<'a> fmt::Display for PyClientGen<'a> {
491    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
492        write!(f, "{}", PyTypes(self.0))?;
493        writeln!(f)?;
494        write!(f, "{}", PyAccounts(self.0))?;
495        writeln!(f)?;
496        write!(f, "{}", PyInstructions(self.0))?;
497        writeln!(f)?;
498        write!(f, "{}", PyEvents(self.0))?;
499        Ok(())
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::{
507        AccountEntry, ArgDescriptor, EventDescriptor, FieldDescriptor, FieldIntent,
508        InstructionDescriptor, LayoutManifest,
509    };
510
511    fn sample_manifest() -> ProgramManifest {
512        static LAYOUTS: [LayoutManifest; 1] = [sample_layout_static()];
513        static ACCTS: [AccountEntry; 1] = [AccountEntry {
514            name: "vault",
515            writable: true,
516            signer: false,
517            layout_ref: "vault",
518        }];
519        static ARGS: [ArgDescriptor; 1] = [ArgDescriptor {
520            name: "amount",
521            canonical_type: "u64",
522            size: 8,
523        }];
524        static IX: [InstructionDescriptor; 1] = [InstructionDescriptor {
525            name: "deposit",
526            tag: 3,
527            args: &ARGS,
528            accounts: &ACCTS,
529            capabilities: &[],
530            policy_pack: "",
531            receipt_expected: true,
532        }];
533        static EV_F: [FieldDescriptor; 1] = [FieldDescriptor {
534            name: "amount",
535            canonical_type: "u64",
536            size: 8,
537            offset: 1,
538            intent: FieldIntent::Balance,
539        }];
540        static EVENTS: [EventDescriptor; 1] = [EventDescriptor {
541            name: "deposited",
542            tag: 1,
543            fields: &EV_F,
544        }];
545
546        ProgramManifest {
547            name: "vault_program",
548            version: "0.1.0",
549            description: "",
550            layouts: &LAYOUTS,
551            layout_metadata: &[],
552            instructions: &IX,
553            events: &EVENTS,
554            policies: &[],
555            compatibility_pairs: &[],
556            tooling_hints: &[],
557            contexts: &[],
558        }
559    }
560
561    const fn sample_layout_static() -> LayoutManifest {
562        const F: [FieldDescriptor; 2] = [
563            FieldDescriptor {
564                name: "authority",
565                canonical_type: "Pubkey",
566                size: 32,
567                offset: 16,
568                intent: FieldIntent::Authority,
569            },
570            FieldDescriptor {
571                name: "balance",
572                canonical_type: "u64",
573                size: 8,
574                offset: 48,
575                intent: FieldIntent::Balance,
576            },
577        ];
578        LayoutManifest {
579            name: "vault",
580            disc: 5,
581            version: 1,
582            layout_id: [1, 2, 3, 4, 5, 6, 7, 8],
583            total_size: 64,
584            field_count: 2,
585            fields: &F,
586        }
587    }
588
589    #[test]
590    fn accounts_mentions_layout_id_and_fields() {
591        let m = sample_manifest();
592        let out = alloc::format!("{}", PyAccounts(&m));
593        assert!(out.contains("class Vault"));
594        assert!(out.contains("LAYOUT_ID"));
595        assert!(out.contains("authority"));
596        assert!(out.contains("balance"));
597        assert!(out.contains("read_balance"));
598    }
599
600    #[test]
601    fn instructions_pack_tag_byte() {
602        let m = sample_manifest();
603        let out = alloc::format!("{}", PyInstructions(&m));
604        assert!(out.contains("def build_deposit"));
605        assert!(out.contains("bytes([3])"));
606        assert!(out.contains("amount"));
607    }
608
609    #[test]
610    fn events_decoder_table_present() {
611        let m = sample_manifest();
612        let out = alloc::format!("{}", PyEvents(&m));
613        assert!(out.contains("class Deposited"));
614        assert!(out.contains("EVENT_DECODERS"));
615        assert!(out.contains("1: Deposited"));
616    }
617
618    #[test]
619    fn types_header_matches_runtime_offsets() {
620        let m = sample_manifest();
621        let out = alloc::format!("{}", PyTypes(&m));
622        assert!(out.contains("HEADER_LEN = 16"));
623        assert!(out.contains("LAYOUT_ID_OFFSET = 4"));
624        assert!(out.contains("LAYOUT_ID_LENGTH = 8"));
625        assert!(out.contains("flags=int.from_bytes(buf[2:4], \"little\")"));
626        assert!(out.contains(
627            "layout_id=bytes(buf[LAYOUT_ID_OFFSET:LAYOUT_ID_OFFSET + LAYOUT_ID_LENGTH])"
628        ));
629        assert!(out.contains("schema_epoch=int.from_bytes(buf[12:16], \"little\")"));
630        assert!(!out.contains("HEADER_LEN = 12"));
631        assert!(!out.contains("layout_id=bytes(buf[4:12])"));
632    }
633}