slippage

7cu · happy

Abort if the SPL token account balance is below the floor.

program idSLDChznvxmWVQpGQbweD1oXK8KcaxgaCD1qyDWB3Tps

What it does

slippage answers one question: is this SPL token account holding at least N tokens? If yes, the transaction continues. If no, Solana aborts the whole transaction atomically and the destination instruction never settles.

Compose it after a swap to confirm the user received what they expected, or before to verify input balance. The guard never touches state, never calls a syscall, never branches into helper functions. Two memory reads, one comparison, exit. That is the whole program.

It is 7 compute units on the happy path. For reference, a Solana transaction has 1.4 million compute units to spend, so slippage is roughly 0.0005% of your budget.

How to use it

import { Connection, PublicKey, Transaction } from "@solana/web3.js"import { slippageIx } from "@solana-asm/shield" const SLIPPAGE = new PublicKey("SLDChznvxmWVQpGQbweD1oXK8KcaxgaCD1qyDWB3Tps") const tx = new Transaction() "text-muted-foreground italic">// Run the swap first, then verify the user actually received what they signed for.tx.add(yourSwapInstruction) tx.add(slippageIx({  programId: SLIPPAGE,  tokenAccount: userUsdcAta,  minAmount: 150_000_000n, "text-muted-foreground italic">// 150 USDC, six decimals}))

Compose either before (verify input) or after (verify output) your destination instruction. Multiple slippage checks can stack in the same tx.

Assembly walkthrough

lines 1-3

Constants for the byte offsets we will read

.equ INSTRUCTION_DATA_LEN, 0x2910.equ INSTRUCTION_DATA,     0x2918.equ ACCT0_TOKEN_AMOUNT,   0x00A0

Three byte offsets into the input region (the block of bytes r1 points at). Declare them up front, read them by name. No structs, no parsers.

Why ACCT0_TOKEN_AMOUNT = 0xA0 (160 decimal)? Solana's aligned loader lays account 0 out in a fixed shape: a per-account header first, then the account's data right after. The header is exactly 0x60 bytes (96 decimal). Inside that 96: dup tag + signer/writable/executable flags (8) + 32-byte pubkey + 32-byte owner + u64 lamports + u64 data_len = 96. So account 0's data block starts at byte 0x60.

An SPL Token account stores its fields in this order: mint (32 bytes) + owner (32) + amount (u64) + ... The amount we care about sits at byte 64 INSIDE the data block. Add that to the data block's start: 0x60 + 0x40 = 0xA0. That is why the constant is 0xA0. One add, no runtime work.

Why INSTRUCTION_DATA_LEN = 0x2910 and INSTRUCTION_DATA = 0x2918? After every declared account's region, the loader appends the instruction's metadata (your ix data length and bytes, the program id, signer indices). Each account region takes about 0x2868 bytes when the account has no data. Account 0 here is a real SPL Token account with 165 bytes of state, which pushes the metadata 168 bytes further along (165 rounded up to an 8-byte alignment boundary). That extra padding is why slippage's offsets are bigger than balance_floor's 0x2868. INSTRUCTION_DATA sits exactly 8 bytes past the length field because the length is a u64.

lines 5-8

Check that the caller gave us exactly 8 bytes of ix data

.globl entrypointentrypoint:  ldxdw r2, [r1 + INSTRUCTION_DATA_LEN]  jne r2, 8, bad_ix_data

ldxdw r2, [r1 + INSTRUCTION_DATA_LEN] reads the u64 length the runtime wrote at that offset into r2. ldxdw means 'load 8 bytes from memory at base + offset, zero-extend into the destination register'.

jne r2, 8, bad_ix_data branches to bad_ix_data unless r2 equals 8.

Why exactly 8? The caller is supposed to hand us one u64 min_amount, no more, no less. A 7-byte input would leave the top byte of the u64 as undefined memory. A 9-byte input could sneak past a permissive SDK and let an attacker stuff extra bytes into the tx. Strict equality blocks both. A careful SDK sends exactly 8, a careless or hostile one does not, the guard trusts neither.

