Decoding a Solana transaction means turning its serialized bytes into a message, instructions and account references. It does not, by itself, prove the signatures are valid, explain every program's business logic or establish that the transaction executed successfully.

This guide builds a small Rust inspector for legacy and v0 transaction bytes, explains account-index resolution and shows where Shredo Decoded Transactions removes reconstruction and transport work.

Four jobs often called “decoding”

Keep these stages separate in your application:

  1. Structural parsing: deserialize the transaction and inspect its compiled message.
  2. Authentication: verify required signatures and structural constraints.
  3. Semantic interpretation: understand what a specific program's instruction does.
  4. Outcome reconciliation: establish execution status and the resulting account changes.

A parser can succeed while any later stage is unavailable. If your application collapses them into one decoded=true flag, downstream code will eventually rely on a stronger claim than the data supports.

The Solana transaction overview describes messages and signatures. Keep your reader's version support explicit: this example deliberately covers legacy and v0, not every format a current network or newer SDK may support. An unsupported format is an integration error to count and investigate, not an empty transaction to ignore.

What compiled instructions contain

A compiled instruction refers to its program by an account index, refers to participating accounts through more indexes, and carries program-specific instruction bytes.

For a legacy message, resolve those indexes against the message's account list. For v0, some account references depend on Address Lookup Tables. Do not treat instruction account indexes as positions in an arbitrary map or a sorted list of addresses.

The parser must preserve the original ordering. Reordering accounts to make JSON prettier changes the meaning of the indexes.

A small Rust inspector

The example uses pinned Solana crates and bincode for the legacy/v0 representation. It is a diagnostic command-line tool, not a validator, a program decoder or a production hot-path implementation.

Create Cargo.toml:

[package]
name = "shredo-transaction-inspector-example"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
base64 = "=0.22.1"
bincode = "=1.3.3"
solana-message = { version = "=3.1.0", features = ["serde"] }
solana-transaction = { version = "=3.1.0", features = ["serde"] }

[[bin]]
name = "inspect-transaction"
path = "inspect.rs"

Save this as inspect.rs:

use base64::{Engine as _, engine::general_purpose::STANDARD};
use bincode::Options;
use solana_message::VersionedMessage;
use solana_transaction::versioned::VersionedTransaction;
use std::{error::Error, io::Read};

fn decode(wire: &[u8]) -> Result<VersionedTransaction, Box<dyn Error>> {
    if wire.is_empty() || wire.len() > 1232 {
        return Err("expected one legacy/v0 transaction, at most 1232 bytes".into());
    }
    Ok(bincode::DefaultOptions::new()
        .with_fixint_encoding()
        .with_limit(1232)
        .reject_trailing_bytes()
        .deserialize(wire)?)
}

