Skip to main content

Command Palette

Search for a command to run...

The 7 Bytes That Weren't in the IDL: How #[repr(C)] Padding Breaks a Borsh Client

Updated
17 min readView as Markdown
The 7 Bytes That Weren't in the IDL: How #[repr(C)] Padding Breaks a Borsh Client

A single u64 added seven bytes of padding the compiler knew about and the client didn't. Here is why, and how to predict it for any #[repr(C)] struct.

The failure

I added one field to an instruction-args struct, a plain u64, and my generated client stopped working. Same logical arguments as before (an owner, an amount, a bump), correct values, correct order. The program rejected it anyway. When I built the exact same instruction by hand, it went through.

Here is what that looked like against the program, verbatim from my own debugging run:

[1] client-built ix: 41 arg bytes (borsh). Program expects size_of::<Initialize>() = 48.
    Transaction simulation failed: Error processing Instruction 0: custom program error: 0x0
  Program CbMX... invoke [1]
  Program log: initialize
  Program CbMX... failed: custom program error: 0x0
 
[2] hand-built repr(C) ix: 48 arg bytes.
    SUCCESS

Forty-one bytes rejected. Forty-eight bytes accepted. The difference is not the data. It is seven bytes that exist in the program's view of the struct and do not exist in the client's. Those seven bytes are trailing padding, and nothing in the generated IDL told the client to send them.

This post is the walk from that custom program error: 0x0 back to the layout rule that explains it, so that next time you can predict the padding before it costs you a debugging session.

TL;DR

  • A zero-copy read (&*(ptr as *const T)) requires the exact #[repr(C)] byte layout, which includes any padding, so it demands size_of::<T>() bytes.

  • A borsh client serializes fields packed, with no padding, so it sends Σ field sizes bytes.

  • They agree only when the struct has no implicit padding, that is, when size_of::<T>() == Σ field sizes.

  • Padding is a function of field alignments, field order, and total size. It is not caused by "having a big field" on its own.

  • The reliable fixes: use only align-1 fields, or order fields by descending alignment, or expose the gap as an explicit _padding: [u8; N] field so it appears in the IDL and the client sends it too.

  • Verify with size_of::<T>() == Σ field sizes. A compile-time assert! on that catches the regression at build time.

Why this bites: two layouts that must agree

On Solana, an on-chain program and its off-chain client are two programs that have to agree on a byte buffer. In a pinocchio-style program, instruction arguments are read zero-copy: the program takes the raw instruction-data bytes and casts a pointer straight to your struct, no deserialization step. The general technique is what pinocchio is built around. As an article from Anza (by Febo, a core engineer at Anza) puts it, zero-copy means reading data straight from the input buffer instead of deserializing it into owned types, which for fixed-layout data avoids a large amount of unnecessary work. That cast only produces correct fields if the incoming bytes are laid out exactly as the compiler laid out the struct, which means the full #[repr(C)] layout, padding and all. A zero-copy read of T wants size_of::<T>() bytes, in T's exact in-memory shape.

The generated client, on the other hand, serializes the same arguments with borsh. Borsh packs fields back to back with no padding. So the client emits Σ field sizes bytes.

Those two numbers are equal only when the struct carries no implicit padding. When they differ, the client and the program disagree about how many bytes the arguments are, and the arguments are read wrong or, as above, rejected outright. Everything else in this post is about predicting that one equality.

Size and alignment: the two numbers

Every type in Rust has two numbers that drive layout:

  • size: how many bytes it occupies.

  • alignment: a value of this type must start at an offset that is a multiple of this number. Padding exists for exactly one reason: to satisfy alignment. If nothing needed to start on a boundary, nothing would ever be padded.

Alignment of the types you actually use

The layout algorithm below runs on the alignments of the primitives you build with, so here they are. The one that trips people is at the bottom: a 32-byte pubkey is align 1, not align 32, because it is a byte array.

Type size align
bool, u8, i8 1 1
u16, i16 2 2
u32, i32, f32 4 4
u64, i64, f64 8 8
u128, i128 16 16
[T; N] N × size(T) align(T)
Address / Pubkey ([u8; 32] newtype) 32 1
a struct (see below) max alignment of its fields

Address and Pubkey are byte arrays: align 1, size 32. They never cause padding on their own, which is why an all-pubkey struct is always safe to read zero-copy.

