sBPF BooksBPF Book
Basics

Account Data

Reading account fields, writing back to account data, and validating who you trust.

State on Solana lives in accounts. A program does not own variables across invocations; it reads from accounts the caller passed in, mutates the ones marked writable, and exits. This chapter covers the four things every program does with accounts: read a field, validate the account, write back data, and refuse work when something doesn't match.

The account header

Every non-duplicate account begins with an 8-byte header. The bytes after the header are the pubkey, owner, lamports, data length, data, and rent epoch.

OffsetSizeField
+0x001dup_flag (0xff when this is a fresh account)
+0x011is_signer (1 if signed the transaction)
+0x021is_writable (1 if the runtime allows mutation)
+0x031is_executable (1 for deployed programs)
+0x044padding
+0x0832pubkey
+0x2832owner (the program ID that owns this account's data)
+0x488lamports
+0x508data_len
+0x5810240data (actual + padding)
+0x28588rent_epoch

Use these as offsets from the account's header start. For account 0 the header is at 0x0008, so the pubkey is at 0x0008 + 0x08 = 0x0010.

Reading a fixed-size field

ldxdw reads 8 bytes. ldxw reads 4. ldxh reads 2. ldxb reads 1. All require their natural alignment (8-byte read needs 8-byte aligned address, etc.). The runtime enforces this.

.equ ACCT0_LAMPORTS, 0x0050
.equ ACCT0_DATA_LEN, 0x0058

ldxdw r2, [r1 + ACCT0_LAMPORTS]    # r2 = lamports (u64)
ldxdw r3, [r1 + ACCT0_DATA_LEN]    # r3 = data_len (u64)

The Anchor parallel:

let lamports = ctx.accounts.foo.lamports();
let data_len = ctx.accounts.foo.try_borrow_data()?.len();

Validating signer

A signer flag protects every privileged operation. Forget the check and an unsigned account flows past it.

.equ ACCT0_HEADER, 0x0008

ldxb r2, [r1 + ACCT0_HEADER + 1]   # is_signer is at header+1
jeq r2, 0, not_signer

The Anchor parallel is the Signer<'info> type wrapper. Anchor enforces the same byte check; you are now doing it by hand.

Validate signer before reading any other field of the account. A program that reads the pubkey first and only later checks is_signer is one refactor away from doing privileged work without authorization.

Validating owner

An account stores arbitrary bytes. Without checking who owns it, anyone could pass an attacker-controlled account that happens to have the right shape. The owner field at header + 0x28 is set by the BPF Loader when the account was created and cannot be forged.

.equ ACCT0_OWNER, 0x0030

.rodata
  expected_owner: .ascii "<32 bytes of the expected owner pubkey>"

# Prerequisites:
#   r7 holds the saved input pointer (entry r1).
# sol_memcmp_ takes (r1: ptr, r2: ptr, r3: len, r4: 4-byte result buffer)
# and writes a u32 into the result buffer (0 means the bytes matched).

mov64 r4, r10
sub64 r4, 4                        # 4-byte aligned stack slot for the result

lddw r1, expected_owner            # r1 = pointer to expected owner (32 bytes in .rodata)
mov64 r2, r7
add64 r2, ACCT0_OWNER              # r2 = pointer to actual owner in the input region
lddw r3, 32                        # r3 = 32 bytes to compare
call sol_memcmp_

ldxw r2, [r4 + 0]                  # read the comparison result
jne r2, 0, bad_owner               # non-zero means mismatch

The Anchor parallel:

#[account(owner = expected_program::ID)]
pub foo: Account<'info, MyData>,

sol_memcmp_ returns its result by writing 4 bytes into the buffer pointed to by r4, not into r0. Reading r0 after the call will not give you the comparison outcome. This is the single most-missed convention in asm-on-Solana.

Reading account data

Account data starts at header + 0x58 (or 0x0060 for account 0). Treat it as a byte array; you decide the layout.

A common pattern: store a small struct at the top of an account.

.equ ACCT0_DATA, 0x0060

# layout we defined for this account (chosen so the u64 lands on an 8-byte boundary):
#   data[0..1]  : u8 bump byte
#   data[1..8]  : 7 bytes of padding
#   data[8..16] : u64 counter

ldxb  r2, [r1 + ACCT0_DATA + 0]      # bump
ldxdw r3, [r1 + ACCT0_DATA + 8]      # counter (offset 8 is 8-byte aligned)

The padding exists because ldxdw requires its address to be 8-byte aligned. ACCT0_DATA is 0x0060, which is divisible by 8, so offset +8 is also divisible by 8. Reading from ACCT0_DATA + 1 would trap. Either pad your struct layout (as above) or read byte by byte with ldxb.

Writing back to account data

stxdw writes 8 bytes. stxw writes 4. stxh writes 2. stxb writes 1. Writes are only legal on accounts marked writable. The runtime traps otherwise.

# increment the u64 counter (at offset 8 of the account's data)
ldxdw r2, [r1 + ACCT0_DATA + 8]
add64 r2, 1
stxdw [r1 + ACCT0_DATA + 8], r2

You can also enforce the writable check explicitly:

ldxb r2, [r1 + ACCT0_HEADER + 2]     # is_writable
jeq r2, 0, not_writable

The Anchor parallel:

#[account(mut)]
pub foo: Account<'info, MyData>,

Putting it together: a one-account "increment a counter" handler

.equ ACCT0_HEADER, 0x0008
.equ ACCT0_DATA,   0x0060

# account 0 data layout:
#   data[0..1]  : u8 bump byte
#   data[1..8]  : 7 bytes padding
#   data[8..16] : u64 counter

.globl entrypoint
entrypoint:
  # require account 0 to be the signer
  ldxb r2, [r1 + ACCT0_HEADER + 1]
  jeq r2, 0, not_signer

  # require account 0 to be writable
  ldxb r2, [r1 + ACCT0_HEADER + 2]
  jeq r2, 0, not_writable

  # read counter, increment, write back
  ldxdw r2, [r1 + ACCT0_DATA + 8]
  add64 r2, 1
  stxdw [r1 + ACCT0_DATA + 8], r2

  mov64 r0, 0
  exit

not_signer:
  mov64 r0, 3
  exit

not_writable:
  mov64 r0, 3
  exit

26 lines, no syscalls, around 12 CU per invocation. The Anchor equivalent (with #[derive(Accounts)], Signer, Account<'info, Counter>, and the increment body) is roughly the same source-line count but compiles to 200-300 CU.

What's next

You can now read account state and write back to it. The next chapter, Errors and Logging, covers the conventions for failing safely and emitting diagnostics.

On this page

Edit on GitHub