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) + 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
452pub 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
478pub 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}