Skip to content

Add native Rust (non-Anchor) VRF example#78

Open
0xraidr wants to merge 1 commit intomagicblock-labs:mainfrom
0xraidr:main
Open

Add native Rust (non-Anchor) VRF example#78
0xraidr wants to merge 1 commit intomagicblock-labs:mainfrom
0xraidr:main

Conversation

@0xraidr
Copy link
Copy Markdown

@0xraidr 0xraidr commented Apr 26, 2026

The existing MagicBlock VRF "roll-dice" example is built on Anchor and pairs with the Anchor client. This adds a plain Solana program (solana-program + Borsh + ephemeral-vrf-sdk) and @solana/kit integration tests on devnet, so users can see the same VRF flow without Anchor in the on-chain or client stack.

Summary by CodeRabbit

  • New Features

    • Added a Solana program for verifiable random function (VRF) functionality, enabling players to initialize accounts, request randomness from oracles, and receive random values through on-chain callbacks.
  • Documentation

    • Included comprehensive README with build/deploy instructions and usage examples.
  • Tests

    • Added integration and unit tests for player initialization and randomness request workflows.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 26, 2026

@0xraidr is attempting to deploy a commit to the MagicBlock Labs Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

Walkthrough

Introduces a complete Solana on-chain program for Verifiable Random Function (VRF) integration using the Ephemeral VRF SDK. The implementation includes instruction handlers for player initialization, randomness requests, and VRF callback consumption, alongside comprehensive TypeScript client utilities and integration tests. Totals approximately 1000 lines across Rust program code, test infrastructure, and configuration files.

Changes