The #[repr(C)] layout algorithm

#[repr(C)] gives you a defined, predictable layout (unlike the default repr(Rust), which the compiler may reorder). The whole thing is this loop:

offset = 0
for each field, in declaration order:
    offset = round_up(offset, align(field))   # gap here = INTERNAL padding
    place field at offset
    offset += size(field)
 
struct_align = max(align(field) for all fields)
struct_size  = round_up(offset, struct_align) # gap here = TRAILING padding

round_up(x, a) is the smallest multiple of a that is >= x.

Three consequences fall directly out of this, and they are the load-bearing facts for the rest of the post:

  • size_of::<T>() equals struct_size, which includes all padding.

  • The borsh size equals the sum of the field sizes, with no padding.

  • Therefore implicit padding is present if and only if size_of::<T>() != Σ field sizes. That last line is the entire test. If those two numbers match, the client and the program agree. If they do not, they do not.

Two kinds of padding: internal and trailing

The algorithm inserts padding in two places, and they have different fixes, so it is worth naming them.

Internal padding sits between fields. A field whose alignment is greater than 1 lands on an offset that is not a multiple of its alignment, so the compiler inserts bytes before it:

struct { a: u8, b: u64 }
 offset 0: a            (1 byte)
 offset 1..8: PAD       (7 bytes; u64 needs offset % 8 == 0)
 offset 8: b            (8 bytes)
 size = 16, sum = 9   -> 7 bytes internal padding

Trailing padding sits at the end. The final size is not a multiple of the struct's own alignment, so bytes are appended, so that an array of the struct keeps every element aligned:

struct { a: u64, b: u8 }
 offset 0: a            (8 bytes)
 offset 8: b            (1 byte)  -> offset now 9
 struct_align = 8, round_up(9, 8) = 16
 size = 16, sum = 9   -> 7 bytes trailing padding

Notice the same two fields, in either order, still cost 7 bytes of padding. Reordering moved where the padding lands (internal vs trailing) but not whether it exists. That is a useful warning: field ordering is a real tool, but it is not a guaranteed fix by itself.

When padding appears: the rules

Three cases cover every struct, and you can sort any struct into one without running the compiler.

No padding, guaranteed: every field has align 1 (u8, i8, bool, [u8; N], Address, Pubkey), in any order. Nothing ever needs a boundary, so nothing is ever inserted.

No padding, if you are careful: fields are placed so each aligned field lands on its boundary with no gap, and the total size is already a multiple of the largest alignment. In practice that means order fields by descending alignment (which removes internal padding), and arrange for the running total to reach a multiple of the biggest alignment on its own, or add an explicit _padding field to reach it (which removes trailing padding).

Padding appears when either a field with align > 1 follows fields whose sizes do not add up to a multiple of that field's alignment (internal padding), or the total size is not a multiple of the struct's max alignment (trailing padding).

So padding is a function of field alignments, order, and total size, not of "having a big field." A u64 alone in a struct pads nothing ({ u64 } is size 8). A u64 sitting next to a u8 is what introduces the gap.

Here is the rule applied across a spread of structs. Two of these are marked because they show up later: the safe all-align-1 case, and the one that actually broke.

{ u8 }                       size 1,  sum 1    no padding
{ u64 }                      size 8,  sum 8    no padding
{ u32, u32 }                 size 8,  sum 8    no padding
{ u32, u16, u16 }            size 8,  sum 8    no padding
{ u16, u16, u32 }            size 8,  sum 8    no padding
{ Address, u8 }              size 33, sum 33   no padding   (both align 1)
{ u8, u64 }                  size 16, sum 9    7 internal
{ u64, u8 }                  size 16, sum 9    7 trailing
{ u16, u32 }                 size 8,  sum 6    2 internal
{ u32, u16 }                 size 8,  sum 6    2 trailing
{ Address, u64, u8 }         size 48, sum 41   7 trailing   <- the bug below
{ u8, u32, u8 }              size 12, sum 6    (3 before u32) + (3 trailing) = 6
{ u128, u8 }                 size 32, sum 17   15 trailing

That { Address, u64, u8 } line, size 48, sum 41, seven bytes of trailing padding, is the struct that produced the custom program error: 0x0.

Back to the bug

The struct started as a scaffold instruction-args type. I added the amount: u64 in the middle:

