Skip to content

Repository files navigation

Solana XRay

An investigation tool for deployed Solana programs: re-execute history in an SVM and read, transaction by transaction, which bytes of which accounts each one moved — then check the whole reconstruction against independent readings of the chain.

Enter an address or a transaction signature. The app reads what the address is, fetches the transactions that ever named it, and rebuilds the byte-level history of the accounts involved.

Three scopes, same machinery:

  • a program — every account it owns, at every slot it changed one;
  • an account — a PDA, a wallet, a buffer: its own timeline, transaction by transaction;
  • a transaction — what it did to what it read, with the histories it writes into re-executed first so that what it read was derived rather than assumed.

What it is for

The chain answers one question well — what does this account hold right now — and refuses the interesting one: what did it hold then, and which transaction changed it. Archival RPC serves transactions, not the account states around them. Nothing on chain will tell you the bytes a PDA held three months ago.

Re-executing the history is the only way to recover those, and this is a tool for doing that with the arithmetic shown:

  • Reverse-engineer an undocumented program. Watch its accounts change under each instruction and infer the layout from what actually moved — which offsets are a counter, which are a discriminator, which never move at all.
  • Read a moment nothing observed. Every moment of an account's life but its newest is unobservable through RPC. A targeted replay reaches one by executing everything before it.
  • Investigate an incident. Take the transaction where something went wrong and see exactly which bytes it wrote, with the compute it burned and the logs it emitted, against the state it found.
  • Recover a program's old bytes. A program account holds only its current binary, but the buffers its past upgrades were written from are still on chain, and the deploy history says which belongs to which slot.
  • Audit a claim. "This program cannot touch that account" is a statement about history, and a complete history can answer it.

It is built for programs whose whole history is fetchable — thousands of transactions, not millions. That bound is the point rather than a limitation to be lifted later: a reconstruction is only worth reading if every transaction behind it was executed, and completeness is what lets the tool prove an account was not written between two moments. A program with millions of transactions is not a slower version of this; it is a different problem.

Its own size is only half of what decides that. A replay has to hold the bytes of every program its history invokes, so what a program is used by counts as much as what it does: one with a few hundred transactions of its own, appearing inside aggregator routes, drags every program in those routes along with it. SPL Token Lending is the cautionary case — 463 transactions, and 300 distinct programs invoked across them.

Accounts a small program touches are another matter — a user's token account and a mint like USDC have histories far larger than the program's. Those are assembled from what the chain recorded about them in each transaction rather than replayed, which is what keeps a small program's replay from cascading into someone else's unbounded history.

RPC as the oracle

A replay that only agrees with itself is worth nothing. Anyone can re-execute transactions and produce a plausible-looking history; what makes one worth reading is something outside it disagreeing when it is wrong.

Two independent checks run against every reconstruction, and neither is produced by the replay:

Per transaction — the chain's recorded balances. Every transaction carries the balance each account held before and after it. The run is held to both: an input assembled rather than read has to reproduce the figure the chain recorded, or the run stops at that transaction rather than carrying a wrong state forward. This catches errors at the transaction that caused them, not as one bad comparison at the end.

At the end — readings of the accounts themselves. After a run, the accounts it reconstructed are read from RPC — the ones no existing reading already stands at or past the claimed state for. Those readings were taken independently of everything the replay used — the stored transactions, the account supply, the emulator — so agreeing with them tests the whole chain at once.

The consequence is per account, and it is the part that matters: an account whose reconstruction a reading confirms is written to the history; one a reading contradicts is discarded. Not flagged, not kept with a warning — discarded. A verdict is recorded either way, so a reader can always see which accounts were checked and which were thrown out.

An account the chain says does not exist is a reading too. That is the only reading a closed account will ever have again, and recording it is what lets a program's dead accounts be verified rather than quietly dropped.

The rule everything else follows

