Self-describing transactions
Opt-in byte-range touch maps for Hopper-tracked acquisitions, with raw escapes and partial maps kept outside completeness claims.
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_writescompiles macro-declaredmut/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_mapemits 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(thestrict_writes/lamport gate) is scoped too: substrate escape hatches (substrate::batch::transfer_lamports, theunsafeunchecked 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.
