1use 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
99pub 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 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 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 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 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
238pub 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 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
310pub 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 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
389pub 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
455pub 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
481pub 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}