Every byte handed to the emulator must be one of:

  • observed — read from the chain, at that moment;
  • derived — produced by re-execution that started from an observed state and has been verified against one;
  • implied — an exact consequence of something the chain recorded (a fee payer must be system-owned for its transaction to have landed at all).

Anything else stops the run and names what it stopped on. A reconstruction that stops is not a shorter history — it is one that ends before the changes it could not derive, so nothing after that point is written.

What is admitted short of a full reading is admitted by name and counted, never folded in quietly. A fee payer with no stored state is rebuilt from the balance the chain charged it, its data assumed empty. A token account is assembled from the holding the meta recorded for it — the mint, the holder and the amount are the chain's own figures at exactly that transaction; the authority fields are not recorded anywhere and are built at rest. An account a landed System transfer proves was empty is supplied as empty, which is recorded fact rather than supposition.

Each of those has its own counter in every report, and each is held to the chain's recorded balance after the transaction runs. A reconstruction resting on one is never mistaken for one that does not.

What a run does

Opening an address is automatic and cheap: on every visit the app re-reads what the address is and brings its signature index up to date — a handful of requests whatever the address turns out to be. Everything past that waits to be asked, because a history is unbounded and only the reader knows whether it is worth paying for.

Replay is the one button, and it does everything the reconstruction needs:

Phase Cost What it leaves behind
Cluster rules a handful of reads the feature gates and epoch schedule of the cluster
Fetch every transaction that named it the transactions, instructions decoded per loader, accounts
Derive however many other histories whatever a replay stopped for want of — fetched, then merged
Reconstruct the merged history the account states, and a verdict on each

Replay a transaction answers the same question about one moment. The run walks the histories the transaction writes into, reads off what it did as it passes through, and then carries on to the end — where readings of the chain check the whole reconstruction. Stopping at the transaction would leave the finding resting on nothing: a reading describes the present, so a run that ends mid-history can never be checked against one.

The report is therefore two verdicts, and they are different things. Reproduced says the transaction itself agreed with the chain about its outcome, its compute and its balances. History checked says a reading nobody here produced agreed with the reconstruction that fed it. A run can reproduce a transaction inside a history that nothing confirms, and the bytes it reports are then a lead rather than a fact — the card says so.

Which histories those are is the transaction's own business: the accounts it writes, less the ones the model reaches another way. Its fee payer is rebuilt from the fee the chain charged it. Token holdings are assembled from the mint, holder and amount the chain records in the transaction itself. Programs answer through their stored binaries. Accounts the transaction created held nothing before it. What is left — the PDAs, the vaults, the buffers — are the histories whose re-execution decides what it read, and nothing is asked of the reader to name them.

Reach a transaction either way: click one out of an address's lists, or paste its signature into the search box. A signature nobody has fetched a history for is read from the chain on sight — one request — so the page is the same either way, and asking for the replay then fetches whatever those histories still lack.

Fetching is resumable from both ends: a run that is stopped or dies picks up the newer transactions it never saw and the older ones it never reached, rather than starting over. A history is complete, interrupted, or unknown, and only a complete one can evidence an absence of writes — which is what makes a state observed rather than merely stored.

Deriving is driven by where the replay stops rather than by prediction: replay, read the one address it could not get past, fetch that, replay again. The account a history mentions most is not the one its replay stops on first, so a pass that fetches the most-mentioned accounts can do a great deal of work and leave the run stopping exactly where it did before. What it finds does not become a separate replay — the co-account's history is merged into the subject's and the two run as one stream, in the order the chain ran them, which is what lets a co-account's state at any moment be what the run itself last left there rather than something read back across a gap.

Reconstructing runs each transaction under the runtime the chain had at its slot: the cluster's feature gates as of that slot, and the cluster's own epoch schedule. Both are read from the chain and stored; a cluster neither has been read for refuses to replay rather than falling back to a default. A history long enough to cross a feature activation is not stopped by it — the emulator is rebuilt under the new rules and the run's own reconstruction carried across, which is what the chain did too.