Cohort / File(s) Summary
Core Program Structure
Cargo.toml, src/lib.rs, src/entrypoint.rs
Establishes Rust crate metadata and program entrypoint; declares dependencies (solana-program, ephemeral-vrf-sdk, borsh); sets up module organization and exposes primary instruction types.
Error Handling
src/error.rs
Defines custom VrfError enum with stable error codes (starting at 0x1770) for initialization, PDA validation, data format, and callback invocation; implements conversion to Solana ProgramError.
State Management
src/state.rs
Implements PlayerState struct with discriminator, random value, and bump; provides PlayerState::LEN constant and find_player_pda() PDA derivation using [PLAYER_SEED, authority] seeds.
VRF Integration
src/vrf_lite.rs
Defines VRF callback data contract with 8-byte Anchor-style discriminator prefix + 32-byte randomness bytes; exports parsing and detection helpers for callback instruction validation.
Instruction Handlers
src/instructions/mod.rs, src/instructions/initialize_player.rs, src/instructions/request_randomness.rs, src/instructions/callback_consume_randomness.rs
Implements three instruction processors: PDA creation with rent-exempt account setup; randomness request with oracle queue/sysvar validation and CPI into Ephemeral VRF SDK; VRF callback consumption with randomness parsing and player state update.
Instruction Dispatch
src/processor.rs
Routes decoded Borsh instructions to handlers; detects callback invocations via 8-byte prefix; defines VrfInstruction enum (InitializePlayer, RequestRandomness, CallbackConsumeRandomness) with unit test validating Borsh encoding.
Documentation
README.md
Describes program flow (initialization, request, callback), on-chain steps with account validation, and build/deploy/test instructions with environment variable configuration.
TypeScript Test Configuration
test/package.json, test/tsconfig.json, test/vitest.config.ts
Sets up ESM test environment with Vitest; configures TypeScript with strict checking, ES2022 target, and global Vitest/Node types; enforces 60-second test timeout and serial execution.
Test Utilities
test/utils.ts
Provides devnet-focused helpers: kit RPC/WS client factory, keypair loading, instruction encoding, PDA derivation, transaction builder/signer, account data parsing, and polling/assertion utilities for player state verification.
Integration Tests
test/initializePlayer.test.ts, test/vrfRequest.test.ts
Tests player initialization (discriminator/state validation) and randomness request flow (callback receipt and state update); includes encoding verification for instruction byte format.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • jonasXchen
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a native Rust VRF example without Anchor framework. It is concise, specific, and clearly conveys the primary objective of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 16

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@native-rust-vrf/Cargo.toml`:
- Line 4: Update the Cargo.toml edition setting from "2024" to "2021" so the
crate uses the supported Rust edition for the current Solana/Agave
cargo-build-sbf toolchain; locate the edition = "2024" entry in Cargo.toml and
change it to edition = "2021" to align with other examples and avoid build
failures.

In `@native-rust-vrf/README.md`:
- Line 3: The README currently describes two different dice ranges; update the
documentation to consistently use 1–6 to match the PR intent and the
implementation in callback_consume_randomness.rs: change the intro sentence and
the later occurrence (around line 26) to state randomness is mapped to 1–6, and
verify PlayerState population and any examples/snippets reference the same 1–6
mapping so docs match the logic in callback_consume_randomness.rs.
- Around line 33-36: The README's deploy command wrongly references
reflex_program-keypair.json from another project; update the solana program
deploy example to use a matching placeholder for the keypair (e.g., replace
reflex_program-keypair.json with <your_program>-keypair.json or
native-rust-vrf-keypair.json) so it aligns with the <your_program>.so artifact
and the rest of this crate.

In `@native-rust-vrf/src/entrypoint.rs`:
- Around line 11-25: The entry function process_instruction uses a parameter
named _instruction_data which implies it's unused but it's both logged and
forwarded; rename the parameter to instruction_data in the fn signature and all
uses (the msg! call and the processor::process_instruction invocation) so intent
is clear, and simplify the return by returning the result of
processor::process_instruction(...) directly instead of using ?; Ok(()). Also
gate the verbose msg! that prints the full instruction bytes (around the
existing msg! call) behind a compile-time feature or debug flag (e.g.,
cfg(feature = "verbose_logs") or cfg!(debug_assertions)) to avoid bloating logs
and consuming compute on mainnet.

In `@native-rust-vrf/src/instructions/callback_consume_randomness.rs`:
- Around line 53-55: Remove the stale commented line and stop clamping the
randomness to 1..=6 inside the callback: delete the commented `// p.random_value
= rnd::random_u64(randomness);` and replace the use of
`rnd::random_u8_with_range(randomness, 1, 6)` for `p.random_value` with either
the full random value (e.g., `rnd::random_u64(randomness)`) or store the raw
randomness elsewhere; additionally, to preserve the "dice roll" example without
breaking test polling, add a monotonic field (e.g., `request_id` or `nonce`) to
`PlayerState` and increment it in the same callback so
`pollUntilPlayerRandomValueChanges` can reliably wait on a strictly increasing
field while `p.random_value` can remain a bounded display value if desired;
update references to `p.random_value`, the callback function handling
randomness, and tests that poll `pollUntilPlayerRandomValueChanges` accordingly.
- Around line 42-51: The Borsh try_from_slice is failing if the account buffer
has trailing bytes; update the PlayerState deserialization to explicitly read
only the first PlayerState::LEN bytes: first bind let data =
player.try_borrow_data()?; if data.len() < PlayerState::LEN return
VrfError::PlayerNotInitialized; then call
PlayerState::try_from_slice(&data[..PlayerState::LEN]) to produce p and keep the
existing discriminator check (p.discriminator vs
crate::state::DISCRIMINATOR_PLAYER). This ensures extra allocated bytes won't
break callback_consume_randomness.

In `@native-rust-vrf/src/instructions/initialize_player.rs`:
- Around line 51-76: The current initialize_player logic uses
system_instruction::create_account which fails if the PDA already holds lamports
(owner == system_program::ID) even though you only checked for non-zero lamports
with other owners; update initialize_player to handle the pre-funded-PDA case:
when player_pda.lamports() > 0 && *player_pda.owner == system_program::ID,
instead of calling create_account, perform the allocate + assign + transfer
sequence (allocate space = PlayerState::LEN, assign to *program_id, and transfer
the rent.minimum_balance(PlayerState::LEN) minus existing lamports) using
invoke_signed, or alternatively add a clear comment documenting the precondition
that the PDA must be fully empty before calling create_account; reference the
create_account invocation, the player_pda.lamports() check, PlayerState::LEN,
and the signer seed (state::PLAYER_SEED, authority.key, bump) when implementing
the alternate path so the same signer seeds are used for
allocate/assign/transfer.

In `@native-rust-vrf/src/instructions/request_randomness.rs`:
- Around line 76-81: Add a defensive length check before indexing the borrowed
data: when calling player.try_borrow_data() in request_randomness.rs, ensure
d.len() >= state::PlayerState::LEN (in addition to checking d[0] ==
state::DISCRIMINATOR_PLAYER) and return VrfError::PlayerNotInitialized.into() if
the length is too small; this prevents future panics if the PlayerState layout
grows while still using the existing discriminator check.
- Around line 45-72: The VrfError::AccountOrder variant is being reused for
writability and slot_hashes key mismatches; update the checks to return more
specific errors: replace the oracle_queue.is_writable check to return
VrfError::InvalidAccountFlags, change the slot_hashes key mismatch to return
VrfError::InvalidSlotHashes, and change the program_identity.is_writable and
player.is_writable checks to return VrfError::InvalidAccountFlags (keep
VrfError::AccountOrder strictly for account ordering issues). Add the new enum
variants (InvalidAccountFlags, InvalidSlotHashes) to VrfError and update any
error conversions/tests that expect AccountOrder for these cases.

In `@native-rust-vrf/src/processor.rs`:
- Around line 48-58: Add a sibling assertion in the borsh_tests module to verify
the Borsh encoding of VrfInstruction::InitializePlayer matches the TS fixture;
specifically, in the existing test function
request_randomness_bytes_match_ts_fixture (or a new small test in the same
module) call borsh::to_vec(&VrfInstruction::InitializePlayer).unwrap() and
assert it equals vec![0] so the Rust on-chain encoding is pinned to the
test/initializePlayer.test.ts expectation.
- Around line 40-43: The current comment on the
VrfInstruction::CallbackConsumeRandomness branch is incorrect about how this arm
is reached; update the comment on the CallbackConsumeRandomness match arm to
state that this variant Borsh-encodes to a single byte (e.g., [2]) and that
try_from_slice will reject trailing bytes, so this branch is actually reached
when the caller supplies a Borsh-valid encoding of the enum variant (exactly the
single-byte payload) rather than a malformed 40-byte Borsh payload — replace the
misleading text with this accurate explanation referencing
VrfInstruction::CallbackConsumeRandomness and the try_from_slice decode
behavior.

In `@native-rust-vrf/src/vrf_lite.rs`:
- Around line 15-25: The slice-to-array conversion at the end of
parse_vrf_callback_randomness uses try_into().map_err(...) even though the
preceding length check (against VRF_CALLBACK_IX_LEN) guarantees
instruction_data[8..] is exactly 32 bytes; replace the fallible branch with a
direct, infallible conversion and return Ok(&[u8;32]) (e.g., convert the slice
to an array reference without mapping an unreachable error) inside
parse_vrf_callback_randomness, keeping checks for VRF_CALLBACK_IX_LEN and
CALLBACK_CONSUME_RANDOMNESS unchanged.

In `@native-rust-vrf/test/package.json`:
- Around line 10-15: Update package.json so build/test tools are dev-only:
remove "typescript", "@types/node", and "vitest" from the "dependencies" block
and add them under "devDependencies" instead; while editing, consider bumping
"vitest" to "^4" and add an "engines": { "node": ">=16" } (or your project's
minimal supported Node version) to prevent `@types/node` mismatches and clarify
runtime requirements.

In `@native-rust-vrf/test/utils.ts`:
- Around line 41-43: The helper encodeRequestRandomnessInstruction currently
truncates clientSeed to 8 bits (clientSeed & 0xff) which silently hides invalid
inputs; update encodeRequestRandomnessInstruction to validate that clientSeed is
an integer within 0..255 (and reject negatives/non-integers) and throw a clear
error for out-of-range or non-integer values so callers fail fast; after
validation keep encoding as a single byte (Uint8Array([1, clientSeed])).
- Around line 130-218: Introduce a named discriminator constant (e.g.,
DISCRIMINATOR_PLAYER) and a parsePlayerState(raw: Uint8Array) helper that
validates length against PlayerState::LEN, checks raw[0] ===
DISCRIMINATOR_PLAYER, and returns an object { disc, randomValue: bigint, bump:
number } (or throws on invalid); then replace the raw[0] checks, raw.length
checks, readPlayerRandomU64Le logic, and usages of raw[9] inside
readPlayerRandomU64Le, assertPlayerInitializedForVrf, and
ensurePlayerInitialized to call parsePlayerState and read fields from its result
so validation and offsets are centralized.

In `@native-rust-vrf/test/vrfRequest.test.ts`:
- Around line 125-138: The test is flaky because it polls for
PlayerState.random_value to change (1..=6 can repeat); modify the contract
callback (callback_consume_randomness.rs) to increment a monotonic request
counter (e.g., PlayerState.request_id) each time randomness is consumed, and in
the test replace polling on pollUntilPlayerRandomValueChanges/random_value with
polling for request_id to advance; specifically, update the VRF callback to bump
request_id when setting random_value, and change the test’s poll function (the
anonymous async that calls rpc.getAccountInfo and dataBase64ToBytes) to read and
return request_id, then assert the returned request_id is greater than the
before value instead of comparing random_value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 58ed5cb9-eb7a-4b7c-a1d1-d6242aa3fe83

📥 Commits

Reviewing files that changed from the base of the PR and between 3d91320 and 70e76c5.

⛔ Files ignored due to path filters (2)
  • native-rust-vrf/Cargo.lock is excluded by !**/*.lock
  • native-rust-vrf/test/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (18)
  • native-rust-vrf/Cargo.toml
  • native-rust-vrf/README.md
  • native-rust-vrf/src/entrypoint.rs
  • native-rust-vrf/src/error.rs
  • native-rust-vrf/src/instructions/callback_consume_randomness.rs
  • native-rust-vrf/src/instructions/initialize_player.rs
  • native-rust-vrf/src/instructions/mod.rs
  • native-rust-vrf/src/instructions/request_randomness.rs
  • native-rust-vrf/src/lib.rs
  • native-rust-vrf/src/processor.rs
  • native-rust-vrf/src/state.rs
  • native-rust-vrf/src/vrf_lite.rs
  • native-rust-vrf/test/initializePlayer.test.ts
  • native-rust-vrf/test/package.json
  • native-rust-vrf/test/tsconfig.json
  • native-rust-vrf/test/utils.ts
  • native-rust-vrf/test/vitest.config.ts
  • native-rust-vrf/test/vrfRequest.test.ts

[package]
name = "native-rust-vrf"
version = "0.1.0"
edition = "2024"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

What is the current Rust version bundled with the latest Solana / Agave cargo-build-sbf platform-tools, and does it support edition = "2024"?

💡 Result:

The latest Agave / Solana cargo-build-sbf platform-tools uses platform-tools v1.54 (as of March 2026), which bundles a Rust version at least 1.84.1 or newer (v1.47 used 1.84.1; later versions like v1.54 likely use 1.94+ given Agave discussions). It does not support edition = "2024", as Cargo 1.84.0 explicitly lacks edition2024 support (requires Cargo 1.85+ or nightly), and issues persist into late 2025 without evidence of upgrade by April 2026.

Citations:


Downgrade edition = "2024" to edition = "2021".

Edition 2024 is not supported by the latest Solana/Agave cargo-build-sbf platform-tools (v1.54 as of March 2026). The bundled Cargo version lacks edition 2024 support and requires Cargo 1.85+. Contributors will encounter a build failure when running cargo build-sbf. Align with other examples in the repo by using edition = "2021".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/Cargo.toml` at line 4, Update the Cargo.toml edition setting
from "2024" to "2021" so the crate uses the supported Rust edition for the
current Solana/Agave cargo-build-sbf toolchain; locate the edition = "2024"
entry in Cargo.toml and change it to edition = "2021" to align with other
examples and avoid build failures.