#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, shank::ShankType)]
pub struct Initialize {
    pub owner: Address,   // 32
    pub amount: u64,      // 8   <- added this
    pub bump: u8,         // 1
}

Adding the u64 raised the struct's alignment to 8. Now walk the layout:

field align offset before pad at after
owner 1 0 0 0 32
amount 8 32 0 (32 is 8-aligned) 32 40
bump 1 40 0 40 41
end 41 struct align 8 -> 41 rounds to 48 48

So size_of::<Initialize>() is 48, while borsh is 32 + 8 + 1 = 41. Seven bytes of trailing padding, exactly the amount needed to round 41 up to the next multiple of 8. The owner and amount fit perfectly (32 is already 8-aligned, so there is no internal padding); the whole cost lands at the tail.

The program reads the args zero-copy, and the read enforces the length:

pub unsafe fn load_ix_data<T>(bytes: &[u8]) -> Result<&T, ProgramError> {
    if bytes.len() != T::LEN {                 // T::LEN = size_of::<Initialize>() = 48
        return Err(MyProgramError::InvalidInstructionData.into());  // -> custom error 0x0
    }
    Ok(&*(bytes.as_ptr() as *const T))
}

The client sent 41 borsh bytes. 41 != 48, so the guard returns InvalidInstructionData, which surfaces as custom program error: 0x0. The seven padding bytes are the whole problem: they are not in the IDL, so the client never serialized them.

One nuance worth keeping, because it tells you where padding bites. The account (state) struct in the same program, holding just owner: Address and amount: u64, is size 40 and borsh 40, padding-free, so its account path round-trips fine. Both the args struct and the account struct are read on-chain by the same family of raw-pointer cast; the account struct simply happens to have no padding, so its borsh client agrees. Padding broke the instruction-args struct, not the account struct, and it was the layout of the struct that decided it, not the read mechanism.

The fix, and the guards

The fix is to stop hiding the padding. Turn those seven trailing bytes into a real field:

#[repr(C)]
pub struct Initialize {
    pub owner: Address,     // 32
    pub amount: u64,        // 8
    pub bump: u8,           // 1
    pub _padding: [u8; 7],  // 7   <- explicit; now a real field, and in the IDL
}
field align at after
owner 1 0 32
amount 8 32 40
bump 1 40 41
_padding 1 41 48
end 48 (48 % 8 = 0, no trailing)

Now size_of is 48 and the field sizes sum to 32 + 8 + 1 + 7 = 48. Implicit padding is zero. Because _padding is a declared field, shank sees it, so it lands in the IDL, so the client serializes it too. The client now sends 48 bytes, exactly what load_ix_data expects. Both sides agree. Step [2] from the opening log already proved this shape works: a hand-built 48-byte payload (owner, amount, bump, then seven zero bytes) executed on-chain, and that is byte-for-byte what the client sends once _padding: [u8; 7] is a real field.

Because I would rather catch this at build time than in a simulation log, pinoc now ships two guards, and both are worth showing because they encode the rule directly.

The scaffold emits a compile-time assert next to the struct. On the plain scaffold struct (Initialize { owner: Address, bump: u8 }, size 33) it reads:

const _: () = assert!(
    core::mem::size_of::<Initialize>() == 32 + 1,
    "Initialize has implicit padding; add explicit _padding fields so its #[repr(C)] layout matches the borsh client"
);

That == 32 + 1 is the packed size the layout is supposed to have. The moment you add amount: u64, size_of becomes 48, the equality is now 48 == 33, and the build fails at const-eval:

error[E0080]: evaluation panicked: Initialize has implicit padding; add explicit _padding fields so its #[repr(C)] layout matches the borsh client
  --> src/instructions/initialize.rs:28:15
   |
28 |   const _: () = assert!(
   |  _______________^
29 | |     core::mem::size_of::<Initialize>() == 32 + 1,
30 | |     "Initialize has implicit padding; add explicit _padding fields so its #[repr(C)] layout matches the borsh client"
31 | | );
   | |_^ evaluation of `instructions::initialize::_` failed here
 
For more information about this error, try `rustc --explain E0080`.
error: could not compile `e0080_repro` (lib) due to 1 previous error

(The error[E0080]: evaluation panicked: <message> phrasing is toolchain-dependent; this is from the current Solana platform-tools rustc. Older compilers word const-assert failures differently, so pin your toolchain if you quote it. The crate name and line numbers above are from a scratch reproduction, not the committed template.)

