How the engine is built
Four modules, one orchestrator, zero global state. Everything is a pure function of the input transaction and the RPC response.
The pipeline
Every invocation goes through the same five-step pipeline. Steps 1-3 are skipped in offline mode.
build_report pipelineVersionedTransaction | v +------------------+ (offline mode skips 1-3) | 1. fetch | get_multiple_accounts for every | pre-state | writable account in the message +------------------+ | v +------------------+ | 2. simulate | simulateTransaction with | | replaceRecentBlockhash = true +------------------+ + accounts config (post-state) | v +------------------+ | 3. diff | per-account: lamport delta, | | owner change, data change +------------------+ | v +------------------+ program_id -> decoder lookup, | 4. decode | registry has 8 program decoders, | | unknown programs -> MEDIUM fallback +------------------+ | v +------------------+ merges per-ix risk, lamport | 5. classify | outflow, owner changes, durable | | nonce, drift-pattern combo +------------------+ | v LegibilityReport
Module layout
crate treecrif/ src/ types.rs LegibilityReport, AccountDiff, RiskLevel, DecodedInstruction, TokenTransfer engine/ mod.rs re-exports simulate.rs RPC fetch + simulateTransaction call, writable-account extraction, pre/post snapshot pairing diff.rs per-account state diff computation decoder/ mod.rs ProgramDecoder trait + decode_all() registry.rs program_id -> Arc<dyn ProgramDecoder> anchor_generic.rs reusable Anchor discriminator matcher system.rs System Program (native) spl_token.rs SPL Token (native, shared with token-2022) token_2022.rs Token-2022 base + extension tags squads.rs Squads v4 (custom, Drift reasoning) jupiter.rs Jupiter v6 (generic anchor) drift_v2.rs Drift v2 (generic anchor) kamino.rs Kamino Lend (generic anchor) marginfi.rs MarginFi v2 (generic anchor) classifier/ mod.rs risk synthesis + drift-pattern detection + human_summary line generation report.rs orchestrator: build_report, build_report_offline, assemble_report main.rs CLI entry point lib.rs crate root tests/ decoder_unit.rs 5 tests (System, SPL Token) squads_unit.rs 6 tests (Squads + drift pattern) protocol_decoders.rs 15 tests (Jupiter, Drift, Kamino, MarginFi, Token-2022) drift_attack_e2e.rs 2 tests (full pipeline + base64 roundtrip) devnet_integration.rs 1 test (ignored, real devnet) examples/ drift_attack.rs synthesizes the Drift 2026 attack tx and prints base64 + offline report
The orchestrator
The entire public API is two functions in src/report.rs:
report.rs (excerpt)/// Full report: runs simulation against live RPC, diffs state, /// decodes, classifies. pub async fn build_report( cfg: &EngineConfig, tx: &VersionedTransaction, ) -> Result<LegibilityReport>; /// Offline report: skips simulation entirely. Runs the decoder /// and classifier against the transaction's static structure only. /// Useful for auditing a tx before it touches any RPC, and for /// programs that may not be deployed on the current cluster. pub fn build_report_offline( tx: &VersionedTransaction, ) -> LegibilityReport;
Both functions converge on a private assemble_report helper that runs decode + classify identically. The only difference is where the state diffs and token transfers come from: the full path extracts them from the simulation outcome, the offline path passes empty vectors.
The decoder registry
A DecoderRegistry is a HashMap<Pubkey, Arc<dyn ProgramDecoder>>. Each protocol decoder implements a three-method trait:
ProgramDecoder traitpub trait ProgramDecoder: Send + Sync { fn program_id(&self) -> Pubkey; fn program_name(&self) -> &'static str; fn decode( &self, ix: &CompiledInstruction, account_keys: &[Pubkey], ) -> Option<DecodedInstruction>; }
Native programs (System, SPL Token, Token-2022) have bespoke decoders that match on the raw instruction tag byte. Anchor programs (Squads, Jupiter, Drift v2, Kamino, MarginFi) go through GenericAnchorDecoder, which takes a static table of (instruction_name, display_name, summary, risk, reasons) tuples and computes Anchor's sha256("global:<name>")[0..8] discriminators at first call, caching them in a OnceLock.
The classifier
The classifier takes decoded instructions, state diffs, token transfers, and a durable-nonce flag, and returns (RiskLevel, Vec<String>) — the overall verdict and the human summary lines. It applies four kinds of rules in order:
- Per-instruction escalation — the overall risk is the maximum of every decoded instruction's risk.
- Durable nonce escalation — if the first instruction is
AdvanceNonceAccount, the risk is bumped to at least HIGH. - Drift pattern detection — if the tx uses a durable nonce AND contains any of
config_transaction_execute,multisig_set_config, orvault_transaction_execute, the risk is forced to CRITICAL with a dedicated Drift 2026 callout. - State diff escalation — large lamport outflows (≥1 SOL) and owner program changes escalate the overall risk.
All rules are pure functions of their inputs. The classifier has no state, no configuration, and no external dependencies. Its full source is about 100 lines.