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

Self-describing transactions

Opt-in byte-range touch maps for Hopper-tracked acquisitions, with raw escapes and partial maps kept outside completeness claims.

This reference covers Hopper 0.5.0, published on crates.io. See the release status for package verification, dated benchmarks, and remaining audit work.

Hopper's borrow ledger names byte ranges, not only whole accounts. Touch maps put tracked borrow acquisitions on the wire, and hopper tx explain maps a confirmed transaction's records back to manifest fields. A write record says the handler acquired mutable access through a Hopper-tracked API; it does not by itself prove which bytes changed.

The scoped claim is: Hopper transactions can publish byte-range access evidence. Emission is per-context opt-in. Completeness requires a non-partial map, mutation through Hopper-tracked APIs, and separate review or denial of raw escape hatches. Each emitted map costs a sol_log_data, so the feature is not hidden 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 macro-declared mut / mut(seg) ranges into gates enforced when a handler acquires mutation through Hopper-tracked typed paths. Raw and unchecked paths are not covered by that scoped guarantee.
  • emit_touch_map emits the observed borrow 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 dated devnet transaction: the smoke program's withdraw (program 2YPBvKJ8h37bUEFBrmytzNuKfUJ5Q2o2tkTiqRCZdjme, signature 4uc6v9hCR6R2tRxGFJYgFbyPxnhWQZV4cL6VCfLnWNbPe2qAK79aX7HgkhuVfJaS4w6FjSoV7hDit6bn51SzKnfP) recorded an 8-byte mutable acquisition that the manifest maps to Vault.balance.

Tracked whole-account borrows

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, tracked load_mut, and borrows with no Context in reach. The dated devnet fixture exercised that path. 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.
  • At record capacity, overlapping or adjacent same-kind ranges coalesce only when their union is exact. More than 32 pairwise-unmergeable ranges set the partial-map flag; downstream verification must return inconclusive.
  • Raw byte borrows (try_borrow_mut), unchecked pointers, direct substrate mutation, and lamport-only changes are not data-map records. Completeness requires denying those paths or reviewing them separately.
  • 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 limits account metas, but it does not restore Hopper's byte-range or lamport policy. Those paths require explicit review.

Why byte ranges matter (the honest version)

No SIMD prices sub-account granularity today. Agave charges a flat 300 CU per writable account (WRITE_LOCK_UNITS), while SIMD-0186 prices loaded bytes, not byte ranges. For a macro-declared mutation-complete instruction whose escape paths have been denied or reviewed, generated clients can demote a role with no declared effects from writable to read-only. That can remove an account lock; it does not create sub-account scheduler parallelism. The end-to-end fixture 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.