fn main() -> Result<(), Box<dyn Error>> {
    let mut input = String::new();
    std::io::stdin().take(16385).read_to_string(&mut input)?;
    if input.len() > 16384 {
        return Err("input too large".into());
    }
    let wire = STANDARD.decode(input.trim())?;
    let tx = decode(&wire)?;
    let (keys, instructions, lookup_count) = match &tx.message {
        VersionedMessage::Legacy(m) => (&m.account_keys, &m.instructions, 0),
        VersionedMessage::V0(m) => (
            &m.account_keys,
            &m.instructions,
            m.address_table_lookups.len(),
        ),
    };
    println!(
        "signatures={} static_keys={} lookup_tables={}",
        tx.signatures.len(),
        keys.len(),
        lookup_count
    );
    for (ordinal, ix) in instructions.iter().enumerate() {
        let program = keys
            .get(usize::from(ix.program_id_index))
            .ok_or("program index is not in the static account list")?;
        println!(
            "instruction={ordinal} program={program} data_bytes={}",
            ix.data.len()
        );
        for index in &ix.accounts {
            match keys.get(usize::from(*index)) {
                Some(key) => println!("  account[{index}]={key}"),
                None if lookup_count > 0 => println!("  account[{index}]=UNRESOLVED_LOOKUP"),
                None => return Err("legacy instruction account index out of bounds".into()),
            }
        }
    }
    if lookup_count > 0 {
        println!("Resolve and validate all v0 lookups before interpreting account roles.");
    }
    println!(
        "Structural inspection only: signatures, program semantics and execution are NOT verified."
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn legacy_and_v0_round_trip() {
        for message in [
            VersionedMessage::Legacy(Default::default()),
            VersionedMessage::V0(Default::default()),
        ] {
            let tx = VersionedTransaction {
                signatures: vec![],
                message,
            };
            let bytes = bincode::serialize(&tx).expect("synthetic serialization");
            assert_eq!(decode(&bytes).expect("structural parse"), tx);
            let mut trailing = bytes;
            trailing.push(0);
            assert!(decode(&trailing).is_err());
        }
    }
    #[test]
    fn malformed_and_unsupported_are_rejected() {
        assert!(decode(&[]).is_err());
        assert!(decode(&vec![0; 1233]).is_err());
        assert!(decode(&[0, 0x81]).is_err());
    }
}

Put one base64-encoded serialized transaction in transaction.b64, without JSON quotes, then run:

cargo generate-lockfile
cargo run --locked --bin inspect-transaction < transaction.b64

Keep the generated lockfile. The tests in the example demonstrate structural parsing and rejection paths; their synthetic transaction messages are not signed, executable transactions.

The inspector caps input, refuses trailing transaction bytes and reports unresolved lookup-backed accounts. It does not validate signatures or all message invariants. Before accepting data from an untrusted source, add the SDK's appropriate sanitization, signature verification and version-aware validation. Never use successful deserialization as authentication.

Why v0 lookups cannot be guessed

For v0, lookup descriptors name table accounts and indexes into those tables. The message does not embed every resolved address. The effective account ordering is static accounts, loaded writable addresses, then loaded read-only addresses.

For multiple tables, concatenate the writable results across the tables before the read-only results; do not interleave each table's two categories. Preserve the indexes within each category. The versioned transaction documentation explains this representation and its limits.

If a referenced table cannot be resolved, do not guess an account from a similarly shaped transaction or silently replace it with the zero address. Retain the raw observation, mark the resolution incomplete and prevent account-sensitive decisions until you have sufficient information.

An RPC lookup made later can observe a different point in time. Record the source and observation context of resolved addresses, and treat lookup caches as versioned data with an explicit failure path. A cache hit is not automatically evidence that the cached context is appropriate for the observation being interpreted.

Program bytes are not yet a swap

Knowing a program address tells you which interpreter to select. It does not tell you that every instruction to that program is a swap, nor that an instruction's accounts match your cached pool model.

A venue-specific decoder should validate the instruction discriminator, data length, account roles and supported variant before producing a strategy event. Unsupported variants should remain visible as unsupported; do not coerce them into a familiar schema.

There is also a difference between top-level compiled instructions and execution-time inner instructions. A pre-execution signed transaction does not contain the execution logs, actual inner-call trace or resulting token balance deltas. Use an execution source when your application requires those facts.

Connect to Shredo without receiving UDP

Shredo Decoded Transactions provides signed transaction observations through its own TLS gRPC protocol. It is not a Yellowstone-compatible endpoint and does not require a Raw Shreds subscription.

Download the published definition and choose a valid program address for PROGRAM_ID:

curl --fail --output decoded.proto 'https://shredo.xyz/protocol/decoded.proto'
grpcurl -proto decoded.proto \
  -H "authorization: Bearer $SHREDO_API_KEY" \
  -d "{\"programIds\":[\"$PROGRAM_ID\"]}" \
  console.shredo.xyz:443 \
  shredo.decoded.v1.DecodedTransactions/Subscribe

Provision the API key securely and keep it out of source control. This filtered diagnostic command is not a production receiver: continuously formatting an unfiltered feed in a terminal can create its own bottleneck.

The wire contract includes serialized signed bytes, instructions, signatures, static keys, lookup descriptors and account-resolution status. Protobuf bytes fields appear as base64 in grpcurl JSON. A signature represented there is not automatically the base58 text expected by an RPC method.

Use the documented combined account list only when the resolution state supports the interpretation you are making. Do not reuse the previous event's addresses if a later event is unresolved.

Continuity is part of correctness

A receiving application should handle transactions, heartbeats and gaps as distinct event types. Track stream_id and delivery_sequence within their documented scope; a delivery ordinal is not a canonical transaction index in a block.

After a gap or reconnect, mark the affected interval incomplete. The service does not offer historical replay. An observed signed transaction is not proof of landing, and a missing observation is not proof that a transaction never existed.

Keep downstream work bounded. If parsing, storage or strategy work falls behind, expose queue pressure rather than allowing unbounded memory growth to hide the problem.

Where Shredo fits

At 199 USDC per calendar month, Decoded Transactions can be the transaction-observation layer of your searcher. You retain program-specific interpretation, account-state maintenance and execution checks. Raw Shreds remains the lower-level option for teams that want to own reconstruction themselves.

Frequently asked questions

Does decoded mean the transaction succeeded?

No. A decoded signed message describes intent. Execution and confirmation require separate evidence.

Can this inspector decode every Solana transaction format?

No. Its pinned dependencies and input scope are legacy/v0. Track unsupported versions explicitly and update the integration deliberately.

Can I identify swaps using only a program address?

No. You also need the instruction variant, validated account roles and relevant state. Program membership alone is too broad.

Do I need to expose a public UDP port for Decoded Transactions?

No. Your client opens an outbound TLS gRPC connection. See the integration documentation for filtering, gaps and receiving-capacity requirements.