The Safest Solana Parser Is the One That Refuses Bad Bytes

The Safest Solana Parser Is the One That Refuses Bad Bytes

In case you missed Part 1 of this series: 1. Data bugs are THE bugs Look through any year’s catalog of SVM exploits and the same patterns keep showing up. An account decodes successfully but belongs to the wrong program. Two account types share a compatible prefix. An “unused” byte turns out to be load-bearing. An enum contains a discriminant nobody bothered to validate. These aren’t logic bugs. They’re data bugs. The program accepted bytes it should have rejected. Two of the largest Solana incidents of 2022 fit exactly that pattern. Wormhole minted 120,000 unbacked ETH because a signature check read a spoofed account instead of the Instructions Sysvar. Cashio⁠ lost $52 million because a single account in its collateral chain wasn’t properly validated. Now add an AI to the team. An agent writes structs quickly and refactors them even faster. It will happily reorder fields, resize a buffer, or add “just one more status variant” — each one a silent change to bytes that already live on chain forever. Code review catches some of that. I wanted a data layer where the compiler and the parser catch all of it. Pinocchio makes that surprisingly natural. It gives you account data as &[u8] and gets out of the way. If every account enters the program through exactly one checked cast, then parsing stops being a convenience. It becomes the validation layer. 2. Parsing as validation Everything in this repository follows one simple rule: On-chain data is never deserialized. It’s validated in place. There are no intermediate objects, no serde layer, and no copied buffers. Every account is a #[repr(C)] struct cast directly from bytes; instruction payloads use the same checked layout but are read into small owned values. The cast itself decides whether those bytes represent a valid object. That behavior comes from two bytemuck traits. NoUninit guarantees that a type has no hidden padding. Every byte of the struct is meaningful, so casting it to bytes is sound. CheckedBitPattern solves the opposite problem. It defines which bit patterns are valid, and bytemuck::checked::try_from_bytes rejects everything else. No copies, no allocator, no serde — just a pointer cast that doubles as an integrity check. The interesting part is that “valid” doesn’t have to mean “valid Rust.” It can mean “valid for my protocol.” That’s where the approach becomes powerful. Instead of checking invariants after parsing, I encode them into the parser itself. If the bytes don’t represent a valid account, there simply isn’t an account to work with. The companion repository builds its entire wire layer from three small primitives that do exactly that. The first building block is AccountHeader. Every account begins with an 8-byte header: a 7-byte discriminator followed by a schema version. The important part isn’t the layout — it’s the validation. The only accepted bit pattern is the header that belongs to that specific account type. unsafe impl CheckedBitPattern for AccountHeader { type Bits = [u8; ACCOUNT_HEADER_BYTES]; fn is_valid_bit_pattern(bits: &Self::Bits) -> bool { bits == &AccountHeader::::BYTES } } If you accidentally (or maliciously) try to interpret a Ticket account as an Event, the cast fails immediately. Type confusion never reaches your business logic — it dies during parsing. The second building block is Reserved. Normally, #[repr(C)] structs contain alignment padding, and NoUninit quite rightly refuses to treat those bytes as part of a safe zero-copy type. Instead of pretending the padding doesn’t exist, I make it explicit. Every alignment hole becomes a Reserved field whose only valid value is zero. // SAFETY: `Reserved` is transparent over `[u8; N]`; the only accepted // bit pattern is all-zero, enforcing that padding bytes stay zero on the // wire so the parent struct's layout is fully defined. unsafe impl CheckedBitPattern for Reserved { type Bits = [u8; N]; fn is_valid_bit_pattern(bits: &Self::Bits) -> bool { bits.iter().all(|byte| *byte == 0) } } Why so strict? Because “unused” bytes have a habit of becoming important later. If every byte is either meaningful or guaranteed to be zero, there is only one valid representation of an account on the wire. Two logically identical accounts are also identical byte-for-byte, snapshots diff cleanly, and future schema versions can safely reuse reserved space without worrying about stale data left behind by older versions. BoundedString applies the same idea to variable-length data. It’s a fixed-size, length-prefixed buffer whose canonical form requires every unused byte in the tail to be zero. Even standard library types become validation tools in this model. Take core::num::NonZeroU64: bytemuck already knows which bit patterns are valid, so a field like a ticket price simply cannot be zero on chain. If it is, parsing fails before the instruction handler ever gets a chance to run. One thing worth pointing out is that all of these CheckedBitPattern implementations are handwritten. bytemuck intentionally refuses to derive them for generic types, so the unsafe code lives in one place, with the reasoning documented in the accompanying SAFETY comments. That’s exactly the kind of code I want to write once, review carefully, test exhaustively, and then leave alone. One performance wrinkle is worth mentioning, because these checks run every time an account is loaded. On the SBF VM, compute units are spent roughly per instruction executed, so a naïve byte-by-byte check like iter().all(|b| *b == 0) gets more expensive as the buffer grows. For something like Reserved, that’s negligible. But a BoundedString may contain nearly 50 bytes of trailing padding that have to be revalidated on every account load, and that overhead starts to matter. Instead of checking one byte at a time, the implementation scans the buffer eight bytes at a time as u64 words, using a small all_zerohelper: let (words, tail) = bytes.as_chunks::() }>(); words.iter().all(|&word| u64::from_ne_bytes(word) == 0) && tail.iter().all(|&byte| byte == 0) That word-wise strategy reduced the event_close instruction from 1081 CU to 654 CU — roughly a 40% improvement for an instruction whose entire job is to read a single account. (Subsequent optimizations have brought it down even further — the current tests pin it at 574 CU — but the jump from 1081 to 654 came from this change alone.) The interesting part is why it worked. The original implementation wasn’t suffering from unaligned memory access; reading u8 values is always naturally aligned. The improvement came from doing fewer, wider reads instead of many narrow ones. align_to simply guarantees that those u64 reads are properly aligned, allowing the SBF VM to process the buffer much more efficiently. In hot paths, the cost isn’t alignment — it’s the sheer number of individual load operations. 3. Layout is a contract, not an accident One habit I’ve adopted is treating every account layout as a public API. That means the layout exists in two forms: one for humans, one for the compiler. The human-readable version is a byte-by-byte layout table. Here’s the Event account from the companion repository: offset size field ------ ---- ------------------------------------------------------------- 0 8 header (AccountHeader = discriminator[7] + version[1], align 1) 8 8 created_slot (u64, align 8) 16 8 updated_slot (u64, align 8) 24 32 organizer (Address, align 1) 56 8 capacity (NonZeroU64, align 8) 64 8 price_lamports (NonZeroU64, align 8) 72 8 sold (u64, align 8) 80 66 name (BoundedString, align 2) 146 1 bump (u8, align 1) 147 1 status (EventStatus, align 1) 148 4 _reserved (align pad) And the law: const _: () = { assert!(size_of::() == 152); assert!(align_of::() == 8); }; This is one of my favorite low-tech AI guardrails in the entire codebase. The agent is free to propose a new field, remove one, or change a type — but it can’t silently change the struct’s size or alignment. If either changes, the build breaks until the compile-time assertions are updated. That’s exactly the failure mode I want. “The model accidentally resized or realigned my account” stops being a production incident and becomes a compiler error. The agent has to explain the change, update the documented layout, and make the new contract explicit before the code can compile. The same idea applies to instruction payloads, with one extra convenience. EventCreateArgs ends with a Reserved field that represents alignment padding. The generated client includes those six zero bytes in the full payload. The parser also accepts the shorter payload and zero-fills the reserved tail automatically. That behavior isn’t configured by hand. #[derive(InstructionArgs)] detects the trailing Reserved field, derives the amount of trailing padding from it, and emits a compile-time assertion that the padding is still at the end of the struct. Again, the goal is to eliminate silent failure. The dangerous refactor isn’t adding a field — it’s accidentally inserting one after the reserved space, which would cause the parser to zero-fill real data. Instead of becoming an on-chain bug, that change fails to compile. The derive keeps the parser and the layout in sync automatically, so there’s nothing for the agent— or a human — to forget. 4. Accounts that verify themselves Validating the bytes is only half of the problem. Even a perfectly valid account is dangerous if it belongs to the wrong program or lives at the wrong address. The companion repository pushes those checks into the type system as well, using two small traits. The first is CanonicalPda. Instead of trusting that an account was loaded from the correct PDA, every account type knows how to derive its own canonical address from the data it already stores. For example, an Event account already contains its organizer and bump seed. Loading the account simply recomputes the PDA from those fields and compares the result with the account’s actual address: /// Recomputes the canonical PDA from `self`'s stored fields via the /// single-SHA256 cheap path ([`Address::derive_address`]). fn canonical_address(&self, program_id: &Address) -> Address { let seeds = self.get_seeds(); Address::derive_address(&Self::pda_seed(&seeds), Some(self.get_bump()), program_id) } There’s no bump search involved — just a single SHA-256. More importantly, the stored bump is verified transitively: if the bump is wrong, the derived PDA won’t match the account’s address.PdaAccount builds on top of that. Every account load goes through the same sequence of checks: ownership, expected data length, a checked zero-copy cast, and canonical PDA validation. If any of those fail, parsing fails. A handler simply cannot obtain a typed reference to an account that doesn’t satisfy all of those invariants. Loading is only half the story, though. Mutation has its own guardrail. Every account stores both created_slot and updated_slot, and mutable access goes through a single entry point: mutate_account_at. Before handing out a mutable reference, it repeats the same validation, rejects clock regressions, and wraps the borrow in a SlotGuard tied to the validated slot. The entire purpose of that wrapper lives in its Drop implementation: impl Drop for SlotGuard { fn drop(&mut self) { self.inner.set_current_slot(self.slot); } } No matter how the handler leaves the mutation scope — return, early exit, or error — the updated bytes are written back with the validated slot.It’s a small pattern, but I like what it buys. “When was this account last modified?” stops being a convention that every handler has to remember and becomes something the type system enforces automatically. The AI can’t forget to update the timestamp, because there is no code path where it has to do it manually. 5. One source of truth, many consumers Everything up to this point protects the on-chain program itself. But the same data model also has to be understood by wallets, indexers, SDKs, and tests. The traditional way to do that is to maintain several copies of the same schema: one in the program, another in the IDL, a third in the client library. Sooner or later they drift apart, and someone ends up debugging bytes instead of business logic. I wanted a single source of truth instead. The companion repository uses codama, but not as a separate schema language. The IDL metadata lives directly on the Rust types the program already compiles: /// Creates an event account with a fixed ticket supply and price. #[codama(account(name = "organizer", signer, writable, default_value = payer, docs = "Event organizer and rent payer"))] #[codama(account(name = "event", writable, default_value = pda("event", [account("organizer")]), docs = "Event PDA being initialized"))] #[codama(account(name = "system_program", default_value = program("system"), docs = "System program for the create-account CPI"))] EventCreate { /// Instruction arguments. args: EventCreateArgs, } = 0, From those annotations, a feature-gated generator walks the Rust AST and produces idl/ticketing.json. @codama/renderers-rust then turns that IDL into a complete client crate: instruction builders, account decoders, PDA helpers, and everything else an off-chain application needs.One implementation detail I particularly like is that none of this affects normal builds. The Codama* derives used throughout the codebase aren’t actually codama’s—they’re lightweight stand-ins from ticketing-macros. Their only job is to make the annotations available to the generator. The full codama dependency graph is only pulled in when generating the IDL, not every time the program is compiled. But the interesting part isn’t the code generation itself. It’s that the same metadata also drives the on-chain parser. #[derive(InstructionAccounts)] reads the same #[codama(account(...))] declarations and generates the account parser used by the program at runtime. It verifies the account count, signer and writable constraints, and fixed program addresses directly from that single declaration. That means the IDL consumed by wallets and the validation performed on-chain are literally generated from the same source. Rename an account, change its mutability, or update one of its constraints, and both sides change together. There is nothing to synchronize because there is only one definition to begin with. The same philosophy applies throughout the repository. #[derive(StateAccount)] and #[derive(InstructionArgs)] generate their boilerplate from the Rust types themselves instead of relying on handwritten glue code. Generated code is never edited and never trusted. Two tripwires guard the loop: The first, wire_parity.rs, builds every instruction using the generated client and immediately decodes it with the program’s own parser. The round trip has to match exactly, including PDA derivation. The second, svm_lifecycle.rs, goes one step further. It runs the real compiled program under Mollusk, exercising the generated builders through the entire lifecycle: create an event, buy tickets, close sales, then decode every account that comes back. Those tests matter most when the data model changes. If the agent modifies a wire type, it also has to regenerate the IDL and client. Forget to do that, and the generated client — still built from the committed IDL — no longer agrees with the program’s decoder. If the generator itself gets something wrong (Codama is still young; I even ran into a renderer bug around inline instruction-argument structs and worked around it with named mirror types), the parity tests fail the same way. Either way, the disagreement shows up in CI long before it reaches a wallet or an application. 6. What this buys you Here’s what this layer buys you: every mechanism turns silent corruption into a loud failure. Load the wrong account? The parse fails at the very first byte. Non-zero padding? Parse error. Change the account layout? Compile error. Load from the wrong PDA? Validation fails. Forget to regenerate the client? The parity tests catch it. That’s exactly the feedback loop I want an AI to work in. The agent is free to refactor aggressively, but every change that could silently alter the wire format or break an invariant becomes an immediate, actionable error. The dangerous failures stop being subtle. Of course, this only solves one class of problems. A program can read perfectly valid bytes from the correct account and still make the wrong decision. An overflow in a price calculation. An invalid state transition. An off-by-one in a capacity check. Those aren’t data-model bugs anymore —they’re logic bugs. Tests help, but they’re only as good as the cases someone thought to write. Part 3 is about the final layer of defense: separating the program into trusted shells and formally verified cores, then using a theorem prover — not me — to verify the logic the AI writes. If you’d like to explore the code, the complete companion repository — including the event-ticketing program used throughout this series — is available here: https://github.com/kalaninja/pinocchio-workshop Part 3 (Coming next): formally verifying the logic an AI writes with Creusot.

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.