lines 10-13

Two loads and the actual safety check

  ldxdw r3, [r1 + ACCT0_TOKEN_AMOUNT]  ldxdw r4, [r1 + INSTRUCTION_DATA]   jlt r3, r4, insufficient

ldxdw r3, [r1 + ACCT0_TOKEN_AMOUNT] reads the live token balance straight out of the account's data block. There is no separate 'fetch account' step because the runtime already mapped account 0's bytes into the same memory r1 points at.

ldxdw r4, [r1 + INSTRUCTION_DATA] reads the caller's floor (the minimum acceptable balance) from the ix data, 8 bytes past the length field.

jlt r3, r4, insufficient is the entire safety check. jlt is unsigned less-than: if the token balance is less than the floor, jump to the failure exit. Equal passes because jlt is strict. Everything around this line was loading values into registers; this one instruction is the check.

lines 15-16

Happy path exit

  mov64 r0, 0  exit

Set r0 to 0 (success), then exit. The runtime reads r0: 0 means 'continue with the next ix in the transaction', anything non-zero means 'abort the whole transaction and revert every state change'.

Total work on the happy path: 7 instructions, no syscalls, no memory writes, no branches taken. That is the 7 CU baseline. For reference, a Solana transaction has 1.4 million CU to spend, so this guard costs roughly 0.0005% of your budget.

lines 18-23

Failure path for the slippage check

insufficient:  lddw r1, msg_insufficient  mov64 r2, 12  call sol_log_  mov64 r0, 1  exit

lddw r1, msg_insufficient puts the address of the 'insufficient' string into r1. This overwrites the input pointer in r1, which is fine because we have already read everything we needed from the input region.

mov64 r2, 12 sets the byte length of the string. The Solana syscall ABI for sol_log_ is 'pointer in r1, length in r2' (this is the same calling convention every sBPF syscall uses).

call sol_log_ invokes the runtime's logger. The log line is recorded in the transaction's log messages, captured by every Solana RPC and indexer. That is how a client app finds out WHY the tx failed: the SDK parses 'insufficient' from the log and reports a friendly error instead of a generic 'transaction failed'.

mov64 r0, 1 then exit returns exit code 1. The runtime sees a non-zero exit, aborts the whole transaction atomically, reverts every state change from earlier instructions. The destination ix (your swap, your transfer, whatever) never runs.

lines 25-30

Failure path for malformed ix data

bad_ix_data:  lddw r1, msg_bad  mov64 r2, 11  call sol_log_  mov64 r0, 2  exit

Same shape as the previous block: load the address of 'bad ix data' (11 bytes), log it, exit with code 2.

Why a different exit code? Code 1 means 'the world failed your check' (the user did not have enough tokens). Code 2 means 'you gave me a malformed instruction' (probably a client bug). The SDK can tell these apart and route them differently: show a user-facing slippage error for code 1, log a developer bug for code 2.

The Shield contract uses four exit codes total: 0 success, 1 condition failed, 2 bad ix data, 3 invalid account. slippage has no exit 3 because it does not check what KIND of account 0 is, it just reads the SPL Token amount field by offset. Pass it a non-token account and it will read whatever bytes are at that offset and probably fail with code 1.

lines 32-34

Read-only string table

.rodata  msg_insufficient: .ascii "insufficient"  msg_bad:          .ascii "bad ix data"

The two log strings live in the program's read-only data section (.rodata). lddw loads their addresses at runtime; the actual bytes are baked into the compiled .so and never copied.

Total program footprint after the sBPF linker runs: a couple hundred bytes. No relocations, no allocator, no panic handler. The output .so is essentially the entrypoint, the two failure branches, and 23 bytes of read-only ASCII.

Exit codes

Exit codes for this guard
codenamelog line
0Success(no log)
1ConditionFailedinsufficient
2BadInstructionDatabad ix data