Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["solana/account"]
members = ["solana/account", "solana/transaction-view"]
resolver = "3"

[workspace.package]
Expand All @@ -21,6 +21,7 @@ arc-swap = "1.9.1"
bincode = "1.3.3"
bitflags = "2.11.1"
blake3 = "1.8.5"
criterion = "0.8.2"
qualifier_attr = "0.2.2"
rand = "0.8.5"
rustix = { version = "1.1.4" }
Expand All @@ -46,9 +47,12 @@ solana-instruction-error = "2.3.0"
solana-program-error = "3.0.1"
solana-pubkey = "4.2.0"
solana-sdk-ids = "3.1.0"
solana-short-vec = "3.2.1"
solana-sysvar = "4.0.0"
solana-transaction-error = "3.2.0"

[patch.crates-io]
agave-transaction-view = { path = "solana/transaction-view" }

[workspace.lints.rust]
missing_docs = "deny"
Expand Down
15 changes: 8 additions & 7 deletions solana/transaction-view/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
[package]
name = "agave-transaction-view"
authors = { workspace = true }
description = "Zero-copy parser and sanitizer for serialized Solana transactions"
documentation = "https://docs.rs/agave-transaction-view"
version = { workspace = true }
authors = { workspace = true }
repository = { workspace = true }
edition = "2024"
homepage = { workspace = true }
license = { workspace = true }
edition = "2024"
name = "agave-transaction-view"
readme = "README.md"
repository = { workspace = true }
version = "4.1.1"

[features]
agave-unstable-api = []
Expand Down Expand Up @@ -41,9 +42,9 @@ solana-transaction = { workspace = true, features = ["serde", "wincode"] }
wincode = { workspace = true }

[[bench]]
name = "bytes"
harness = false
name = "bytes"

[[bench]]
name = "transaction_view"
harness = false
name = "transaction_view"
55 changes: 55 additions & 0 deletions solana/transaction-view/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# `agave-transaction-view`

This Agave fork parses and sanitizes serialized transactions without fully
deserializing them. Workspace `[patch.crates-io]` entries force the dependency
graph to use this copy.

The view owns or borrows the original transaction bytes through
`TransactionData` and caches only framing metadata. Accessors and iterators then
read signatures, account keys, instructions, configuration, and address-table
metadata directly from the validated byte layout.

## Supported formats

- Legacy and v0 use the standard Solana wire layouts.
- V1 uses the Agave V1 layout with instruction headers, contiguous payloads,
transaction configuration, and trailing signatures.
- `Magicblock` is the Engine-private version `127`. It uses the V1 layout and a
distinct version prefix; it is not a client-facing Solana transaction version.

For V1 and Magicblock transactions, `message_data()` covers the version byte
through the instruction payloads and excludes the trailing signatures. Engine
construction must sign exactly that range after writing the final version
prefix.

## Limits and parsing invariants

Legacy, v0, and V1 transactions may be at most `u16::MAX` bytes, inclusive.
Magicblock transactions may be at most 16 MiB and may use an instruction trace
length of 255. Other structural limits, including the standard signature and
account-index limits, remain enforced by sanitization.

Compact-u16 values are parsed in their complete canonical one-, two-, or
three-byte form. Absolute offsets and transaction lengths are stored as `u32`.
Fallible parsing validates additions, multiplications, ranges, and conversion to
that representation before any unchecked iterator or typed-slice access. Code
using a sanitized view may rely on those validated frame boundaries.

## Address lookup tables

Address lookup tables are unsupported. A transaction containing any lookup
table entry fails sanitization with `TransactionViewError::AddressLookupMismatch`.
A v0 transaction with an empty lookup list remains valid and requires no loaded
addresses.

## Maintenance constraints

