Skip to content

Comments

StakeStakeV2 zero copy api [wincode]#222

Open
grod220 wants to merge 8 commits intomainfrom
wincode-state
Open

StakeStakeV2 zero copy api [wincode]#222
grod220 wants to merge 8 commits intomainfrom
wincode-state

Conversation

@grod220
Copy link
Member

@grod220 grod220 commented Dec 19, 2025

Adds a new zero‑copy state API backed by wincode.

Key Changes

  • Adds p-stake-interface crate (#![no_std] compatible)
  • Introduces alignment-1 POD types (PodU32, PodU64, PodI64, PodAddress) for safe zero-copy from
    unaligned slices
  • New API via StakeStateV2:
    • StakeStateV2::from_bytes(&[u8]) -> Result<&StakeStateV2, StakeStateError> for read-only access
    • StakeStateV2::from_bytes_mut(&mut [u8]) -> Result<&mut StakeStateV2, StakeStateError> for
      mutations
    • Tag-checked accessors: meta(), stake(), meta_mut(), stake_mut()
    • State transitions: initialize(), delegate()
  • Maintains full ABI compatibility with legacy bincode-encoded stake accounts

Motivation

Deserialization via bincode/borsh is computationally expensive for programs that only need to access specific fields or verify the state. This zero-copy approach allows for significantly lower compute unit (CU) usage when interacting with stake accounts.

@grod220 grod220 changed the title Wincode spike StakeStakeV2 zero copy api [wincode] Jan 12, 2026
@grod220 grod220 marked this pull request as ready for review January 12, 2026 18:27
@grod220 grod220 requested review from febo and joncinque January 12, 2026 18:27
}
}

pub fn from_bytes(bytes: &[u8]) -> Result<Self, StakeStateError> {
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be zero-copy if you do a transmute instead of StakeStateV2Tag::deserialize, but not sure if there is any benefit in doing that.

Copy link
Member Author

Choose a reason for hiding this comment

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

Would we lose some safety though? What do you think about this instead?

    pub fn from_bytes(bytes: &[u8]) -> Result<Self, StakeStateError> {
        if bytes.len() < 4 {
            return Err(StakeStateError::UnexpectedEof);
        }
        let raw = u32::from_le_bytes(bytes[..4].try_into().unwrap());
        Self::from_u32(raw)
    }

#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, SchemaWrite, SchemaRead)]
#[wincode(assert_zero_copy)]
pub struct PodAddress(pub [u8; 32]);
Copy link
Contributor

Choose a reason for hiding this comment

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

We should add wincode support to Address, then this type is not needed.

Copy link
Contributor

Choose a reason for hiding this comment

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

};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StakeStateV2View<'a> {
Copy link
Contributor

@febo febo Jan 15, 2026

Choose a reason for hiding this comment

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

Since this is a breaking change, could we stop using the enum? Seems like all we need is already in StakeStateV2Layout, so that one could become the state "account". One benefit is that we won't need to have the *View and *Write type. The fields not applicable for a variant can be optional.

Copy link
Member Author

Choose a reason for hiding this comment

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

(Discussed offline)

Going to revise the API, but ensure a private-field pattern to ensure only methods can make state transitions to prevent invalidate state.

@grod220 grod220 requested a review from febo January 16, 2026 16:06
Copy link
Contributor

@joncinque joncinque left a comment

Choose a reason for hiding this comment

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

Looks great overall! Mostly small things on my side

Comment on lines +57 to +76
miri:
needs: set_env
runs-on: ubuntu-latest
env:
RUST_TOOLCHAIN: ${{ needs.set_env.outputs.RUST_TOOLCHAIN_NIGHTLY }}
steps:
- name: Git Checkout
uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
components: miri

- name: Setup Miri
run: cargo +"$RUST_TOOLCHAIN" miri setup

- name: Run Miri (pinocchio/interface)
run: make miri-pinocchio-interface
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's integrate this directly as a new variable in the reusable action, ie https://github.com/solana-program/actions/blob/287c6068cc3b672ec4ce9dc6acb75757d477fd2b/.github/workflows/main.yml#L39

Then it's easy for any repo to add a miri check

Comment on lines 14 to 19
/// Wincode deserialization error.
Read(wincode::ReadError),
/// Input buffer is shorter than 200 bytes.
/// Buffer shorter than 200 bytes.
UnexpectedEof,
/// Pass-through for wincode write errors when serializing layout structs.
/// Wincode serialization error.
Write(wincode::WriteError),
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I missed the PR that introduced these, but unless we have a really good reason, it's more annoying than helpful to expose external types in a public API. Let's not have wincode::ReadError or wincode::WriteError there directly, and instead use our own variants

#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, SchemaWrite, SchemaRead)]
#[wincode(assert_zero_copy)]
pub struct PodAddress(pub [u8; 32]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

@febo discussed updating spl-pod to contain these types, which would probably simplify things.

On the other hand, there's no harm in redefining them if needed, since they're just used internally to convert to normal types.

Comment on lines +83 to +92
#[inline]
pub(crate) fn from_u32(v: u32) -> Result<Self, StakeStateError> {
match v {
0 => Ok(Self::Uninitialized),
1 => Ok(Self::Initialized),
2 => Ok(Self::Stake),
3 => Ok(Self::RewardsPool),
other => Err(StakeStateError::InvalidTag(other)),
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: up to you, but it would be more idiomatic rust to impl TryFrom<u32> instead of this function

/// Returns a reference to `Meta` if in the `Initialized` or `Stake` state.
#[inline]
pub fn meta(&self) -> Option<&Meta> {
match self.tag() {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Since this function is called a lot, I wonder if we'd get some performance gains from something like StakeStateV2Tag::from_u32_unchecked to be used from inside the function

Comment on lines +181 to +188
/// Returns a mutable reference to `Meta` if in the `Initialized` or `Stake` state.
#[inline]
pub fn meta_mut(&mut self) -> Result<&mut Meta, StakeStateError> {
match self.tag() {
StakeStateV2Tag::Initialized | StakeStateV2Tag::Stake => Ok(&mut self.meta),
tag => Err(StakeStateError::InvalidStateAccess(tag)),
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: is there a reason for the mutable ones to return a Result and the immutable ones to return an Option? Probably better to be consistent

Comment on lines +231 to +234
if from == StakeStateV2Tag::Initialized {
self.stake_flags = 0;
self.padding.fill(0);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry if I'm being dense, but why is this done only for Initialized?

[dependencies]
# TODO: Waiting on 0.3.0 to be released
wincode = { git = "https://github.com/anza-xyz/wincode.git", rev = "fa70c7c7c13885085f743e4f01deb1a4de0b64fb", default-features = false, features = ["derive"] }
wincode = { git = "https://github.com/anza-xyz/wincode.git", rev = "6feb75ff4c5b26275b0ee8bc4a12ea906d996a02", default-features = false, features = ["derive"] }
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we're now on v0.4, let's use that published version!

Comment on lines 19 to 22
pub const META_OFF: usize = TAG_LEN;
pub const STAKE_OFF: usize = TAG_LEN + size_of::<Meta>();
pub const FLAGS_OFF: usize = TAG_LEN + size_of::<Meta>() + size_of::<Stake>();
pub const PADDING_OFF: usize = FLAGS_OFF + 1;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: doesn't need to be done here, but let's spell out OFFSET fully

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants