program_allowlist

80cu · N=1

Abort if any other top-level ix targets a non-allowlisted program.

program idSLDHxogaum69jT7C8V4jV16AK7jnuQM8y8EfCJ9RGeK

What it does

program_allowlist answers: does every other top-level instruction in this transaction target a program that the caller put on the allowlist? The guard implicitly skips its own ix.

Useful for hardening keeper bots and agent flows so a compromised or misconfigured client cannot redirect the transaction to a program the operator never intended.

Every OTHER top-level ix is checked, including ComputeBudget. Allowlist ComputeBudget111111111111111111111111111111 if your tx sets a CU limit or price. ~80 CU on N=1, scales with num_top_level_ix × average allowlist position.

How to use it

import { programAllowlistIx } from "@solana-asm/shield"import { ComputeBudgetProgram, PublicKey, Transaction } from "@solana/web3.js" const PROGRAM_ALLOWLIST = new PublicKey("SLDHxogaum69jT7C8V4jV16AK7jnuQM8y8EfCJ9RGeK") const tx = new Transaction()tx.add(programAllowlistIx({  programId: PROGRAM_ALLOWLIST,  allowed: [JUPITER_V6, ComputeBudgetProgram.programId],}))tx.add(yourDestinationInstruction)

Include ComputeBudget on the allowlist if your transaction sets a CU limit. The guard's own ix is skipped automatically.

Assembly walkthrough

lines 1-3

Constants for the byte offsets we will read

.equ ACCT0_KEY,             0x0010.equ ACCT0_DATA_LEN,        0x0058.equ ACCT0_DATA,            0x0060

Three constants, identical to the other sysvar-walking guards (fee_ceiling, compute_unit_floor). They name where account 0's pubkey, data length, and data block live inside the input region.

Why ACCT0_KEY = 0x0010, ACCT0_DATA_LEN = 0x0058, ACCT0_DATA = 0x0060? Same per-account header math as the other guards. 8 bytes for the u64 account count + 8 bytes for the dup tag/flags/padding = 16, where the 32-byte pubkey starts. Pubkey ends at 48, then 32 bytes of owner pubkey ends at 80, then 8-byte u64 lamports ends at 88, which is where the data_len u64 sits (0x58). Data starts at 96 (0x60). Account 0 here is the Instructions sysvar, so the data block is the serialized list of every instruction in the tx.

lines 7-20

Verify account 0 is the Instructions sysvar

  lddw r2, sysvar_ix_key  ldxdw r3, [r1 + ACCT0_KEY + 0]  ldxdw r4, [r2 + 0]  jne r3, r4, bad_account  ldxdw r3, [r1 + ACCT0_KEY + 8]  ldxdw r4, [r2 + 8]  jne r3, r4, bad_account  ldxdw r3, [r1 + ACCT0_KEY + 16]  ldxdw r4, [r2 + 16]  jne r3, r4, bad_account  ldxdw r3, [r1 + ACCT0_KEY + 24]  ldxdw r4, [r2 + 24]  jne r3, r4, bad_account

Four 8-byte compares against the rodata sysvar pubkey constant (Sysvar1nstructions1111111111111111111111111). Any mismatch exits 3 with 'bad account'.

Why check at all? The SDK always wires the sysvar in, but a hand-crafted tx could pass any account. We refuse anything else before reading bytes we would otherwise interpret as serialized instructions. See the fee_ceiling walkthrough for the same pattern.

lines 22-31

Set up the data pointer, cache our own index and the total ix count

  mov64 r3, r1  add64 r3, ACCT0_DATA   mov64 r4, r3  add64 r4, r2  sub64 r4, 2  ldxh r9, [r4 + 0]   ldxh r8, [r3 + 0]

r3 = pointer to the sysvar's serialized data (input region + 0x60). All subsequent ix lookups are relative to r3.

r9 = current_instruction_index, read from the LAST 2 bytes of the sysvar data. The runtime puts our own index there so a program can identify itself. We cache it because the outer loop will compare every ix index against r9 to skip the guard's own ix (more on that below).

r8 = num_instructions, the u16 at the very start of the sysvar data. r8 is the outer loop bound.

lines 33-45

Locate our own ix and read its data length

  lsh64 r2, 1  add64 r2, r3  ldxh r4, [r2 + 2]  add64 r4, r3   ldxh r2, [r4 + 0]  mul64 r2, 33  add64 r2, 34  add64 r4, r2   ldxh r2, [r4 + 0]  add64 r4, 2

Same sysvar walk as fee_ceiling: use the offsets table to find our own ix's serialized form, then skip past the per-ix header (num_accounts u16 + 33 bytes per account meta + 32-byte program_id) to land on the ix_data_len u16.

ldxh r2 reads num_accounts. mul64 r2, 33; add64 r2, 34 computes the offset to ix_data_len (skipping the num_accounts u16, the account metas, and the program_id). r4 += r2 leaves r4 pointing at ix_data_len.

ldxh r2 reads the length. add64 r4, 2 advances r4 to the first byte of ix_data itself.

lines 47-53

Read the allowlist count, validate the total length

  jeq r6, 0, not_allowed   mov64 r5, r6  lsh64 r5, 5  add64 r5, 1  jne r2, r5, bad_ix_data

ldxb r6, [r4 + 0] reads count (the first byte of our ix data). The layout is [u8 count][32 bytes * count]: one count byte, then count contiguous 32-byte pubkeys.

jeq r6, 0, not_allowed fast-fails on an empty allowlist. An empty list means 'nothing is permitted', every non-self ix would fail, so we exit 1 immediately instead of running the loop for nothing.

Expected length = 1 + 32 * count. r5 = r6 << 5 (left-shift by 5 is multiplication by 32, since 2^5 = 32), then add 1 for the count byte. jne r2, r5, bad_ix_data on mismatch (exit 2). Catches both truncation and trailing junk.

lines 55-61

Set up the start and end pointers for the allowlist

  add64 r5, 1   mov64 r2, r6  lsh64 r2, 5  mov64 r6, r5  add64 r6, r2

r5 = r4 + 1 = pointer to the first 32-byte allowlist entry (skipping the count byte).

r6 = r5 + count * 32 = pointer to one byte past the last entry. We use r6 as a sentinel in the inner loop: when the scan pointer reaches r6, we have walked the whole allowlist without finding a match for the current ix.

lines 63-80

Outer loop: skip our own ix, look up the next program_id

 loop:  jge r7, r8, ok   jeq r7, r9, advance_outer   mov64 r2, r7  lsh64 r2, 1  add64 r2, r3  ldxh r4, [r2 + 2]  add64 r4, r3   ldxh r2, [r4 + 0]  mul64 r2, 33  add64 r2, 2  add64 r4, r2  mov64 r1, r4

jge r7, r8, ok exits the loop successfully when r7 reaches num_instructions (all top-level ixs checked, no violation).

jeq r7, r9, advance_outer skips the guard's own ix (where r7 equals current_instruction_index, which we cached in r9). This is the implicit-self-skip contract: the caller does not need to include this guard's program_id in the allowlist because the guard exempts itself. Without this branch, the user would have to list this program's own pubkey, which is awkward and circular.

For every other ix, locate its program_id using the offsets table (same pattern as fee_ceiling). Save the candidate program_id pointer in r1.

lines 84-102

Inner check: compare the candidate program_id against every allowlist entry

  ldxdw r0, [r1 + 0]  ldxdw r4, [r2 + 0]  jne r0, r4, advance_inner  ldxdw r0, [r1 + 8]  ldxdw r4, [r2 + 8]  jne r0, r4, advance_inner  ldxdw r0, [r1 + 16]  ldxdw r4, [r2 + 16]  jne r0, r4, advance_inner  ldxdw r0, [r1 + 24]  ldxdw r4, [r2 + 24]  jne r0, r4, advance_inner   ja advance_outer advance_inner:  add64 r2, 32  jge r2, r6, not_allowed  ja check_inner

Inner loop body. Compare the candidate program_id (in memory at r1) against the current allowlist entry (in memory at r2), 8 bytes at a time. Four pairs of ldxdw + jne.

First mismatch jumps to advance_inner: bump r2 by 32 (move to the next allowlist entry), check if r2 reached r6 (the end sentinel we computed above). If yes, the candidate didn't match anything in the allowlist, fail with exit 1 (not_allowed). If no, loop back to check_inner with the new r2.

All four chunks match (this ix's program_id is in the allowlist), ja advance_outer to check the next ix.

lines 104-110

Outer advance and happy exit

advance_outer:  add64 r7, 1  ja loop ok:  mov64 r0, 0  exit

advance_outer: add64 r7, 1; ja loop. Increment the outer counter, restart.

ok: mov64 r0, 0; exit. All non-self ixs matched some entry in the allowlist. Success.

lines 112-131

Three failure paths, three exit codes

not_allowed:  lddw r1, msg_not_allowed  mov64 r2, 11  call sol_log_  mov64 r0, 1  exit bad_ix_data:  lddw r1, msg_bad  mov64 r2, 11  call sol_log_  mov64 r0, 2  exit bad_account:  lddw r1, msg_acct  mov64 r2, 11  call sol_log_  mov64 r0, 3  exit

not_allowed (exit 1): either the count was 0, or some ix targeted a program not in the allowlist. Log 'not allowed' (11 bytes).

bad_ix_data (exit 2): the declared count and the actual ix data length disagreed. Log 'bad ix data' (11 bytes).

bad_account (exit 3): account 0 was not the Instructions sysvar. Log 'bad account' (11 bytes).

lines 133-137

Read-only data

.rodata  sysvar_ix_key:    .byte 0x06, 0xa7, 0xd5, 0x17, 0x18, 0x7b, 0xd1, 0x66, 0x35, 0xda, 0xd4, 0x04, 0x55, 0xfd, 0xc2, 0xc0, 0xc1, 0x24, 0xc6, 0x8f, 0x21, 0x56, 0x75, 0xa5, 0xdb, 0xba, 0xcb, 0x5f, 0x08, 0x00, 0x00, 0x00  msg_not_allowed:  .ascii "not allowed"  msg_bad:          .ascii "bad ix data"  msg_acct:         .ascii "bad account"

The Instructions sysvar pubkey constant (32 bytes) and three log strings. Total program size stays small because the loops are unrolled four wide (each pubkey compare is four ldxdw + four jne, no inner table machinery).

Exit codes

Exit codes for this guard
codenamelog line
0Success(no log)
1ConditionFailednot allowed
2BadInstructionDatabad ix data
3InvalidAccountbad account