One job per address at a time. The kinds overlap in what they read and write, so two of them on one address would race over the same rows to no purpose. A request that arrives while something is running is answered with that job — asking becomes watching — and the page says which job it got when that is not the one it asked for.

Reading a change

Every account state the replay produces is stored against the transaction that caused it, so both views are byte-level and neither re-executes anything:

  • a transaction lists the accounts it changed, each with the runs of bytes that moved, shown against the state it replaced;
  • an account lists its states newest-first, each diffed against the one before it, so a single account can be watched across the whole history.

Diffs are runs rather than dumps: an account is up to ten megabytes and the parts that did not move are the bulk of it, so the unchanged stretches carry only their extent and the changed ones carry their bytes on both sides.

Filters, sorts, groupings and page numbers live in the URL. Follow a transaction out of a filtered list and come back, and you land on the list you left — and any view is a link somebody else can open.

What comes out

Per account, a verdict:

  • verified — a reading the replay did not produce agreed with it. Written.
  • diverged — a reading contradicted it. Discarded.
  • oracle — nothing ever read it, but the chain's recorded balance agreed at every transaction of its complete history. The only evidence a closed account can have when no reading reached it in life.
  • missing — something read it and the replay never produced it at all.

Runs also report how many reconstructed accounts no reading covers at all, and how many inputs leaned on each of the weaker evidence classes — a state assembled from a token balance, a fee payer rebuilt from what it was charged — so a reconstruction resting on one is never mistaken for one that does not.

