Skip to content
hopper
Get started
Operations / self-describing-transactions

Self-describing transactions

Opt-in byte-range touch maps that let a confirmed transaction name the exact fields it wrote, decoded live from devnet signatures.

Hopper's borrow ledger names byte ranges, not whole accounts. Touch maps put that granularity on the wire: a context that opts in emits one compact record per write, and hopper tx explain decodes a confirmed transaction back into the exact fields it wrote. No other 2026 framework can express this. Pinocchio-derived substrates track one borrow byte per account, and Anchor v2's finest write tracking is an account-index mask.

The claim, scoped the way we make it: Hopper transactions can explain their own state effects. Not "every Hopper transaction does". Emission is per-context opt-in by design, because every record costs a sol_log_data and we do not hide CU costs in defaults.

Opting in

#[derive(Accounts)]
#[accounts(strict_writes, emit_touch_map)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,

    #[account(mut(balance), has_one = authority)]
    pub vault: Account<'info, Vault>,
}

On the wire, the map is one sol_log_data record, magic 0x7A, version 0x01, then (slot, offset, size, R/W) entries, which hopper tx explain decodes from the transaction's log stream.

  • strict_writes compiles the declared mut / mut(seg) structure into an enforced write-set: the published ranges ARE the enforceable ones, so published-equals-enforced holds by construction.
  • emit_touch_map emits the observed write records on success. Emission is Ok-only: a handler that touches state and then errors emits zero records (host-verified in the e2e suite).

What a decode looks like

$ hopper tx explain 4uc6v9hC...51SzKnfP
W slot 1 (vault) [48..56) -> Vault.balance

That is a real devnet transaction: the smoke program's withdraw (program 2YPBvKJ8h37bUEFBrmytzNuKfUJ5Q2o2tkTiqRCZdjme, signature 4uc6v9hCR6R2tRxGFJYgFbyPxnhWQZV4cL6VCfLnWNbPe2qAK79aX7HgkhuVfJaS4w6FjSoV7hDit6bn51SzKnfP) names the exact 8 bytes it wrote and the field they belong to.

The wrapper blind spot, closed

Until 2026-07-10, only segment-path writes were recorded. A whole-account get_mut() borrow bypassed the map. The touch log now lives in instruction-ambient storage (reserved VM heap on SBF), so whole-account borrows record from inside AccountView::load_mut itself: wrapper get_mut, raw load_mut, even borrows with no Context in reach. Proven on-chain the same day. The wrapper-path bump_whole_vault decodes live as W slot 1 [0..76) (signature 4yTv8gKBktSAg4sNiZgCwEnh6CkgHA89NA1r2ZrEAcNamak1xsu8u4veTYJ88wMQMaPmwsyx6jJnnmce9R2oyXMB).

Whole-account records are honest-but-coarse: [0..len) is the borrow's actual grant, versus the segment path's field-precise 8-byte record.

What it costs, disclosed

Enabling the touch-map feature crate-wide costs a measured +52 CU on the smoke crate's event_cpi demo instruction (one get_mut record plus the per-context log reset). That is the price of write-observability in a touch-map-enabled crate, and programs that do not enable the feature pay none of it. Per-record cost is one sol_log_data-class syscall; see the CU costs page.

Scope, plainly

  • Emission is per-context opt-in; do not read "every Hopper tx explains itself" into this page.
  • Raw byte borrows (try_borrow_mut) and lamport-only changes are not data-map records.
  • mutation_complete (the strict_writes/lamport gate) is scoped too: substrate escape hatches (substrate::batch::transfer_lamports, the unsafe unchecked CPI tier) sit outside the gate. Sealevel's writable bit still enforces underneath, so the failure mode is a failed transaction, never fund loss.

Why byte ranges matter (the honest version)

No SIMD prices sub-account granularity today. Agave charges a flat 300 CU per writable account (count-based, WRITE_LOCK_UNITS), and the write-lock-pricing SIMDs closed unmerged. What Solana DOES reward is declaring less: SIMD-0186 (Accepted) prices loaded bytes, and a declared-writable account that a proven write-set shows is never mutated can be demoted to read-only by the client, dropping a write lock and 300 CU of block cost on the shipped scheduler (InstructionDescriptor::effective_writable, demonstrated live by the Sentinel showcase). Byte-range write-sets are the vocabulary Solana does not have yet: a program that publishes them is scheduler-legible in a way account-granular frameworks cannot retrofit without forking the account type they all share, and only a framework with an enforced per-byte write policy can supply the data truthfully. The end-to-end proof path lives in examples/hopper-smoke/tests/touch_map_e2e.rs (compiled .so, captured log stream, decoded through the same wire-format check hopper tx explain applies); the live signatures are on the Benchmarks page, and the decoder is described in Manifests and tx tooling.

Self-describing transactions | Hopper docs