Comment thread native-rust-vrf/README.md
@@ -0,0 +1,58 @@
# rust-native-vrf-example

Native Solana program (no Anchor) that uses **MagicBlock Ephemeral VRF** via `ephemeral-vrf-sdk`: the user **requests** randomness; the **VRF program** **callbacks** with 32 bytes; you **derive** a value (e.g. 1–6) and store it in `PlayerState`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent dice range between intro and callback section.

Line 3 says randomness is mapped to 1–6 ("roll-dice") while Line 26 says 1–10. Pick one and align with what callback_consume_randomness.rs actually computes — the PR is titled "roll-dice" so 1–6 is the natural choice. Readers copying snippets will otherwise be confused.

Also applies to: 26-26

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/README.md` at line 3, The README currently describes two
different dice ranges; update the documentation to consistently use 1–6 to match
the PR intent and the implementation in callback_consume_randomness.rs: change
the intro sentence and the later occurrence (around line 26) to state randomness
is mapped to 1–6, and verify PlayerState population and any examples/snippets
reference the same 1–6 mapping so docs match the logic in
callback_consume_randomness.rs.

Comment thread native-rust-vrf/README.md
Comment on lines +33 to +36
```bash
cargo build-sbf
solana program deploy target/deploy/<your_program>.so --program-id reflex_program-keypair.json
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Stray reflex_program-keypair.json from another project.

