[GH-ISSUE #2003] [Feature] Offline Solana sBPF program interface recovery #666

Open
opened 2026-10-15 18:27:05 +00:00 by bsakaguchi · 2 comments
Owner

Originally created by @trenbonole on GitHub (Oct 12, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/2003

Before submitting

  • I searched the open and closed issues and pull requests for a related proposal.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Other — Solana sBPF program analysis

Current limitation

REA has no Solana program support. Its offline bytecode coverage is JVM/Python and, since #1051, EVM runtime bytecode through inspect_evm_interface. Solana programs are ELF files for the sBPF virtual machine, and native providers do not establish sBPF interface semantics.

An agent holding a program today (from solana program dump, a build directory, or a CTF archive) has to combine a separate disassembler, an Anchor IDL fetch that fails for programs that never published one or returns a stale one, and hand-computed discriminators. Nothing ties a claimed instruction name back to the bytes that justify it.

Use cases

  • List the instructions of an unverified or closed-source program before signing a transaction against it.
  • Check a published IDL against the deployed binary: missing or extra instructions, mismatched discriminators.
  • Find the account types a program validates, including other programs' types. Example below: pump.fun's bonding-curve program checks pump AMM's GlobalConfig and Pool, which its own IDL does not list.
  • Triage audit and CTF targets: sBPF version, entrypoint, syscalls and their call sites.

As in #972, Web3 and CTF are showcases. The capability is reusable artifact inspection and interface recovery.

Desired outcome and success criteria

A first increment shaped like inspect_evm_interface: inspect_sbpf_interface / inspect-sbpf-interface over one explicitly selected local sBPF ELF, either a raw .so or a program-data dump with its trailing zero padding.

Observations, each with file offsets:

  • e_machine, e_flags and the sBPF version as the Solana loader maps e_flags (v0 to v4, reserved)
  • entrypoint and sections
  • named call targets with every call site, flagged as registered syscalls or not

Inferences, labelled as inferences, each with the evidence behind it:

  • Anchor instructions: handler name, logged name, sha256("global:" + name)[..8], log-string offsets, and where the discriminator occurs (lddw immediate, mov32 + hor64 immediate on sBPF v2+, or data bytes). Each is marked implemented when the code compares the discriminator right after loading it, as dispatch does, or referenced when it is only written to memory, as when building a call to another program
  • Anchor's IDL-management instructions, reported as framework instructions
  • account and event types with their account: / event: discriminators

A name is reported only when its discriminator occurs in the binary, so a string that looks like a name is never promoted to one.

Unknowns, stated explicitly:

  • event names absent from the binary
  • programs without Anchor's instruction log strings
  • instruction arguments, account lists and constraints
  • deployment identity, upgrade authority and verified-build status

Malformed input and non-sBPF targets return errors distinct from provider failure.

Acceptance as in #972: source-owned Anchor and native fixtures built with a pinned toolchain, a real upstream run, packaged CLI/MCP examples, final schema validation, malformed input, and cancellation, bounds and owned-process cleanup matching the EVM worker.

Authority boundaries and non-goals

  • Offline only. No program execution, RPC, explorer lookup or other network request.
  • Fetching a program by address, reading its upgrade authority and checking verified builds stay out of this increment. They would come later as explicit operations recording slot and source.
  • Recovered names are inferences, not a recovered IDL. Discriminator matches establish that a name's hash occurs in the binary, not how the program uses it.
  • No decompilation in this increment.

Alternatives considered

Tool License Fit
FuzzingLabs/sol-azy SSPL Cannot be bundled. A separate solazy-mcp server exists in FuzzingLabs' mcp-security-hub.
sec3-service/IDLGuesser GPL-3.0 Bring-your-own at most. Its published method originated recovering instruction names from Anchor's log strings, which sbpf-inspect also uses and credits; sbpf-inspect shares no code with it.
exreve/sol-bytecode-decompiler none No license, so not reusable.
otter-sec/bn-ebpf-solana MIT Needs Binary Ninja, which is not a REA provider.
Ghidra sBPF processor modules LGPL-3.0 or none, mostly stale Possible later path to decompilation through the Ghidra provider, not to interface recovery.
blueshift-gg/sbpf crates MIT Permissive, maintained, ships a WebAssembly binding. Used below.
anza-xyz/sbpf (solana-sbpf) Apache-2.0 The official VM. Compiles to wasm32 with its JIT feature off. Candidate for a later control-flow increment.

Possible direction

Mirror #1051, which adapts an unchanged, pinned EVMole. The engine would be sbpf-inspect (https://github.com/trenbonole/sbpf-inspect, MIT OR Apache-2.0), a Rust crate on Blueshift's MIT sbpf-disassembler. It is shipped as a 178 KB WebAssembly module for Node, and its native and WebAssembly builds produce byte-identical reports on every program tested.

The layout would follow src/evm:

  • an src/sbpf provider and worker with the same resource limits
  • domain, contracts, application, server and CLI entries
  • docs/sbpf-programs.md
  • third_party/sbpf-inspect with its license

Later increments, each reviewable on its own:

  1. Per-function disassembly and control flow through solana-sbpf static analysis.
  2. Relating a supplied IDL to the binary, and decoding imported transaction instruction data against it.
  3. Explicit RPC acquisition at a recorded slot, with upgrade authority.

I can implement the first increment if the direction fits.

References

Measured with sbpf-inspect 0.1.0. Each program is graded against an answer key the engine never sees. pump.fun program data was dumped on 2026-10-08 near mainnet slot 454,696,749; the others were measured on 2026-10-12.

Program Instructions Account types Wrong names Time (WebAssembly)
pump.fun bonding curve 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P 56 of 56 7 of 7, plus pump AMM's GlobalConfig and Pool 0 64 ms
pump.fun AMM pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA 38 of 38 7 of 7 0 55 ms
Bordrless companion 6ZUM1gWBH9hBBNoJoaVAGwSftyZ6CUda6vUZTW9MsJuo, sBPF v3, graded against its public source 27 of 27, plus 2 outgoing calls reported as referenced 3 of 6 0 14 ms
Closed-source launchpad BohF4FAftBwEBoa23sUgM8Vr5HL2nAHLx5s7b1iBK56f, deleted by its owner, graded against its own runtime logs 23 of 23 instructions it was ever called with 11 found, no key 0 76 ms native

Every recovered discriminator matches the published IDL byte for byte. The two extra types' discriminators match pump AMM's IDL; the bonding curve checks them in migrate.

The deleted launchpad had no file to inspect. Its binary was rebuilt byte for byte from the upload transactions of its final deployment. The answer key pairs the first 8 bytes of each of its 2,263 calls with the Instruction: <Name> line it logged. The engine also reports 30 handlers never called on mainnet, which nothing outside the binary can grade. Both scripts ship in the repository. Event names are recovered only when they appear in the binary, which happened once across these programs.

To reproduce:

solana program dump -u mainnet-beta 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P pump.so
curl -sLO https://raw.githubusercontent.com/pump-fun/pump-public-docs/main/idl/pump.json
sbpf-inspect pump.so > pump.report.json
node scripts/grade-against-idl.mjs pump.report.json pump.json
Originally created by @trenbonole on GitHub (Oct 12, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2003 ### Before submitting - [x] I searched the open and closed issues and pull requests for a related proposal. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Other — Solana sBPF program analysis ### Current limitation REA has no Solana program support. Its offline bytecode coverage is JVM/Python and, since #1051, EVM runtime bytecode through `inspect_evm_interface`. Solana programs are ELF files for the sBPF virtual machine, and native providers do not establish sBPF interface semantics. An agent holding a program today (from `solana program dump`, a build directory, or a CTF archive) has to combine a separate disassembler, an Anchor IDL fetch that fails for programs that never published one or returns a stale one, and hand-computed discriminators. Nothing ties a claimed instruction name back to the bytes that justify it. ### Use cases - List the instructions of an unverified or closed-source program before signing a transaction against it. - Check a published IDL against the deployed binary: missing or extra instructions, mismatched discriminators. - Find the account types a program validates, including other programs' types. Example below: pump.fun's bonding-curve program checks pump AMM's `GlobalConfig` and `Pool`, which its own IDL does not list. - Triage audit and CTF targets: sBPF version, entrypoint, syscalls and their call sites. As in #972, Web3 and CTF are showcases. The capability is reusable artifact inspection and interface recovery. ### Desired outcome and success criteria A first increment shaped like `inspect_evm_interface`: `inspect_sbpf_interface` / `inspect-sbpf-interface` over one explicitly selected local sBPF ELF, either a raw `.so` or a program-data dump with its trailing zero padding. **Observations**, each with file offsets: - `e_machine`, `e_flags` and the sBPF version as the Solana loader maps `e_flags` (v0 to v4, reserved) - entrypoint and sections - named call targets with every call site, flagged as registered syscalls or not **Inferences**, labelled as inferences, each with the evidence behind it: - Anchor instructions: handler name, logged name, `sha256("global:" + name)[..8]`, log-string offsets, and where the discriminator occurs (`lddw` immediate, `mov32` + `hor64` immediate on sBPF v2+, or data bytes). Each is marked `implemented` when the code compares the discriminator right after loading it, as dispatch does, or `referenced` when it is only written to memory, as when building a call to another program - Anchor's IDL-management instructions, reported as framework instructions - account and event types with their `account:` / `event:` discriminators A name is reported only when its discriminator occurs in the binary, so a string that looks like a name is never promoted to one. **Unknowns**, stated explicitly: - event names absent from the binary - programs without Anchor's instruction log strings - instruction arguments, account lists and constraints - deployment identity, upgrade authority and verified-build status Malformed input and non-sBPF targets return errors distinct from provider failure. Acceptance as in #972: source-owned Anchor and native fixtures built with a pinned toolchain, a real upstream run, packaged CLI/MCP examples, final schema validation, malformed input, and cancellation, bounds and owned-process cleanup matching the EVM worker. ### Authority boundaries and non-goals - Offline only. No program execution, RPC, explorer lookup or other network request. - Fetching a program by address, reading its upgrade authority and checking verified builds stay out of this increment. They would come later as explicit operations recording slot and source. - Recovered names are inferences, not a recovered IDL. Discriminator matches establish that a name's hash occurs in the binary, not how the program uses it. - No decompilation in this increment. ### Alternatives considered | Tool | License | Fit | |---|---|---| | FuzzingLabs/sol-azy | SSPL | Cannot be bundled. A separate `solazy-mcp` server exists in FuzzingLabs' mcp-security-hub. | | sec3-service/IDLGuesser | GPL-3.0 | Bring-your-own at most. Its published method originated recovering instruction names from Anchor's log strings, which sbpf-inspect also uses and credits; sbpf-inspect shares no code with it. | | exreve/sol-bytecode-decompiler | none | No license, so not reusable. | | otter-sec/bn-ebpf-solana | MIT | Needs Binary Ninja, which is not a REA provider. | | Ghidra sBPF processor modules | LGPL-3.0 or none, mostly stale | Possible later path to decompilation through the Ghidra provider, not to interface recovery. | | blueshift-gg/sbpf crates | MIT | Permissive, maintained, ships a WebAssembly binding. Used below. | | anza-xyz/sbpf (`solana-sbpf`) | Apache-2.0 | The official VM. Compiles to wasm32 with its JIT feature off. Candidate for a later control-flow increment. | ### Possible direction Mirror #1051, which adapts an unchanged, pinned EVMole. The engine would be `sbpf-inspect` (https://github.com/trenbonole/sbpf-inspect, MIT OR Apache-2.0), a Rust crate on Blueshift's MIT `sbpf-disassembler`. It is shipped as a 178 KB WebAssembly module for Node, and its native and WebAssembly builds produce byte-identical reports on every program tested. The layout would follow `src/evm`: - an `src/sbpf` provider and worker with the same resource limits - domain, contracts, application, server and CLI entries - `docs/sbpf-programs.md` - `third_party/sbpf-inspect` with its license Later increments, each reviewable on its own: 1. Per-function disassembly and control flow through `solana-sbpf` static analysis. 2. Relating a supplied IDL to the binary, and decoding imported transaction instruction data against it. 3. Explicit RPC acquisition at a recorded slot, with upgrade authority. I can implement the first increment if the direction fits. ### References Measured with sbpf-inspect 0.1.0. Each program is graded against an answer key the engine never sees. pump.fun program data was dumped on 2026-10-08 near mainnet slot 454,696,749; the others were measured on 2026-10-12. | Program | Instructions | Account types | Wrong names | Time (WebAssembly) | |---|---|---|---|---| | pump.fun bonding curve `6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P` | 56 of 56 | 7 of 7, plus pump AMM's `GlobalConfig` and `Pool` | 0 | 64 ms | | pump.fun AMM `pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA` | 38 of 38 | 7 of 7 | 0 | 55 ms | | Bordrless companion `6ZUM1gWBH9hBBNoJoaVAGwSftyZ6CUda6vUZTW9MsJuo`, sBPF v3, graded against its public source | 27 of 27, plus 2 outgoing calls reported as `referenced` | 3 of 6 | 0 | 14 ms | | Closed-source launchpad `BohF4FAftBwEBoa23sUgM8Vr5HL2nAHLx5s7b1iBK56f`, deleted by its owner, graded against its own runtime logs | 23 of 23 instructions it was ever called with | 11 found, no key | 0 | 76 ms native | Every recovered discriminator matches the published IDL byte for byte. The two extra types' discriminators match pump AMM's IDL; the bonding curve checks them in `migrate`. The deleted launchpad had no file to inspect. Its binary was rebuilt byte for byte from the upload transactions of its final deployment. The answer key pairs the first 8 bytes of each of its 2,263 calls with the `Instruction: <Name>` line it logged. The engine also reports 30 handlers never called on mainnet, which nothing outside the binary can grade. Both scripts ship in the repository. Event names are recovered only when they appear in the binary, which happened once across these programs. To reproduce: ```sh solana program dump -u mainnet-beta 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P pump.so curl -sLO https://raw.githubusercontent.com/pump-fun/pump-public-docs/main/idl/pump.json sbpf-inspect pump.so > pump.report.json node scripts/grade-against-idl.mjs pump.report.json pump.json ``` - #972, #1051 (EVM increment and its adapter pattern), #740 (capability modularization) - Loader version mapping: [`get_sbpf_version` in anza-xyz/sbpf](https://github.com/anza-xyz/sbpf/blob/main/src/elf.rs) - [blueshift-gg/sbpf](https://github.com/blueshift-gg/sbpf)
Author
Owner

@trenbonole commented on GitHub (Oct 12, 2026):

Small update since I posted this. sbpf-inspect 0.2.0 now recovers each instruction's account list (order plus writable/signer), which the proposal above lists under unknowns.

Short version of how: it finds the try_accounts function Anchor generates for each instruction and follows the accounts slice through it. The names and flags come off the error paths (ConstraintMut, ConstraintSigner, AccountNotSigner). Every account comes with the offset where it's loaded and the error codes behind its flags, so it stays tied to bytes in the file like the rest of the report.

I wanted an honest number, so I ran it once without any tuning on 20 mainnet Anchor programs I hadn't touched while building it: Mango v4, Zeta, marginfi, Jupiter Perps and DCA, a bunch of Meteora and Kamino programs, Tensor, Bubblegum, and others. I graded against their on-chain IDLs:

  • 652 of 677 instructions found
  • 567 of 650 account lists exactly right (names, order, both flags)
  • writable agrees on 5,373 of 5,385 accounts, signer on 5,368

Three times it marked an account writable and the IDL didn't. Each time the program really does check mut there (marginfi's source has it, and Zeta's binary raises ConstraintMut on that account), so those IDLs are just out of date. Some of the misses are stale IDLs too: Tensor renamed fields after publishing its IDL. The per-program table is here: https://github.com/trenbonole/sbpf-inspect#account-lists

I also found a gap I hadn't noticed. Older Anchor versions compile dispatch into byte-by-byte compares, so Marinade, OpenBook v2 and Squads v4 were coming back with zero instruction names. They now get all of them.

This doesn't change what I'd suggest for a first increment; account lists could land as a follow-up. Still happy to do the work if the direction fits.

<!-- gh-comment-id:6120287024 --> @trenbonole commented on GitHub (Oct 12, 2026): Small update since I posted this. sbpf-inspect 0.2.0 now recovers each instruction's account list (order plus writable/signer), which the proposal above lists under unknowns. Short version of how: it finds the `try_accounts` function Anchor generates for each instruction and follows the accounts slice through it. The names and flags come off the error paths (ConstraintMut, ConstraintSigner, AccountNotSigner). Every account comes with the offset where it's loaded and the error codes behind its flags, so it stays tied to bytes in the file like the rest of the report. I wanted an honest number, so I ran it once without any tuning on 20 mainnet Anchor programs I hadn't touched while building it: Mango v4, Zeta, marginfi, Jupiter Perps and DCA, a bunch of Meteora and Kamino programs, Tensor, Bubblegum, and others. I graded against their on-chain IDLs: - 652 of 677 instructions found - 567 of 650 account lists exactly right (names, order, both flags) - writable agrees on 5,373 of 5,385 accounts, signer on 5,368 Three times it marked an account writable and the IDL didn't. Each time the program really does check `mut` there (marginfi's source has it, and Zeta's binary raises ConstraintMut on that account), so those IDLs are just out of date. Some of the misses are stale IDLs too: Tensor renamed fields after publishing its IDL. The per-program table is here: https://github.com/trenbonole/sbpf-inspect#account-lists I also found a gap I hadn't noticed. Older Anchor versions compile dispatch into byte-by-byte compares, so Marinade, OpenBook v2 and Squads v4 were coming back with zero instruction names. They now get all of them. This doesn't change what I'd suggest for a first increment; account lists could land as a follow-up. Still happy to do the work if the direction fits.
Author
Owner

@trenbonole commented on GitHub (Oct 14, 2026):

Another update since 0.2.0. A few more of the things this proposal lists under unknowns now work, so I'll note them here.

Instruction arguments. It recovers each instruction's argument layout — the offset and size of every fixed field after the discriminator — by following the handler's own reads of the instruction data. On the same 9 mainnet programs I held out while building this, 201 of 227 instructions come back exactly right, and of the layouts that do report fields, 96% put every field edge on a real boundary. It stops at the first variable-length value (a string or vec) and says so instead of guessing past it.

Programs without log strings. Two parts:

  • It can now find an instruction that has no logged name at all, by its 8-byte discriminator. It runs the program's own entrypoint on a made-up input to see where the instruction data ends up, then confirms a discriminator by walking from there and checking the code really compares all 8 bytes. To make sure it isn't inventing them, I ran it over 45 programs with name-matching turned off and let it re-find the instructions the logs already name — it got all 1,524, and the one thing it reported that wasn't in an IDL turned out to be a real instruction the IDL had left out. It also flags when a deployed program is smaller than its published IDL: Drift's binary only dispatches 3 of the 241 instructions the IDL lists, and Tensor Swap and Switchboard are the same.
  • Native (non-Anchor) programs get their instruction dispatch too — the one-byte tag switch and each handler's offset.

It also pulls out the program's own error and log messages. Those are in the binary even when the field names aren't, and they usually say what the arguments are for ("Name is empty or longer than 32 characters", that kind of thing).

One cleanup worth mentioning if you ever embed it: it no longer uses a third-party disassembler. The ELF reader, decoder and loader are its own now, so the whole analysis path is one MIT/Apache crate.

None of this changes the first increment I suggested — it's all follow-on. Numbers and per-program tables are in the readme: https://github.com/trenbonole/sbpf-inspect. Still glad to do the work if the direction fits.

<!-- gh-comment-id:6172317660 --> @trenbonole commented on GitHub (Oct 14, 2026): Another update since 0.2.0. A few more of the things this proposal lists under unknowns now work, so I'll note them here. **Instruction arguments.** It recovers each instruction's argument layout — the offset and size of every fixed field after the discriminator — by following the handler's own reads of the instruction data. On the same 9 mainnet programs I held out while building this, 201 of 227 instructions come back exactly right, and of the layouts that do report fields, 96% put every field edge on a real boundary. It stops at the first variable-length value (a string or vec) and says so instead of guessing past it. **Programs without log strings.** Two parts: - It can now find an instruction that has no logged name at all, by its 8-byte discriminator. It runs the program's own entrypoint on a made-up input to see where the instruction data ends up, then confirms a discriminator by walking from there and checking the code really compares all 8 bytes. To make sure it isn't inventing them, I ran it over 45 programs with name-matching turned off and let it re-find the instructions the logs already name — it got all 1,524, and the one thing it reported that wasn't in an IDL turned out to be a real instruction the IDL had left out. It also flags when a deployed program is smaller than its published IDL: Drift's binary only dispatches 3 of the 241 instructions the IDL lists, and Tensor Swap and Switchboard are the same. - Native (non-Anchor) programs get their instruction dispatch too — the one-byte tag switch and each handler's offset. It also pulls out the program's own error and log messages. Those are in the binary even when the field names aren't, and they usually say what the arguments are for ("Name is empty or longer than 32 characters", that kind of thing). One cleanup worth mentioning if you ever embed it: it no longer uses a third-party disassembler. The ELF reader, decoder and loader are its own now, so the whole analysis path is one MIT/Apache crate. None of this changes the first increment I suggested — it's all follow-on. Numbers and per-program tables are in the readme: https://github.com/trenbonole/sbpf-inspect. Still glad to do the work if the direction fits.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#666
No description provided.