For programs that do not carry the assert, pinoc build and pinoc idl run a lint that finds padded #[repr(C)] structs and prints:

⚠️  `Initialize` is #[repr(C)] with 7 byte(s) of implicit padding (layout size 48 vs packed 41). The generated client (de)serializes it as packed borsh, so it won't round-trip on-chain. Add explicit `_padding: [u8; 7]` field(s).

Both guards landed together and are on main. The source is public if you want to read the exact emitter and detection logic: github.com/A91y/pinoc (the assert lives in the scaffold templates, the lint in src/idl/, merged in commit 04031fb).

One more note on explicit padding, because it is easy to over-learn the lesson. Explicit _padding is not always fixing an alignment problem. The Solana Foundation pinocchio-counter uses it on an all-align-1 struct:

#[repr(C)]
struct Counter {
    bump: u8,
    _padding: [u8; 7],   // EXPLICIT padding: a real field, appears in the IDL
    authority: Address,  // 32
    count: u8,
}

Here authority is align 1, so nothing forces a gap. The _padding is a deliberate design choice, not an alignment repair. The underlying principle is the same either way: make every gap an explicit field so size_of == Σ field sizes, whether the gap is demanded by alignment or chosen by you.

The checklist

When you write a #[repr(C)] struct that will be read zero-copy on-chain and serialized by a borsh client:

  1. Prefer align-1 fields. u8, [u8; N], Address. If you want zero risk, store integers as [u8; 8] and reach for u64::from_le_bytes(...) at the read site. Align-1 structs never pad.

  2. Otherwise, order fields by descending alignment (biggest align first) to kill internal padding.

  3. Add an explicit _padding: [u8; N] to round the total up to the max alignment, killing trailing padding. Now the padding is a real IDL field and the client sends it.

  4. Verify size_of::<T>() == Σ field sizes. A const _: () = assert!(...) on that equality turns this whole class of bug into a build error.

Test yourself

Predict size_of, Σ field sizes, and the padding for each. Answers below.

  1. { u8, u8, u16 }

  2. { u16, u8, u8 }

  3. { u32, u8 }

  4. { Address, u32 }

  5. { u64, u32, u32 }

  6. { u8, u128 } Answers:

  7. size 4, sum 4, 0 (u16 at offset 2 is already aligned; total 4 is a multiple of 2).

  8. size 4, sum 4, 0 (u16 at 0, u8 at 2, u8 at 3; total 4).

  9. size 8, sum 5, 3 trailing (u32 at 0, u8 at 4 gives 5, round up to 8).

  10. size 36, sum 36, 0 (Address align 1 at 0..32, u32 needs align 4, and 32 % 4 == 0 so no gap; total 36 is a multiple of 4).

  11. size 16, sum 16, 0 (u64 at 0, u32 at 8, u32 at 12 gives 16, a multiple of 8).

  12. size 32, sum 17, 15 (u8 at 0, u128 needs align 16 so it lands at 16, giving 15 bytes of internal padding; total 32).

Takeaway

The bug was never in the data. It was in the seven bytes the compiler adds and the IDL does not describe. A zero-copy read wants the full #[repr(C)] layout; a borsh client sends packed fields; they agree only when size_of::<T>() == Σ field sizes. Run that layout loop in your head when you write the struct, or assert the equality so the compiler runs it for you. Either way it becomes a build error instead of a simulation log, which is where I wanted it.

Why this post exists

This came out of building pinoc, my IDL and client generator for pinocchio programs. I hit the padding mismatch in my own work: the client kept getting custom program error: 0x0 even though the arguments were correct, and it took a while of banging my head against it before the size mismatch clicked. Once it did, I figured the write-up was worth reconciling into something the community could use, since the next person to add a u64 to a #[repr(C)] args struct is going to hit exactly this.

The IDL and client generation that surfaced all of this ships with pinoc v0.2.0. If you want to try it or read the layout guards for yourself, the source is here: github.com/A91y/pinoc.

If you have questions or want to poke at it, I am on X at @AyushAgr91.

Further reading

For the authoritative rules behind everything above, the Rust Reference on type layout is the source of truth: https://doc.rust-lang.org/reference/type-layout.html.

This is the first post in a series on low-level Solana and pinocchio work. More is coming :)