The deploy command references reflex_program-keypair.json, but nothing else in this PR or crate is named "reflex". This appears copied from an unrelated example and will fail for anyone following the README literally. Use a placeholder consistent with the <your_program>.so style, or native-rust-vrf-keypair.json.

📝 Proposed fix
 cargo build-sbf
-solana program deploy target/deploy/<your_program>.so --program-id reflex_program-keypair.json
+solana program deploy target/deploy/native_rust_vrf.so --program-id native-rust-vrf-keypair.json
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/README.md` around lines 33 - 36, The README's deploy command
wrongly references reflex_program-keypair.json from another project; update the
solana program deploy example to use a matching placeholder for the keypair
(e.g., replace reflex_program-keypair.json with <your_program>-keypair.json or
native-rust-vrf-keypair.json) so it aligns with the <your_program>.so artifact
and the rest of this crate.

Comment on lines +11 to +25
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
// Log a message indicating the program ID, number of accounts, and instruction data
msg!(
"process_instruction: Program {} is executed with {} account(s) and the following data={:?}",
program_id,
accounts.len(),
_instruction_data
);
processor::process_instruction(program_id, accounts, _instruction_data)?;
Ok(())
} No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Drop the leading underscore on the used parameter, and simplify the return.

_instruction_data is conventionally read as "intentionally unused", but it is in fact passed to both msg! (Line 21) and processor::process_instruction (Line 23). Rename to instruction_data so the signature reflects intent. The trailing ?; Ok(()) is also redundant — just return the inner call. Separately, consider gating the entry msg! (which prints the full instruction byte slice via {:?}) — on a real cluster this consumes CU and bloats logs on every transaction, and other crates in this repo will likely be copy/pasted from this example.

♻️ Proposed fix
 pub fn process_instruction(
     program_id: &Pubkey,
     accounts: &[AccountInfo],
-    _instruction_data: &[u8],
+    instruction_data: &[u8],
 ) -> ProgramResult {
-    // Log a message indicating the program ID, number of accounts, and instruction data
-    msg!(
-        "process_instruction: Program {} is executed with {} account(s) and the following data={:?}",
-        program_id,
-        accounts.len(),
-        _instruction_data
-    );
-    processor::process_instruction(program_id, accounts, _instruction_data)?;
-    Ok(())
+    msg!(
+        "process_instruction: program {} accounts={} data_len={}",
+        program_id,
+        accounts.len(),
+        instruction_data.len()
+    );
+    processor::process_instruction(program_id, accounts, instruction_data)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
// Log a message indicating the program ID, number of accounts, and instruction data
msg!(
"process_instruction: Program {} is executed with {} account(s) and the following data={:?}",
program_id,
accounts.len(),
_instruction_data
);
processor::process_instruction(program_id, accounts, _instruction_data)?;
Ok(())
}
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
msg!(
"process_instruction: program {} accounts={} data_len={}",
program_id,
accounts.len(),
instruction_data.len()
);
processor::process_instruction(program_id, accounts, instruction_data)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/src/entrypoint.rs` around lines 11 - 25, The entry function
process_instruction uses a parameter named _instruction_data which implies it's
unused but it's both logged and forwarded; rename the parameter to
instruction_data in the fn signature and all uses (the msg! call and the
processor::process_instruction invocation) so intent is clear, and simplify the
return by returning the result of processor::process_instruction(...) directly
instead of using ?; Ok(()). Also gate the verbose msg! that prints the full
instruction bytes (around the existing msg! call) behind a compile-time feature
or debug flag (e.g., cfg(feature = "verbose_logs") or cfg!(debug_assertions)) to
avoid bloating logs and consuming compute on mainnet.