- Preserve standard Legacy and v0 wire compatibility for client-produced
transactions.
- Keep V1 and Magicblock framing synchronized; only the version, size, account,
and instruction-trace policies intentionally differ.
- Keep the Magicblock prefix, signed message range, and Engine transaction
composer synchronized.
- Treat the sanitizer as the authoritative boundary for rejecting address
lookup tables.
- Validate new frame offsets before exposing them through unchecked accessors.
5 changes: 2 additions & 3 deletions solana/transaction-view/benches/bytes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ fn setup() -> Vec<(u16, usize, Vec<u8>)> {
for value in 0..PACKET_DATA_SIZE as u16 {
let short_u16 = ShortU16(value);
let mut buffer = vec![0u8; 16];
let serialized_len = options
.serialized_size(&short_u16)
.expect("Failed to get serialized size");
let serialized_len =
options.serialized_size(&short_u16).expect("Failed to get serialized size");
serialize_into(&mut buffer[..], &short_u16).expect("Serialization failed");
values.push((value, serialized_len as usize, buffer));
}
Expand Down
6 changes: 1 addition & 5 deletions solana/transaction-view/benches/transaction_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,7 @@ fn simple_transfers() -> Vec<VersionedTransaction> {
let keypair = Keypair::new();
VersionedTransaction::try_new(
VersionedMessage::Legacy(Message::new_with_blockhash(
&[system_instruction::transfer(
&keypair.pubkey(),
&Pubkey::new_unique(),
1,
)],
&[system_instruction::transfer(&keypair.pubkey(), &Pubkey::new_unique(), 1)],
Some(&keypair.pubkey()),
&Hash::default(),
)),
Expand Down
109 changes: 8 additions & 101 deletions solana/transaction-view/src/bytes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,9 @@ pub fn check_remaining(bytes: &[u8], offset: usize, num_bytes: usize) -> Result<
pub fn read_byte(bytes: &[u8], offset: &mut usize) -> Result<u8> {
// Implicitly checks that the offset is within bounds, no need
// to call `check_remaining` explicitly here.
let value = bytes
.get(*offset)
.copied()
.ok_or(TransactionViewError::ParseError);
*offset = offset.wrapping_add(1);
value
let value = bytes.get(*offset).copied().ok_or(TransactionViewError::ParseError)?;
*offset = offset.checked_add(1).ok_or(TransactionViewError::ParseError)?;
Ok(value)
}

/// Read a byte and advance the offset without any bounds checks.
Expand Down Expand Up @@ -65,9 +62,8 @@ pub fn read_compressed_u16(bytes: &[u8], offset: &mut usize) -> Result<u16> {
for i in 0..3 {
// Implicitly checks that the offset is within bounds, no need
// to call check_remaining explicitly here.
let byte = *bytes
.get(offset.wrapping_add(i))
.ok_or(TransactionViewError::ParseError)?;
let index = offset.checked_add(i).ok_or(TransactionViewError::ParseError)?;
let byte = *bytes.get(index).ok_or(TransactionViewError::ParseError)?;
// non-minimal encoding or overflow
if (i > 0 && byte == 0) || (i == 2 && byte > 3) {
return Err(TransactionViewError::ParseError);
Expand All @@ -81,46 +77,7 @@ pub fn read_compressed_u16(bytes: &[u8], offset: &mut usize) -> Result<u16> {
}

// if we reach here, it means that all 3 bytes were used
*offset = offset.wrapping_add(3);
Ok(result)
}

/// Domain-specific optimization for reading a compressed u16.
///
/// The compressed u16's are only used for array-lengths in our transaction
/// format. The transaction packet has a maximum size of 1232 bytes.
/// This means that the maximum array length within a **valid** transaction is
/// 1232. This has a minimally encoded length of 2 bytes.
/// Although the encoding scheme allows for more, any arrays with this length
/// would be too large to fit in a packet. This function optimizes for this
/// case, and reads a maximum of 2 bytes.
/// If the buffer is too short or the encoding is invalid, return Err.
/// `offset` is updated to point to the byte after the compressed u16.
///
/// * `bytes` - Slice of bytes to read from.
/// * `offset` - Current offset into `bytes`.
#[inline(always)]
pub fn optimized_read_compressed_u16(bytes: &[u8], offset: &mut usize) -> Result<u16> {
let mut result = 0u16;

// First byte
let byte1 = *bytes.get(*offset).ok_or(TransactionViewError::ParseError)?;
result |= (byte1 & 0x7F) as u16;
if byte1 & 0x80 == 0 {
*offset = offset.wrapping_add(1);
return Ok(result);
}

// Second byte
let byte2 = *bytes
.get(offset.wrapping_add(1))
.ok_or(TransactionViewError::ParseError)?;
if byte2 == 0 || byte2 & 0x80 != 0 {
return Err(TransactionViewError::ParseError); // non-minimal encoding or overflow
}
result |= ((byte2 & 0x7F) as u16) << 7;
*offset = offset.wrapping_add(2);

*offset = offset.checked_add(3).ok_or(TransactionViewError::ParseError)?;
Ok(result)
}

Expand Down Expand Up @@ -305,9 +262,8 @@ mod tests {
serialize_into(&mut buffer[..], &short_u16).expect("Serialization failed");

// Use bincode's size calculation to determine the length of the serialized data
let serialized_len = options
.serialized_size(&short_u16)
.expect("Failed to get serialized size");
let serialized_len =
options.serialized_size(&short_u16).expect("Failed to get serialized size");

// Reset offset
offset = 0;
Expand Down Expand Up @@ -343,55 +299,6 @@ mod tests {
assert!(read_compressed_u16(&[0x81, 0x80, 0x00], &mut 0).is_err());
}

#[test]
fn test_optimized_read_compressed_u16() {
let mut buffer = [0u8; 1024];
let options = DefaultOptions::new().with_fixint_encoding(); // Ensure fixed-int encoding

// Test all possible u16 values under the packet length
for value in 0..=PACKET_DATA_SIZE as u16 {
let mut offset;
let short_u16 = ShortU16(value);

// Serialize the value into the buffer
serialize_into(&mut buffer[..], &short_u16).expect("Serialization failed");

// Use bincode's size calculation to determine the length of the serialized data
let serialized_len = options
.serialized_size(&short_u16)
.expect("Failed to get serialized size");

// Reset offset
offset = 0;

// Read the value back using unchecked_read_u16_compressed
let read_value = optimized_read_compressed_u16(&buffer, &mut offset);

// Assert that the read value matches the original value
assert_eq!(read_value, Ok(value), "Value mismatch for: {value}");

// Assert that the offset matches the serialized length
assert_eq!(
offset, serialized_len as usize,
"Offset mismatch for: {value}"
);
}

// Test bounds.
// All 0s => 0
assert_eq!(Ok(0), optimized_read_compressed_u16(&[0; 3], &mut 0));
// Overflow
assert!(optimized_read_compressed_u16(&[0xFF, 0xFF, 0x04], &mut 0).is_err());
assert!(optimized_read_compressed_u16(&[0xFF, 0x80], &mut 0).is_err());

// overflow errors
assert!(optimized_read_compressed_u16(&[u8::MAX; 1], &mut 0).is_err());
assert!(optimized_read_compressed_u16(&[u8::MAX; 2], &mut 0).is_err());

// Minimal encoding checks
assert!(optimized_read_compressed_u16(&[0x81, 0x00], &mut 0).is_err());
}

#[test]
fn test_advance_offset_for_array() {
#[repr(C)]
Expand Down
Loading