Per run, where it stopped, if it did. Some name an address a fetch would resolve — AccountUnavailable, LookupTableUnavailable, ProgramBytesUnknown — and the derivation loop feeds on exactly those. The rest are answered by something other than more fetching: Undecodable, ExecutionFailed, BalanceMismatch, UnreconstructableInput, and LoaderOperationWithWrites (a transaction that both rewrote the program's bytes and changed accounts the run was rebuilding, which cannot be both skipped and executed).

A run that stops writes nothing at all, for the reason given above: what it holds is the history of a program whose last changes never happened.

Recovering superseded binaries

A program account holds one binary: the current one. Every version before it is gone from the account the moment it is upgraded, and the deployment that put it there does not carry the bytes either — they arrive beforehand, written in chunks into a buffer account by transactions that never mention the program.

What the deployment does carry is the buffer's address. From there the buffer's own transactions hold every chunk, and reassembling them by offset reproduces the file that was deployed. That is how a replay runs each transaction against the bytes that were live when it ran, rather than against whatever the program is today — and it is why a history that spans an upgrade reconstructs at all.

Recovery is asked for only when a run stops for want of a program's bytes, which is the moment it is demonstrably worth the cost.

Layout

A monorepo: a Rust core service does all the work; the web app is a thin frontend that talks to it.

.
├─ core/        Rust (axum) — RPC ingestion, SVM replay, verification; serves the API
├─ web/         Next.js — pure frontend, calls the core API
├─ docker-compose.yml   Postgres (local dev infra)
├─ Cargo.toml   Rust workspace
└─ pnpm-workspace.yaml   JS workspace

The web app is four pages: a search entry point that takes an address or a signature, an address page (the bulk of it — identity, activity timeline, accounts, balances, transactions, instructions, replay report), a transaction page that can replay the transaction inside the histories it writes into, and a jobs console.

Prerequisites

  • Rust (pinned by rust-toolchain.toml), pnpm 11+ (pinned by packageManager)
  • Node is pinned by pnpm-workspace.yaml (nodeVersion) and downloaded on demand by pnpm — no manual Node install needed; .nvmrc mirrors it for nvm/CI
  • Docker with a running daemon

Docker context note. If docker commands fail with a ~/.docker/desktop/docker.sock error, the CLI is pointed at a stopped Docker Desktop. Either start Docker Desktop, or use the system daemon: docker context use default (persistent) — this repo's infra runs on either.

Setup

pnpm install                 # JS deps (root tooling + web)
cp .env.example .env         # core reads this from the repo root
docker compose up -d         # Postgres

An RPC key is optional but wanted. Without HELIUS_API_KEY everything runs against the public endpoints; with one, requests go to Helius first and fall back to public when it errors. It is also the only server for getTransactionsForAddress, the bulk history read that makes fetching a large history practical — without it, history is paged one transaction at a time.

Reads of the signature index are the exception: they never fall back. A node that has pruned what is asked for answers successfully with nothing, so a fallback there does not rescue a failed read — it replaces it with a shorter answer that looks like a complete one, and "this history reaches its beginning" is a claim later fetches lean on permanently. Failing and retrying the job is the cheap direction.

Run (dev)

pnpm dev                     # docker + web (:3000) + core (:3001) together
# or individually:
pnpm dev:web
pnpm dev:core

Open http://localhost:3000 — the header shows a live core-service status pill that turns green once core is reachable.

WORKER_COUNT (default 2) sets how many jobs run at once. At most one job per address may be live, so workers never contend over a subject; what they share is the account timeline, where concurrent writes to a single account can collide and cost a retry.

Verify the stack

curl localhost:3001/health   # liveness  → {"status":"ok",...}
curl localhost:3001/ready    # readiness → 200 once Postgres is reachable
                             #             and a job worker is running

/ready returns 503 when no worker is running — otherwise a core whose workers never started would look healthy while every job sat queued forever.

Checks

pnpm lint          # generated types in step + web ESLint + core clippy (-D warnings)
pnpm typecheck     # tsc --noEmit
pnpm test          # the Rust workspace
pnpm test:db       # also the tests needing a live database
pnpm format        # Prettier (whole repo) + cargo fmt
pnpm format:check  # the same, without writing

Run everything from the repository root. Tools resolve their configuration from the working directory, so a command run from inside web/ or core/ silently uses different settings — Prettier, for one, looks for .prettierignore in the working directory only, and from web/ it would not find the root one and would rewrite the generated bindings.

A pre-commit hook (Husky + lint-staged, configured in lint-staged.config.mjs) runs these on staged files: web files through ESLint/Prettier/tsc, Rust through rustfmt/clippy.

Database queries (sqlx offline cache)

The core service uses compile-time-checked SQL. Builds compile against the committed query cache (core/.sqlx), so compiling never needs a running databasecore/.cargo/config.toml sets SQLX_OFFLINE=true.

After adding or changing any SQL, refresh the cache and commit it:

docker compose up -d postgres   # DB must be up
pnpm db:prepare                 # reconnects (SQLX_OFFLINE=false) and rewrites core/.sqlx

Migration policy. While the project has no deployed database, migrations are edited in place and the dev DB is reset (there is exactly one migration file today). Reset with:

docker compose down
docker run --rm -v $(pwd)/docker:/data alpine rm -rf /data/postgresql
docker compose up -d
pnpm db:prepare

Once a real database is deployed this flips: sqlx checksums applied migrations, so from then on they are immutable — add new migration files instead of editing existing ones.

Shared types (Rust → TypeScript)

Types that cross the core ↔ web boundary (enums like job status / loader kind / replay stop reason, and API response shapes) are defined once in Rust and generated into web/lib/bindings.ts — the web imports them from there, and its zod schemas are type-checked against them. pnpm lint fails if the generated file has drifted. After changing any #[typeshare] type in core/, regenerate and commit:

cargo install typeshare-cli   # one-time
pnpm types:gen                # writes web/lib/bindings.ts

Services

Service URL Notes
web http://localhost:3000 Next.js frontend
core http://localhost:3001 Rust API
Postgres localhost:5432 user/pass/db all solana_xray

About

Recover the byte-level history of Solana accounts by re-executing every transaction that touched them, verified against RPC.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages