extern crate alloc;
use alloc::format;
use alloc::string::{String, ToString};
use core::fmt;
use crate::{InstructionDescriptor, LayoutManifest, ProgramManifest};
pub struct RsClientGen<'a>(pub &'a ProgramManifest);
impl<'a> fmt::Display for RsClientGen<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let prog = self.0;
writeln!(
f,
"// Auto-generated by `hopper compile --emit rust-client`"
)?;
writeln!(f, "// Program: {} v{}", prog.name, prog.version)?;
writeln!(f, "// DO NOT EDIT")?;
writeln!(f)?;
writeln!(
f,
"//! Off-chain Rust client for the `{}` Hopper program.",
prog.name
)?;
writeln!(f, "//!")?;
writeln!(
f,
"//! Every account decoder calls `assert_{{name}}_layout` first, which"
)?;
writeln!(
f,
"//! compares the on-chain `LAYOUT_ID` fingerprint to the compiled-in"
)?;
writeln!(
f,
"//! constant. A mismatch raises `LayoutMismatch` instead of reading"
)?;
writeln!(
f,
"//! stale bytes as if they were the new layout. this is the"
)?;
writeln!(
f,
"//! client-side complement to the runtime's header check."
)?;
writeln!(f)?;
writeln!(
f,
"use solana_program::instruction::{{AccountMeta, Instruction}};"
)?;
writeln!(f, "use solana_program::pubkey::Pubkey;")?;
writeln!(f)?;
writeln!(f, "/// Hopper account-header size (bytes).")?;
writeln!(f, "pub const HOPPER_HEADER_SIZE: usize = 16;")?;
writeln!(
f,
"/// Byte offset of the 8-byte `LAYOUT_ID` fingerprint in a Hopper header."
)?;
writeln!(f, "pub const LAYOUT_ID_OFFSET: usize = 4;")?;
writeln!(f, "/// Byte length of the fingerprint (always 8).")?;
writeln!(f, "pub const LAYOUT_ID_LENGTH: usize = 8;")?;
writeln!(f)?;
writeln!(f, "/// Shared error type for every decoder in this module.")?;
writeln!(f, "#[derive(Clone, Copy, Debug, PartialEq, Eq)]")?;
writeln!(f, "pub enum ClientError {{")?;
writeln!(
f,
" /// Buffer smaller than the 16-byte Hopper header + declared body."
)?;
writeln!(f, " BufferTooSmall {{ need: usize, got: usize }},")?;
writeln!(
f,
" /// Account header's `LAYOUT_ID` does not match the layout the client"
)?;
writeln!(
f,
" /// was generated against. The on-chain ABI drifted from this client."
)?;
writeln!(
f,
" LayoutMismatch {{ expected: [u8; 8], actual: [u8; 8] }},"
)?;
writeln!(f, "}}")?;
writeln!(f)?;
writeln!(f, "impl core::fmt::Display for ClientError {{")?;
writeln!(
f,
" fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {{"
)?;
writeln!(f, " match self {{")?;
writeln!(f, " Self::BufferTooSmall {{ need, got }} => {{")?;
writeln!(
f,
" write!(f, \"hopper client: account too small, need {{}} bytes got {{}}\", need, got)"
)?;
writeln!(f, " }}")?;
writeln!(
f,
" Self::LayoutMismatch {{ expected, actual }} => {{"
)?;
writeln!(
f,
" write!(f, \"hopper client: layout mismatch: expected {{:02x?}}, got {{:02x?}}\", expected, actual)"
)?;
writeln!(f, " }}")?;
writeln!(f, " }}")?;
writeln!(f, " }}")?;
writeln!(f, "}}")?;
writeln!(f)?;
writeln!(
f,
"/// Internal helper. read the 8-byte `LAYOUT_ID` from a Hopper header."
)?;
writeln!(f, "#[inline]")?;
writeln!(
f,
"fn read_layout_id(data: &[u8]) -> Result<[u8; 8], ClientError> {{"
)?;
writeln!(f, " if data.len() < HOPPER_HEADER_SIZE {{")?;
writeln!(
f,
" return Err(ClientError::BufferTooSmall {{ need: HOPPER_HEADER_SIZE, got: data.len() }});"
)?;
writeln!(f, " }}")?;
writeln!(f, " let mut id = [0u8; 8];")?;
writeln!(
f,
" id.copy_from_slice(&data[LAYOUT_ID_OFFSET..LAYOUT_ID_OFFSET + LAYOUT_ID_LENGTH]);"
)?;
writeln!(f, " Ok(id)")?;
writeln!(f, "}}")?;
writeln!(f)?;
for layout in prog.layouts.iter() {
write_layout_const_and_decoder(f, layout)?;
}
for ix in prog.instructions.iter() {
write_instruction_builder(f, ix, &prog.name)?;
}
Ok(())
}
}
fn write_layout_const_and_decoder(
f: &mut fmt::Formatter<'_>,
layout: &LayoutManifest,
) -> fmt::Result {
let pascal = pascal_case(layout.name);
let snake = snake_case(layout.name);
let upper = upper_snake_case(layout.name);
writeln!(
f,
"// {} ({} bytes total, {} body bytes)",
pascal,
layout.total_size,
body_size(layout)
)?;
write!(f, "pub const {}_LAYOUT_ID: [u8; 8] = [", upper)?;
for (i, b) in layout.layout_id.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "0x{:02x}", b)?;
}
writeln!(f, "];")?;
writeln!(f, "pub const {}_DISC: u8 = {};", upper, layout.disc)?;
writeln!(f, "pub const {}_VERSION: u8 = {};", upper, layout.version)?;
writeln!(
f,
"pub const {}_TOTAL_SIZE: usize = {};",
upper, layout.total_size
)?;
writeln!(f)?;
for field in layout.fields.iter() {
writeln!(
f,
"pub const {}_{}_OFFSET: usize = {};",
upper,
upper_snake_case(field.name),
field.offset
)?;
writeln!(
f,
"pub const {}_{}_SIZE: usize = {};",
upper,
upper_snake_case(field.name),
field.size
)?;
}
writeln!(f)?;
writeln!(f, "/// Decoded `{}` account.", pascal)?;
writeln!(f, "#[derive(Clone, Debug)]")?;
writeln!(f, "pub struct {} {{", pascal)?;
for field in layout.fields.iter() {
writeln!(
f,
" pub {}: {},",
snake_case(field.name),
rust_field_type(field.canonical_type)
)?;
}
writeln!(f, "}}")?;
writeln!(f)?;
writeln!(
f,
"/// Refuse to decode if the header's `LAYOUT_ID` disagrees with the"
)?;
writeln!(
f,
"/// compiled-in `{}_LAYOUT_ID`. This is the client-side audit guard.",
upper
)?;
writeln!(
f,
"pub fn assert_{}_layout(data: &[u8]) -> Result<(), ClientError> {{",
snake
)?;
writeln!(f, " let actual = read_layout_id(data)?;")?;
writeln!(f, " if actual != {}_LAYOUT_ID {{", upper)?;
writeln!(
f,
" return Err(ClientError::LayoutMismatch {{ expected: {}_LAYOUT_ID, actual }});",
upper
)?;
writeln!(f, " }}")?;
writeln!(f, " Ok(())")?;
writeln!(f, "}}")?;
writeln!(f)?;
writeln!(
f,
"/// Decode a `{}` account buffer into the typed struct.",
pascal
)?;
writeln!(f, "///")?;
writeln!(
f,
"/// Performs `assert_{}_layout` first; on success, reads each field out",
snake
)?;
writeln!(f, "/// of the byte buffer at its declared offset.")?;
writeln!(
f,
"pub fn decode_{}(data: &[u8]) -> Result<{}, ClientError> {{",
snake, pascal
)?;
writeln!(f, " assert_{}_layout(data)?;", snake)?;
writeln!(f, " if data.len() < {}_TOTAL_SIZE {{", upper)?;
writeln!(
f,
" return Err(ClientError::BufferTooSmall {{ need: {}_TOTAL_SIZE, got: data.len() }});",
upper
)?;
writeln!(f, " }}")?;
for field in layout.fields.iter() {
writeln!(f, " let {} = {{", snake_case(field.name))?;
write_field_decode(
f,
field.canonical_type,
field.offset as usize,
field.size as usize,
)?;
writeln!(f, " }};")?;
}
writeln!(f, " Ok({} {{", pascal)?;
for field in layout.fields.iter() {
writeln!(f, " {},", snake_case(field.name))?;
}
writeln!(f, " }})")?;
writeln!(f, "}}")?;
writeln!(f)?;
Ok(())
}
fn write_field_decode(
f: &mut fmt::Formatter<'_>,
canonical: &str,
offset: usize,
size: usize,
) -> fmt::Result {
let end = offset + size;
match canonical {
"u8" => writeln!(f, " data[{}]", offset),
"i8" => writeln!(f, " data[{}] as i8", offset),
"u16" => writeln!(
f,
" u16::from_le_bytes([data[{}], data[{}]])",
offset,
offset + 1
),
"i16" => writeln!(
f,
" i16::from_le_bytes([data[{}], data[{}]])",
offset,
offset + 1
),
"u32" => writeln!(
f,
" u32::from_le_bytes([data[{}], data[{}], data[{}], data[{}]])",
offset,
offset + 1,
offset + 2,
offset + 3
),
"i32" => writeln!(
f,
" i32::from_le_bytes([data[{}], data[{}], data[{}], data[{}]])",
offset,
offset + 1,
offset + 2,
offset + 3
),
"u64" | "WireU64" => {
writeln!(f, " let mut buf = [0u8; 8];")?;
writeln!(
f,
" buf.copy_from_slice(&data[{}..{}]);",
offset, end
)?;
writeln!(f, " u64::from_le_bytes(buf)")
}
"i64" | "WireI64" => {
writeln!(f, " let mut buf = [0u8; 8];")?;
writeln!(
f,
" buf.copy_from_slice(&data[{}..{}]);",
offset, end
)?;
writeln!(f, " i64::from_le_bytes(buf)")
}
"u128" => {
writeln!(f, " let mut buf = [0u8; 16];")?;
writeln!(
f,
" buf.copy_from_slice(&data[{}..{}]);",
offset, end
)?;
writeln!(f, " u128::from_le_bytes(buf)")
}
"bool" | "WireBool" => writeln!(f, " data[{}] != 0", offset),
"Pubkey" => {
writeln!(f, " let mut buf = [0u8; 32];")?;
writeln!(
f,
" buf.copy_from_slice(&data[{}..{}]);",
offset, end
)?;
writeln!(f, " Pubkey::new_from_array(buf)")
}
_ => {
writeln!(f, " let mut buf = [0u8; {}];", size)?;
writeln!(
f,
" buf.copy_from_slice(&data[{}..{}]);",
offset, end
)?;
writeln!(f, " buf")
}
}
}
fn write_instruction_builder(
f: &mut fmt::Formatter<'_>,
ix: &InstructionDescriptor,
_program: &str,
) -> fmt::Result {
let pascal = pascal_case(ix.name);
let snake = snake_case(ix.name);
let upper = upper_snake_case(ix.name);
writeln!(f, "// {} instruction (discriminator = {})", pascal, ix.tag)?;
writeln!(f, "pub const {}_DISC: u8 = {};", upper, ix.tag)?;
writeln!(f)?;
if !ix.args.is_empty() {
writeln!(f, "/// Arguments for the `{}` instruction.", snake)?;
writeln!(f, "#[derive(Clone, Debug)]")?;
writeln!(f, "pub struct {}Args {{", pascal)?;
for arg in ix.args.iter() {
writeln!(
f,
" pub {}: {},",
snake_case(arg.name),
rust_field_type(arg.canonical_type)
)?;
}
writeln!(f, "}}")?;
writeln!(f)?;
}
writeln!(
f,
"/// Account keys for the `{}` instruction, in the order Hopper expects.",
snake
)?;
writeln!(f, "#[derive(Clone, Debug)]")?;
writeln!(f, "pub struct {}Accounts {{", pascal)?;
for acc in ix.accounts.iter() {
writeln!(f, " pub {}: Pubkey,", snake_case(acc.name))?;
}
writeln!(f, "}}")?;
writeln!(f)?;
writeln!(f, "/// Build a `{}` transaction instruction.", snake)?;
writeln!(f, "///")?;
writeln!(
f,
"/// The returned `Instruction` carries the exact `AccountMeta` order"
)?;
writeln!(
f,
"/// and discriminator byte Hopper's runtime dispatcher expects."
)?;
writeln!(f, "pub fn {}_ix(", snake)?;
writeln!(f, " program_id: &Pubkey,")?;
writeln!(f, " accounts: &{}Accounts,", pascal)?;
if !ix.args.is_empty() {
writeln!(f, " args: &{}Args,", pascal)?;
}
writeln!(f, ") -> Instruction {{")?;
let arg_bytes: usize = ix.args.iter().map(|a| a.size as usize).sum();
writeln!(
f,
" let mut data = Vec::with_capacity(1 + {});",
arg_bytes
)?;
writeln!(f, " data.push({}_DISC);", upper)?;
for arg in ix.args.iter() {
write_arg_encode(f, arg.canonical_type, &snake_case(arg.name))?;
}
writeln!(f, " let account_metas = vec![")?;
for acc in ix.accounts.iter() {
let ctor = match (acc.writable, acc.signer) {
(true, true) => "new",
(true, false) => "new",
(false, true) => "new_readonly",
(false, false) => "new_readonly",
};
let signer_bool = if acc.signer { "true" } else { "false" };
writeln!(
f,
" AccountMeta::{}(accounts.{}, {}),",
if acc.writable { "new" } else { "new_readonly" },
snake_case(acc.name),
signer_bool
)?;
let _ = ctor;
}
writeln!(f, " ];")?;
writeln!(f, " Instruction {{")?;
writeln!(f, " program_id: *program_id,")?;
writeln!(f, " accounts: account_metas,")?;
writeln!(f, " data,")?;
writeln!(f, " }}")?;
writeln!(f, "}}")?;
writeln!(f)?;
Ok(())
}
fn write_arg_encode(f: &mut fmt::Formatter<'_>, canonical: &str, name: &str) -> fmt::Result {
match canonical {
"u8" => writeln!(f, " data.push(args.{});", name),
"i8" => writeln!(f, " data.push(args.{} as u8);", name),
"u16" | "i16" | "u32" | "i32" | "u64" | "i64" | "u128" | "i128" | "WireU64" | "WireI64" => {
writeln!(
f,
" data.extend_from_slice(&args.{}.to_le_bytes());",
name
)
}
"bool" | "WireBool" => {
writeln!(f, " data.push(if args.{} {{ 1 }} else {{ 0 }});", name)
}
"Pubkey" => writeln!(f, " data.extend_from_slice(args.{}.as_ref());", name),
_ => {
writeln!(f, " data.extend_from_slice(args.{}.as_ref());", name)
}
}
}
fn rust_field_type(canonical: &str) -> String {
match canonical {
"u8" => "u8".into(),
"i8" => "i8".into(),
"u16" => "u16".into(),
"i16" => "i16".into(),
"u32" => "u32".into(),
"i32" => "i32".into(),
"u64" | "WireU64" => "u64".into(),
"i64" | "WireI64" => "i64".into(),
"u128" => "u128".into(),
"i128" => "i128".into(),
"bool" | "WireBool" => "bool".into(),
"Pubkey" => "Pubkey".into(),
s if s.starts_with("[u8;") => s.to_string(),
_ => {
format!("[u8; /* {} */ 0]", canonical)
}
}
}
fn pascal_case(s: &str) -> String {
let mut out = String::new();
let mut upper_next = true;
for c in s.chars() {
if c == '_' || c == '-' || c == ' ' {
upper_next = true;
continue;
}
if upper_next {
out.extend(c.to_uppercase());
upper_next = false;
} else {
out.push(c);
}
}
out
}
fn snake_case(s: &str) -> String {
let mut out = String::new();
let mut first = true;
for c in s.chars() {
if c == '_' || c == ' ' || c == '-' {
out.push('_');
continue;
}
if c.is_uppercase() {
if !first && !out.ends_with('_') {
out.push('_');
}
out.extend(c.to_lowercase());
} else {
out.push(c);
}
first = false;
}
out
}
fn upper_snake_case(s: &str) -> String {
snake_case(s).to_uppercase()
}
fn body_size(layout: &LayoutManifest) -> usize {
layout.total_size.saturating_sub(16)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
AccountEntry, ArgDescriptor, EventDescriptor, FieldDescriptor, FieldIntent, LayoutManifest,
PolicyDescriptor,
};
fn test_manifest() -> ProgramManifest {
static VAULT_FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
name: "authority",
canonical_type: "Pubkey",
size: 32,
offset: 16,
intent: FieldIntent::Authority,
},
FieldDescriptor {
name: "balance",
canonical_type: "u64",
size: 8,
offset: 48,
intent: FieldIntent::Balance,
},
];
static VAULT_LAYOUT: LayoutManifest = LayoutManifest {
name: "Vault",
version: 1,
disc: 42,
layout_id: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08],
total_size: 56,
field_count: 2,
fields: VAULT_FIELDS,
};
static LAYOUTS: &[LayoutManifest] = &[VAULT_LAYOUT];
static DEPOSIT_ARGS: &[ArgDescriptor] = &[ArgDescriptor {
name: "amount",
canonical_type: "u64",
size: 8,
}];
static DEPOSIT_ACCTS: &[AccountEntry] = &[
AccountEntry {
name: "vault",
writable: true,
signer: false,
layout_ref: "Vault",
},
AccountEntry {
name: "authority",
writable: false,
signer: true,
layout_ref: "",
},
];
static DEPOSIT: InstructionDescriptor = InstructionDescriptor {
name: "deposit",
tag: 0,
args: DEPOSIT_ARGS,
accounts: DEPOSIT_ACCTS,
capabilities: &[],
policy_pack: "",
receipt_expected: false,
};
static INSTRUCTIONS: &[InstructionDescriptor] = &[DEPOSIT];
static EVENTS: &[EventDescriptor] = &[];
static POLICIES: &[PolicyDescriptor] = &[];
ProgramManifest {
name: "vault_program",
version: "0.1.0",
description: "test",
layouts: LAYOUTS,
layout_metadata: &[],
instructions: INSTRUCTIONS,
events: EVENTS,
policies: POLICIES,
compatibility_pairs: &[],
tooling_hints: &[],
contexts: &[],
}
}
#[test]
fn rs_client_emits_layout_id_constant_with_bytes() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
assert!(out.contains("pub const VAULT_LAYOUT_ID: [u8; 8] = ["));
assert!(out.contains("0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08"));
}
#[test]
fn rs_client_emits_typed_account_struct() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
assert!(out.contains("pub struct Vault {"));
assert!(out.contains("pub authority: Pubkey,"));
assert!(out.contains("pub balance: u64,"));
}
#[test]
fn rs_client_emits_per_field_offset_and_size_consts() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
assert!(out.contains("pub const VAULT_AUTHORITY_OFFSET: usize = 16;"));
assert!(out.contains("pub const VAULT_AUTHORITY_SIZE: usize = 32;"));
assert!(out.contains("pub const VAULT_BALANCE_OFFSET: usize = 48;"));
assert!(out.contains("pub const VAULT_BALANCE_SIZE: usize = 8;"));
}
#[test]
fn rs_client_emits_layout_assertion_helper() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
assert!(out.contains("pub fn assert_vault_layout(data: &[u8]) -> Result<(), ClientError>"));
assert!(out.contains("if actual != VAULT_LAYOUT_ID"));
assert!(out.contains("ClientError::LayoutMismatch"));
}
#[test]
fn rs_client_decode_calls_layout_assertion_first() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
let assertion = out.find("pub fn decode_vault").unwrap();
let body = &out[assertion..];
let assert_pos = body.find("assert_vault_layout(data)?").unwrap();
let field_read_pos = body.find("authority").unwrap();
assert!(
assert_pos < field_read_pos,
"layout assert must precede field reads"
);
}
#[test]
fn rs_client_emits_instruction_builder_with_disc_prefix() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
assert!(out.contains("pub fn deposit_ix("));
assert!(out.contains("data.push(DEPOSIT_DISC);"));
assert!(out.contains("program_id: *program_id,"));
assert!(out.contains("AccountMeta::new"));
}
#[test]
fn rs_client_rejects_truncated_header() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
assert!(out.contains("if data.len() < HOPPER_HEADER_SIZE"));
assert!(out.contains("ClientError::BufferTooSmall"));
}
#[test]
fn rs_client_emits_args_struct_and_accounts_struct() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
assert!(out.contains("pub struct DepositArgs {"));
assert!(out.contains("pub amount: u64,"));
assert!(out.contains("pub struct DepositAccounts {"));
assert!(out.contains("pub vault: Pubkey,"));
assert!(out.contains("pub authority: Pubkey,"));
}
#[test]
fn rs_client_uses_solana_sdk_types() {
let m = test_manifest();
let out = RsClientGen(&m).to_string();
assert!(out.contains("use solana_program::instruction::{AccountMeta, Instruction};"));
assert!(out.contains("use solana_program::pubkey::Pubkey;"));
}
}