Comment on lines +42 to +51
let mut p = PlayerState::try_from_slice(&player.try_borrow_data()?).map_err(|_| {
if player.data_is_empty() {
VrfError::PlayerNotInitialized
} else {
VrfError::InvalidPlayerState
}
})?;
if p.discriminator != crate::state::DISCRIMINATOR_PLAYER {
return Err(VrfError::InvalidPlayerState.into());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

try_from_slice is strict on trailing bytes — fine today, brittle if PlayerState ever grows or the account is realloc'd.

borsh::BorshDeserialize::try_from_slice rejects input with leftover bytes. Today the PDA is allocated to exactly PlayerState::LEN so this is correct, but if a future migration grows the struct or pre-allocates extra space, every callback will start failing with InvalidPlayerState. Consider being explicit about the slice length:

♻️ Suggested change
-    let mut p = PlayerState::try_from_slice(&player.try_borrow_data()?).map_err(|_| {
+    let data_ref = player.try_borrow_data()?;
+    let mut p = PlayerState::try_from_slice(&data_ref[..PlayerState::LEN]).map_err(|_| {
         if player.data_is_empty() {
             VrfError::PlayerNotInitialized
         } else {
             VrfError::InvalidPlayerState
         }
     })?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mut p = PlayerState::try_from_slice(&player.try_borrow_data()?).map_err(|_| {
if player.data_is_empty() {
VrfError::PlayerNotInitialized
} else {
VrfError::InvalidPlayerState
}
})?;
if p.discriminator != crate::state::DISCRIMINATOR_PLAYER {
return Err(VrfError::InvalidPlayerState.into());
}
let data_ref = player.try_borrow_data()?;
let mut p = PlayerState::try_from_slice(&data_ref[..PlayerState::LEN]).map_err(|_| {
if player.data_is_empty() {
VrfError::PlayerNotInitialized
} else {
VrfError::InvalidPlayerState
}
})?;
if p.discriminator != crate::state::DISCRIMINATOR_PLAYER {
return Err(VrfError::InvalidPlayerState.into());
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/src/instructions/callback_consume_randomness.rs` around lines
42 - 51, The Borsh try_from_slice is failing if the account buffer has trailing
bytes; update the PlayerState deserialization to explicitly read only the first
PlayerState::LEN bytes: first bind let data = player.try_borrow_data()?; if
data.len() < PlayerState::LEN return VrfError::PlayerNotInitialized; then call
PlayerState::try_from_slice(&data[..PlayerState::LEN]) to produce p and keep the
existing discriminator check (p.discriminator vs
crate::state::DISCRIMINATOR_PLAYER). This ensures extra allocated bytes won't
break callback_consume_randomness.

Comment on lines +15 to +25
pub fn parse_vrf_callback_randomness(instruction_data: &[u8]) -> Result<&[u8; 32], ()> {
if instruction_data.len() != VRF_CALLBACK_IX_LEN {
return Err(());
}
if &instruction_data[..8] != CALLBACK_CONSUME_RANDOMNESS.as_ref() {
return Err(());
}
instruction_data[8..]
.try_into()
.map_err(|_| ())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Optional: simplify the infallible try_into.

After the length check at Line 16 succeeds, instruction_data[8..] is exactly 32 bytes, so the try_into can never produce Err. Minor cleanup:

♻️ Suggested simplification
-    instruction_data[8..]
-        .try_into()
-        .map_err(|_| ())
+    Ok(<&[u8; 32]>::try_from(&instruction_data[8..]).expect("length checked above"))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/src/vrf_lite.rs` around lines 15 - 25, The slice-to-array
conversion at the end of parse_vrf_callback_randomness uses
try_into().map_err(...) even though the preceding length check (against
VRF_CALLBACK_IX_LEN) guarantees instruction_data[8..] is exactly 32 bytes;
replace the fallible branch with a direct, infallible conversion and return
Ok(&[u8;32]) (e.g., convert the slice to an array reference without mapping an
unreachable error) inside parse_vrf_callback_randomness, keeping checks for
VRF_CALLBACK_IX_LEN and CALLBACK_CONSUME_RANDOMNESS unchanged.

Comment on lines +10 to +15
"dependencies": {
"@solana/kit": "^6.8.0",
"@types/node": "^25.6.0",
"typescript": "^6.0.3",
"vitest": "^2.1.9"
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🌐 Web query:

What is the current latest stable major version of vitest on npm?

💡 Result:

The current latest stable major version of Vitest on npm is 4.

Citations:


🏁 Script executed:

#!/bin/bash
# Check if there are newer major versions of the other dependencies
# We'll search npm registry info for each package

cat << 'EOF' | python3
import json
import subprocess

packages = [
    "@solana/kit",
    "@types/node",
    "typescript"
]

for pkg in packages:
    try:
        result = subprocess.run(
            ["npm", "view", pkg, "versions", "--json"],
            capture_output=True,
            text=True,
            timeout=10
        )
        if result.returncode == 0:
            versions = json.loads(result.stdout)
            # Get last 3 versions to show current state
            recent = versions[-3:] if len(versions) >= 3 else versions
            print(f"{pkg}: recent versions: {recent}")
    except Exception as e:
        print(f"{pkg}: error - {e}")
EOF

Repository: magicblock-labs/magicblock-engine-examples

Length of output: 331


Move test/build tooling to devDependencies.

typescript, @types/node, and vitest are dev-only tools and should not live under dependencies. While this is a private leaf package with no downstream consumer impact, the misclassification will mislead npm install --production, lockfile audits, and tooling that distinguishes runtime vs build deps. Also consider bumping vitest to ^4 (current latest stable) and adding an engines.node field so contributors don't end up with an @types/node major that mismatches their runtime.

♻️ Proposed fix
   "scripts": {
     "test": "vitest run",
     "test:watch": "vitest"
   },
   "dependencies": {
-    "@solana/kit": "^6.8.0",
-    "@types/node": "^25.6.0",
-    "typescript": "^6.0.3",
-    "vitest": "^2.1.9"
-  }
+    "@solana/kit": "^6.8.0"
+  },
+  "devDependencies": {
+    "@types/node": "^25.6.0",
+    "typescript": "^6.0.3",
+    "vitest": "^4.0.0"
+  },
+  "engines": {
+    "node": ">=20"
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/test/package.json` around lines 10 - 15, Update package.json
so build/test tools are dev-only: remove "typescript", "@types/node", and
"vitest" from the "dependencies" block and add them under "devDependencies"
instead; while editing, consider bumping "vitest" to "^4" and add an "engines":
{ "node": ">=16" } (or your project's minimal supported Node version) to prevent
`@types/node` mismatches and clarify runtime requirements.

Comment on lines +41 to +43
export function encodeRequestRandomnessInstruction(clientSeed: number): Uint8Array {
return new Uint8Array([1, clientSeed & 0xff]);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

clientSeed silently truncates to 8 bits.

clientSeed & 0xff accepts any number and quietly drops the high bits — passing 256 produces seed 0, -1 produces 255. Validate the range and reject non-integers so test failures point at the caller, not the wire format.

♻️ Proposed change
 export function encodeRequestRandomnessInstruction(clientSeed: number): Uint8Array {
-  return new Uint8Array([1, clientSeed & 0xff]);
+  if (!Number.isInteger(clientSeed) || clientSeed < 0 || clientSeed > 0xff) {
+    throw new RangeError(`clientSeed must be a u8 (0..=255), got ${clientSeed}`);
+  }
+  return new Uint8Array([1, clientSeed]);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function encodeRequestRandomnessInstruction(clientSeed: number): Uint8Array {
return new Uint8Array([1, clientSeed & 0xff]);
}
export function encodeRequestRandomnessInstruction(clientSeed: number): Uint8Array {
if (!Number.isInteger(clientSeed) || clientSeed < 0 || clientSeed > 0xff) {
throw new RangeError(`clientSeed must be a u8 (0..=255), got ${clientSeed}`);
}
return new Uint8Array([1, clientSeed]);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/test/utils.ts` around lines 41 - 43, The helper
encodeRequestRandomnessInstruction currently truncates clientSeed to 8 bits
(clientSeed & 0xff) which silently hides invalid inputs; update
encodeRequestRandomnessInstruction to validate that clientSeed is an integer
within 0..255 (and reject negatives/non-integers) and throw a clear error for
out-of-range or non-integer values so callers fail fast; after validation keep
encoding as a single byte (Uint8Array([1, clientSeed])).

Comment on lines +130 to +218
/**
* `PlayerState` on-chain: disc (u8) + `random_value` (u64 le) + bump (u8). Returns `0n` for empty/bad.
*/
export function readPlayerRandomU64Le(accountData: Uint8Array): bigint {
if (accountData.length < 10) return 0n;
if (accountData[0] !== 1) return 0n;
return readU64LE(accountData.subarray(1, 9));
}

export type DevnetKitClients = ReturnType<typeof createDevnetKitClients>;

/**
* Fails fast if the player PDA is missing or not `PlayerState`-initialized. Use when you already ran
* `initialize_player` on-chain and only want VRF tests.
*/
export async function assertPlayerInitializedForVrf(
clients: DevnetKitClients,
payer: LoadedKeyPairSigner,
programIdStr: string = getTestProgramId(),
): Promise<{ playerPda: Address; bump: number; randomValue: bigint }> {
const programAddress = address(programIdStr);
const [playerPda] = await getPlayerPda(programAddress, payer.address);
const acc = await clients.rpc.getAccountInfo(playerPda, { encoding: "base64" }).send();
if (!acc.value) {
throw new Error(
"Player PDA missing. Run initialize_player once, or rerun with AUTO_INIT_PLAYER=1.",
);
}
const raw = dataBase64ToBytes(acc.value.data!);
if (raw[0] !== 1 || raw.length < 10) {
throw new Error(
"Player account is not initialized (expected disc=1). Run initialize_player or AUTO_INIT_PLAYER=1.",
);
}
return {
playerPda,
bump: raw[9]!,
randomValue: readPlayerRandomU64Le(raw),
};
}

/**
* If the player PDA is missing or not initialized, send `InitializePlayer`. Idempotent for tests.
*/
export async function ensurePlayerInitialized(
clients: DevnetKitClients,
payer: LoadedKeyPairSigner,
programIdStr: string = getTestProgramId(),
) {
const { rpc, sendAndConfirmTransaction } = clients;
const programAddress = address(programIdStr);
const [playerPda] = await getPlayerPda(programAddress, payer.address);

const acc = await rpc.getAccountInfo(playerPda, { encoding: "base64" }).send();
if (acc.value) {
const raw = dataBase64ToBytes(acc.value.data!);
if (raw[0] === 1 && raw.length >= 10) {
return { playerPda, bump: raw[9]! };
}
}

const ix = {
programAddress,
data: IX_INITIALIZE_PLAYER,
accounts: [
{ address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer },
{ address: playerPda, role: AccountRole.WRITABLE },
{ address: SYSTEM_PROGRAM, role: AccountRole.READONLY },
],
};
const { value: latest } = await rpc.getLatestBlockhash().send();
const message = pipe(
createTransactionMessage({ version: 0 }),
(m) => setTransactionMessageFeePayerSigner(payer, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(latest, m),
(m) => appendTransactionMessageInstruction(ix, m),
);
const transaction = await signTransactionMessageWithSigners(message, {
abortSignal: AbortSignal.timeout(30_000),
});
await sendAndConfirmTransaction(asSendableBlockhashTransaction(transaction), {
commitment: "confirmed",
});

const a2 = await rpc.getAccountInfo(playerPda, { encoding: "base64" }).send();
if (!a2.value) throw new Error("player PDA still missing after initialize");
const raw2 = dataBase64ToBytes(a2.value.data!);
return { playerPda, bump: raw2[9]! };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Extract a parsePlayerState helper and name the discriminator.

raw[0] !== 1, raw.length < 10, and raw[9] are repeated across readPlayerRandomU64Le, assertPlayerInitializedForVrf, and ensurePlayerInitialized. A named constant (mirroring state::DISCRIMINATOR_PLAYER and PlayerState::LEN) plus one decode helper removes the drift risk if PlayerState ever grows a field.

♻️ Sketch
+const PLAYER_DISCRIMINATOR = 1;
+const PLAYER_STATE_LEN = 10; // disc(u8) + random_value(u64) + bump(u8)
+
+function parsePlayerState(raw: Uint8Array): { randomValue: bigint; bump: number } | null {
+  if (raw.length < PLAYER_STATE_LEN || raw[0] !== PLAYER_DISCRIMINATOR) return null;
+  return { randomValue: readU64LE(raw.subarray(1, 9)), bump: raw[9]! };
+}

…then use parsePlayerState inside readPlayerRandomU64Le, assertPlayerInitializedForVrf, and ensurePlayerInitialized.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/test/utils.ts` around lines 130 - 218, Introduce a named
discriminator constant (e.g., DISCRIMINATOR_PLAYER) and a parsePlayerState(raw:
Uint8Array) helper that validates length against PlayerState::LEN, checks raw[0]
=== DISCRIMINATOR_PLAYER, and returns an object { disc, randomValue: bigint,
bump: number } (or throws on invalid); then replace the raw[0] checks,
raw.length checks, readPlayerRandomU64Le logic, and usages of raw[9] inside
readPlayerRandomU64Le, assertPlayerInitializedForVrf, and
ensurePlayerInitialized to call parsePlayerState and read fields from its result
so validation and offsets are centralized.

Comment on lines +125 to +138
const afterRandom = await pollUntilPlayerRandomValueChanges(
async () => {
const a = await rpc.getAccountInfo(playerPda, { encoding: "base64" }).send();
if (!a.value) return null;
return dataBase64ToBytes(a.value.data!);
},
beforeRandom,
{ maxWaitMs: 60_000, intervalMs: 500 },
);

expect(afterRandom).not.toBe(beforeRandom);
console.log("PlayerState.random_value (u64 LE) after VRF callback:", afterRandom.toString());
console.log("hex:", "0x" + afterRandom.toString(16));
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Flaky assertion: ~1/6 chance the new roll equals the previous one.

callback_consume_randomness.rs (Line 54) sets random_value to a roll in 1..=6. If the test is re-run on an already-initialized player whose stored value is also in 1..=6, there is a ~16% chance the new VRF roll matches the prior one, and pollUntilPlayerRandomValueChanges will time out even though the callback succeeded. The very first run is safe (initial value is 0, roll is 1..=6), but repeated CI runs against the same wallet will be flaky.

Consider asserting on a request "version"/counter (e.g., bump a request_id in PlayerState inside the callback) and polling for that to advance, rather than for the dice value itself to differ.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@native-rust-vrf/test/vrfRequest.test.ts` around lines 125 - 138, The test is
flaky because it polls for PlayerState.random_value to change (1..=6 can
repeat); modify the contract callback (callback_consume_randomness.rs) to
increment a monotonic request counter (e.g., PlayerState.request_id) each time
randomness is consumed, and in the test replace polling on
pollUntilPlayerRandomValueChanges/random_value with polling for request_id to
advance; specifically, update the VRF callback to bump request_id when setting
random_value, and change the test’s poll function (the anonymous async that
calls rpc.getAccountInfo and dataBase64ToBytes) to read and return request_id,
then assert the returned request_id is greater than the before value instead of
comparing random_value.

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.

1 participant