Skip to content

feat: zero-copy-derive #1849

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 28 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ jobs:
cargo test -p light-account-checks --all-features
cargo test -p light-verifier --all-features
cargo test -p light-merkle-tree-metadata --all-features
cargo test -p light-zero-copy --features std
cargo test -p light-zero-copy --features "std, mut, derive"
cargo test -p light-zero-copy-derive --features "mut"
cargo test -p light-hash-set --all-features
- name: program-libs-slow
packages: light-bloom-filter light-indexed-merkle-tree light-batched-merkle-tree
Expand Down
44 changes: 44 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ members = [
"program-libs/hash-set",
"program-libs/indexed-merkle-tree",
"program-libs/indexed-array",
"program-libs/zero-copy-derive",
"programs/account-compression",
"programs/system",
"programs/compressed-token",
Expand Down Expand Up @@ -167,6 +168,7 @@ light-compressed-account = { path = "program-libs/compressed-account", version =
light-account-checks = { path = "program-libs/account-checks", version = "0.3.0" }
light-verifier = { path = "program-libs/verifier", version = "2.1.0" }
light-zero-copy = { path = "program-libs/zero-copy", version = "0.2.0" }
light-zero-copy-derive = { path = "program-libs/zero-copy-derive", version = "0.1.0" }
photon-api = { path = "sdk-libs/photon-api", version = "0.51.0" }
forester-utils = { path = "forester-utils", version = "2.0.0" }
account-compression = { path = "programs/account-compression", version = "2.0.0", features = [
Expand Down
Empty file added program-libs/expand.rs
Empty file.
26 changes: 26 additions & 0 deletions program-libs/zero-copy-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "light-zero-copy-derive"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
description = "Proc macro for zero-copy deserialization"

[features]
default = []
mut = []

[lib]
proc-macro = true

[dependencies]
proc-macro2 = "1.0"
quote = "1.0"
syn = { version = "2.0", features = ["full", "extra-traits"] }
lazy_static = "1.4"

[dev-dependencies]
trybuild = "1.0"
rand = "0.8"
borsh = { workspace = true }
light-zero-copy = { workspace = true, features = ["std", "derive"] }
zerocopy = { workspace = true, features = ["derive"] }
103 changes: 103 additions & 0 deletions program-libs/zero-copy-derive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Light-Zero-Copy-Derive

A procedural macro for deriving zero-copy deserialization for Rust structs used with Solana programs.

## Features

This crate provides two key derive macros:

1. `#[derive(ZeroCopy)]` - Implements zero-copy deserialization with:
- The `zero_copy_at` and `zero_copy_at_mut` methods for deserialization
- Full Borsh compatibility for serialization/deserialization
- Efficient memory representation with no copying of data
- `From<Z<StructName>>` and `From<Z<StructName>Mut>` implementations for easy conversion back to the original struct

2. `#[derive(ZeroCopyEq)]` - Adds equality comparison support:
- Compare zero-copy instances with regular struct instances
- Can be used alongside `ZeroCopy` for complete functionality
- Derivation for Options<struct> is not robust and may not compile.

## Rules for Zero-Copy Deserialization

The macro follows these rules when generating code:

1. Creates a `ZStruct` for your struct that follows zero-copy principles
1. Fields are extracted into a meta struct until reaching a `Vec`, `Option` or non-`Copy` type
2. Vectors are represented as `ZeroCopySlice` and not included in the meta struct
3. Integer types are replaced with their zerocopy equivalents (e.g., `u16` → `U16`)
4. Fields after the first vector are directly included in the `ZStruct` and deserialized one by one
5. If a vector contains a nested vector (non-`Copy` type), it must implement `Deserialize`
6. Elements in an `Option` must implement `Deserialize`
7. Types that don't implement `Copy` must implement `Deserialize` and are deserialized one by one

## Usage

### Basic Usage

```rust
use borsh::{BorshDeserialize, BorshSerialize};
use light_zero_copy_derive::ZeroCopy;
use light_zero_copy::{borsh::Deserialize, borsh_mut::DeserializeMut};

#[repr(C)]
#[derive(Debug, PartialEq, BorshSerialize, BorshDeserialize, ZeroCopy)]
pub struct MyStruct {
pub a: u8,
pub b: u16,
pub vec: Vec<u8>,
pub c: u64,
}
let my_struct = MyStruct {
a: 1,
b: 2,
vec: vec![1u8; 32],
c: 3,
};
// Use the struct with zero-copy deserialization
let mut bytes = my_struct.try_to_vec().unwrap();

// Immutable zero-copy deserialization
let (zero_copy, _remaining) = MyStruct::zero_copy_at(&bytes).unwrap();

// Convert back to original struct using From implementation
let converted: MyStruct = zero_copy.clone().into();
assert_eq!(converted, my_struct);

// Mutable zero-copy deserialization with modification
let (mut zero_copy_mut, _remaining) = MyStruct::zero_copy_at_mut(&mut bytes).unwrap();
zero_copy_mut.a = 42;

// The change is reflected when we convert back to the original struct
let modified: MyStruct = zero_copy_mut.into();
assert_eq!(modified.a, 42);

// And also when we deserialize directly from the modified bytes
let borsh = MyStruct::try_from_slice(&bytes).unwrap();
assert_eq!(borsh.a, 42u8);
```
Comment on lines +37 to +77
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify the code examples compile and run correctly.

The usage examples look comprehensive, but it's important to ensure they actually compile and demonstrate the intended functionality.


🏁 Script executed:

#!/bin/bash
# Create a temporary test file to verify the README examples compile
cat > /tmp/readme_test.rs << 'EOF'
use borsh::{BorshDeserialize, BorshSerialize};
use light_zero_copy_derive::ZeroCopy;
use light_zero_copy::{borsh::Deserialize, borsh_mut::DeserializeMut};

#[repr(C)]
#[derive(Debug, PartialEq, BorshSerialize, BorshDeserialize, ZeroCopy)]
pub struct MyStruct {
    pub a: u8,
    pub b: u16,
    pub vec: Vec<u8>,
    pub c: u64,
}

fn main() {
    let my_struct = MyStruct {
        a: 1,
        b: 2,
        vec: vec![1u8; 32],
        c: 3,
    };
    let mut bytes = my_struct.try_to_vec().unwrap();
    let (zero_copy, _remaining) = MyStruct::zero_copy_at(&bytes).unwrap();
    let converted: MyStruct = zero_copy.clone().into();
    assert_eq!(converted, my_struct);
}
EOF

# Try to check if the syntax is valid (this is a basic check)
rustc --edition 2021 --crate-type bin /tmp/readme_test.rs --extern borsh --extern light_zero_copy_derive --extern light_zero_copy 2>&1 || echo "Example may need syntax adjustments"

Length of output: 2546


Use Cargo to compile and run the zero-copy examples

The Rust snippets in your README won’t compile with a plain rustc invocation because the external crates and Borsh traits aren’t in scope. To verify both the immutable and mutable zero-copy flows:

• Add the required dependencies to your Cargo.toml (adjust versions/paths as needed):

+# Cargo.toml
+[dependencies]
+borsh = "0.10"
+light_zero_copy = { path = "program-libs/zero-copy" }
+light_zero_copy_derive = { path = "program-libs/zero-copy-derive" }

• Move the code sample into an example file at examples/zero_copy.rs (wrap it in fn main() { … }).
• Run the example and doctests:

cargo run --example zero_copy
cargo test --doc

This will pull in the Borsh traits (try_to_vec), the derive macros, and ensure that zero_copy_at / zero_copy_at_mut compile, execute, and round-trip correctly.

🤖 Prompt for AI Agents
In program-libs/zero-copy-derive/README.md between lines 37 and 77, the Rust
code snippet is not directly runnable because it lacks the necessary Cargo.toml
dependencies and is not wrapped in a main function. To fix this, add the
required external crate dependencies (borsh, light_zero_copy_derive,
light_zero_copy) to Cargo.toml with appropriate versions, move the code into a
new example file at examples/zero_copy.rs, wrap the code inside a fn main()
function, and then run it using cargo run --example zero_copy and cargo test
--doc to verify compilation and execution.


### With Equality Comparison

```rust
use borsh::{BorshDeserialize, BorshSerialize};
use light_zero_copy_derive::ZeroCopy;

#[repr(C)]
#[derive(Debug, PartialEq, BorshSerialize, BorshDeserialize, ZeroCopy)]
pub struct MyStruct {
pub a: u8,
pub b: u16,
pub vec: Vec<u8>,
pub c: u64,
}
let my_struct = MyStruct {
a: 1,
b: 2,
vec: vec![1u8; 32],
c: 3,
};
// Use the struct with zero-copy deserialization
let mut bytes = my_struct.try_to_vec().unwrap();
let (zero_copy, _remaining) = MyStruct::zero_copy_at(&bytes).unwrap();
assert_eq!(zero_copy, my_struct);
```
132 changes: 132 additions & 0 deletions program-libs/zero-copy-derive/src/byte_len_derive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Field, Ident};

use crate::{
utils,
z_struct::{analyze_struct_fields, FieldType},
};

/// Generates ByteLen implementation for structs
///
/// RULES AND EXCEPTIONS FROM borsh_mut.rs:
///
/// DEFAULT RULE: Call byte_len() on each field and sum the results
///
/// EXCEPTIONS:
/// 1. Boolean fields: Use core::mem::size_of::<u8>() (1 byte) instead of byte_len()
/// * See line 97 where booleans use a special case
///
/// NOTES ON TYPE-SPECIFIC IMPLEMENTATIONS:
/// * Primitive types: self.field.byte_len() delegates to size_of::<T>()
/// - u8, u16, u32, u64, etc. all use size_of::<T>() in their implementations
/// - See implementations in lines 88-90, 146-148, and macro in lines 135-151
///
/// * Arrays [T; N]: use size_of::<Self>() in implementation (line 41)
///
/// * Vec<T>: 4 bytes for length prefix + sum of byte_len() for each element
/// - The Vec implementation in line 131 is: 4 + self.iter().map(|t| t.byte_len()).sum::<usize>()
/// - Special case in Struct4 (line 650-657): explicitly sums the byte_len of each item
///
/// * VecU8<T>: Uses 1 byte for length prefix instead of regular Vec's 4 bytes
/// - Implementation in line 205 shows: 1 + size_of::<T>()
///
/// * Option<T>: 1 byte for discriminator + value's byte_len if Some, or just 1 byte if None
/// - See implementation in lines 66-72
///
/// * Fixed-size types: Generally implement as their own fixed size
/// - Pubkey (line 45-46): hard-coded as 32 bytes
pub fn generate_byte_len_derive_impl<'a>(
_name: &Ident,
meta_fields: &'a [&'a Field],
struct_fields: &'a [&'a Field],
) -> TokenStream {
let field_types = analyze_struct_fields(struct_fields);

// Generate statements for calculating byte_len for each field
let meta_byte_len = if !meta_fields.is_empty() {
meta_fields
.iter()
.map(|field| {
let field_name = &field.ident;
// Handle boolean fields specially by using size_of instead of byte_len
if utils::is_bool_type(&field.ty) {
quote! { core::mem::size_of::<u8>() }
} else {
quote! { self.#field_name.byte_len() }
}
})
.reduce(|acc, item| {
quote! { #acc + #item }
})
} else {
None
};

// Generate byte_len calculations for struct fields
// Default rule: Use self.field.byte_len() for all fields
// Exception: Use core::mem::size_of::<u8>() for boolean fields
let struct_byte_len = field_types.into_iter().map(|field_type| {
match field_type {
// Exception 1: Booleans use size_of::<u8>() directly
FieldType::Bool(_) | FieldType::CopyU8Bool(_) => {
quote! { core::mem::size_of::<u8>() }
}
// All other types delegate to their own byte_len implementation
FieldType::VecU8(field_name)
| FieldType::VecCopy(field_name, _)
| FieldType::VecNonCopy(field_name, _)
| FieldType::Array(field_name, _)
| FieldType::Option(field_name, _)
| FieldType::Pubkey(field_name)
| FieldType::IntegerU64(field_name)
| FieldType::IntegerU32(field_name)
| FieldType::IntegerU16(field_name)
| FieldType::IntegerU8(field_name)
| FieldType::Copy(field_name, _)
| FieldType::NonCopy(field_name, _) => {
quote! { self.#field_name.byte_len() }
},
FieldType::OptionU64(field_name)
| FieldType::OptionU32(field_name)
| FieldType::OptionU16(field_name) => {
quote! { self.#field_name.as_ref().map_or(1, |x| 1 + x.byte_len()) }
}
}
});

// Combine meta fields and struct fields for total byte_len calculation
let combined_byte_len = match meta_byte_len {
Some(meta) => {
let struct_bytes = struct_byte_len.fold(quote!(), |acc, item| {
if acc.is_empty() {
item
} else {
quote! { #acc + #item }
}
});

if struct_bytes.is_empty() {
meta
} else {
quote! { #meta + #struct_bytes }
}
}
None => struct_byte_len.fold(quote!(), |acc, item| {
if acc.is_empty() {
item
} else {
quote! { #acc + #item }
}
}),
};

// Generate the final implementation
quote! {
impl light_zero_copy::ByteLen for #_name {
fn byte_len(&self) -> usize {
#combined_byte_len
}
}
}
}
Loading
Loading