Structured, Borsh-serialized Anchor events as a drop-in replacement for msg! in Solana programs.
Solana programs use msg! for logging, but msg! formats strings on-chain. For a common operation like logging a swap — an amount and a pubkey — msg! allocates and formats a string at execution time, consuming roughly 11,000 compute units per call.
SolTrace replaces that string with a Borsh-serialized struct emitted as an Anchor event. The same data takes ~781 compute units. The event is encoded as a Program data: log in the transaction metadata and is decodable by any Borsh-aware off-chain client, including TypeScript, Python, and Go indexers.
This is not a wrapper around msg!. SolTrace bypasses string formatting entirely. The payload is a typed struct that the user defines, serialized by Borsh directly into the event's payload field. Off-chain consumers deserialize it back into the same struct shape. There is no runtime schema reflection and no dynamic dispatch.
Measured on a local solana-test-validator using identical inputs (a u64 amount and a Pubkey).
Standard msg! 11086 CU
SolTrace sol_log! 781 CU
Savings 10305 CU (92.96%)
Methodology. Two instructions are defined in programs/benchmark: one calls msg! with the amount and user formatted as a string; the other calls sol_log! with the same values serialized into a SwapExecuted struct. Both instructions are submitted in separate transactions to a local validator. Compute units are read from getTransaction on each confirmed signature. The TypeScript test suite in tests/soltrace_workspace.ts runs the benchmark and also decodes the emitted event to verify the payload round-trips correctly.
To reproduce locally:
# terminal 1
solana-test-validator --reset
# terminal 2
cargo build-sbf --manifest-path programs/benchmark/Cargo.toml
solana program deploy target/deploy/benchmark.so
yarn install
env ANCHOR_PROVIDER_URL=http://localhost:8899 \
ANCHOR_WALLET=~/.config/solana/id.json \
npx ts-mocha -p ./tsconfig.json tests/soltrace_workspace.ts[dependencies]
soltrace = "0.1"
anchor-lang = "0.30"SolTrace is tested against anchor-lang 0.30.x. Later versions are expected to be compatible but are not yet CI-verified.
use anchor_lang::prelude::*;
use soltrace::sol_info;
#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct SwapExecuted {
pub amount: u64,
pub user: Pubkey,
}
pub fn swap(ctx: Context<Swap>, amount: u64) -> Result<()> {
sol_info!("swap_executed", SwapExecuted {
amount,
user: ctx.accounts.user.key(),
});
Ok(())
}Each macro is a thin wrapper over sol_log! with the level pre-set:
| Macro | Level |
|---|---|
sol_trace!(name, payload) |
Trace |
sol_debug!(name, payload) |
Debug |
sol_info!(name, payload) |
Info |
sol_warn!(name, payload) |
Warn |
sol_error!(name, payload) |
Error |
If Borsh serialization of the payload fails, SolTrace emits a sentinel event:
event_name = "__soltrace_serialization_error"
payload = []
This keeps the instruction from panicking while leaving the failure observable off-chain. No compute budget is wasted on a panic unwinding that would revert the transaction anyway.
To compile out all logging in production builds:
# mainnet/production Cargo.toml
[dependencies]
soltrace = { version = "0.1", features = ["devnet-only"] }# devnet/test Cargo.toml
[dependencies]
soltrace = "0.1" # no devnet-only — logging is activeWhen devnet-only is enabled, the emit! call and all payload serialization are removed at compile time. The macro arguments are still syntactically evaluated to avoid unused-variable warnings, but no code path reaches the serializer.
The payload field of a SolTraceEvent contains the raw Borsh bytes of the caller's struct. To decode it in TypeScript:
import * as anchor from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import BN from "bn.js";
// Fetch the transaction and find the Program data log
const txDetails = await connection.getTransaction(signature, {
commitment: "confirmed",
maxSupportedTransactionVersion: 0,
});
const logs = txDetails?.meta?.logMessages ?? [];
const dataLog = logs.find((l) => l.includes("Program data: "));
const buffer = Buffer.from(dataLog!.split("Program data: ")[1], "base64");
// Layout: [discriminator:8][level:1][name_len:4][name:n][payload_len:4][payload:m]
let offset = 8; // skip 8-byte Anchor discriminator
const level = buffer.readUInt8(offset); offset += 1;
const nameLen = buffer.readUInt32LE(offset); offset += 4;
const eventName = buffer.subarray(offset, offset + nameLen).toString("utf8");
offset += nameLen;
const payloadLen = buffer.readUInt32LE(offset); offset += 4;
const payload = buffer.subarray(offset, offset + payloadLen);
// Decode SwapExecuted: u64 (8 bytes LE) + Pubkey (32 bytes)
const amount = new BN(payload.subarray(0, 8), "le").toNumber();
const user = new PublicKey(payload.subarray(8, 40)).toBase58();The integration test in crates/soltrace/tests/integration.rs proves the binary layout is stable: any change to the Borsh format will fail that test before it reaches an indexer.
See docs.rs/soltrace once published.
| Export | Kind | Description |
|---|---|---|
LogLevel |
enum | Severity level; #[repr(u8)] with stable discriminants |
SolTraceEvent |
struct | The Anchor event type emitted by every sol_log! call |
sol_log! |
macro | Core macro; takes level, event name, and payload |
sol_trace! |
macro | sol_log! at Trace |
sol_debug! |
macro | sol_log! at Debug |
sol_info! |
macro | sol_log! at Info |
sol_warn! |
macro | sol_log! at Warn |
sol_error! |
macro | sol_log! at Error |
| Flag | Default | Effect |
|---|---|---|
devnet-only |
off | Compiles out all emit! calls. Enable for mainnet production builds. |
no-entrypoint |
off | Passes anchor-lang/no-entrypoint through. Required when the workspace has another crate that defines the program entrypoint. |
MSRV: Rust 1.75.
Wire format stability: The LogLevel discriminants (Trace=0, Debug=1, Info=2, Warn=3, Error=4) are part of the public API and will not change without a major version bump. Any off-chain decoder that reads the raw byte at offset 9 (after the 8-byte Anchor discriminator) can rely on these values.
Semver: This crate follows standard Rust semver. Adding a new LogLevel variant is a breaking change. Changing the field order of SolTraceEvent is a breaking change. Both require a major version bump.
Run cargo fmt and cargo clippy -- -D warnings before opening a pull request. The CI pipeline enforces both on every push to main. Tests are in crates/soltrace/tests/integration.rs and inline in each module; run them with cargo test --manifest-path crates/soltrace/Cargo.toml.
MIT — see LICENSE.