signer_allowlist

25cu · N=1

Abort if the signer is not one of the caller's allowed pubkeys.

program idSLDPp75MazNodaDGQVqduNNGYYbJVYk3EKWLFppYtvh

What it does

signer_allowlist answers: is the signer of this transaction one of these N pubkeys? If yes, continue. If no, abort.

Useful for gating keeper actions, multi-bot setups, and off-chain-signed intents (pair with slot_deadline to bound how long an intent stays valid).

The guard also verifies the on-chain is_signer byte equals 1 (defense in depth, in case a buggy SDK forgets AccountMeta.isSigner = true). CU scales linearly with N: roughly 17 + 11*N. 25 CU at N=1.

How to use it

import { signerAllowlistIx } from "@solana-asm/shield"import { PublicKey, Transaction } from "@solana/web3.js" const SIGNER_ALLOWLIST = new PublicKey("SLDPp75MazNodaDGQVqduNNGYYbJVYk3EKWLFppYtvh") const tx = new Transaction()tx.add(signerAllowlistIx({  programId: SIGNER_ALLOWLIST,  signer: keeper.publicKey,  allowed: [keeperA.publicKey, keeperB.publicKey, keeperC.publicKey],}))tx.add(yourDestinationInstruction)

Assembly walkthrough

lines 1-8

Constants for every byte we will touch

.equ INSTRUCTION_DATA_LEN, 0x2868.equ INSTRUCTION_DATA,     0x2870.equ ALLOWED_PUBKEYS,      0x2871.equ ACCT0_IS_SIGNER,      0x0009.equ ACCT0_PUBKEY_0,       0x0010.equ ACCT0_PUBKEY_1,       0x0018.equ ACCT0_PUBKEY_2,       0x0020.equ ACCT0_PUBKEY_3,       0x0028

Eight constants this time because signer_allowlist reads more pieces of the input region than slippage. The big additions are the is_signer flag byte and the signer's 32-byte pubkey, split into four 8-byte chunks.

INSTRUCTION_DATA_LEN = 0x2868 and INSTRUCTION_DATA = 0x2870 are the standard offsets for any guard with ONE account that holds no data (the per-account region takes 0x2868 bytes, then the ix metadata follows). ALLOWED_PUBKEYS = 0x2871 is exactly one byte past INSTRUCTION_DATA because the ix data layout is [u8 count][32 bytes * count]: one count byte, then count contiguous 32-byte pubkeys.

Why ACCT0_IS_SIGNER = 0x0009? The first 8 bytes of the input region are the u64 account count. Byte 8 is the dup tag (0xFF for a fresh account, otherwise the index of an earlier duplicate). Byte 9 is is_signer. That is exactly where 0x09 comes from. The runtime writes 1 to this byte if and only if the account actually signed the transaction (the signature check happens BEFORE the program runs; this byte is the result the runtime leaves for us).

Why ACCT0_PUBKEY_0..3 at 0x0010, 0x0018, 0x0020, 0x0028? After byte 9 (is_signer) come is_writable (10), executable (11), and 4 bytes of alignment padding (12-15). The 32-byte pubkey starts at byte 16 (0x10). 32 bytes is four u64 chunks of 8 bytes each, so they sit at 16, 24, 32, 40 (which is 0x10, 0x18, 0x20, 0x28). We load all four into registers up front so the inner loop is register-vs-memory, not memory-vs-memory.

lines 12-13

Trust the runtime, verify anyway

  ldxb r2, [r1 + ACCT0_IS_SIGNER]  jne r2, 1, not_signer

ldxb r2, [r1 + ACCT0_IS_SIGNER] reads the 1-byte is_signer flag (ldxb is 'load byte', zero-extended into r2). jne r2, 1, not_signer fails with exit 3 unless it equals 1.

Why check this at all? The Solana runtime ALREADY checks signatures before invoking any program. But there is a real failure mode: a buggy SDK could construct the ix with AccountMeta { isSigner: false } for the signer. The runtime would not require a signature for that pubkey, and the is_signer byte would be 0. Without this guard's check, signer_allowlist would happily compare an UNSIGNED pubkey against the allowlist and succeed. With this check, the failure surfaces as exit 3 ('not signer'), an explicit diagnostic that tells the SDK author exactly what went wrong.

lines 15-16

Read the allowlist count, reject empty lists fast

  ldxb r2, [r1 + INSTRUCTION_DATA]  jeq r2, 0, not_allowed

ldxb r2, [r1 + INSTRUCTION_DATA] reads the count byte (a u8) into r2. r2 now holds how many allowed pubkeys we expect in the rest of the ix data.

jeq r2, 0, not_allowed bails immediately if count is 0. An empty allowlist means 'nobody is permitted', every signer fails. Treated as condition-failure (exit 1), not as malformed input, because the caller might have legitimately constructed an empty list and we should reject rather than crash.

lines 18-22

Check that the actual ix data length matches the declared count

  ldxdw r3, [r1 + INSTRUCTION_DATA_LEN]  mov64 r4, r2  lsh64 r4, 5  add64 r4, 1  jne r3, r4, bad_ix_data

Expected length = 1 + 32 * count. Compare against what the runtime actually delivered.

lsh64 r4, 5 is left-shift by 5 bits, which is the same as multiplying by 32 (since 2^5 = 32). Strictly the same result as mul64 r4, 32, but shift is a cheaper microop. Then add64 r4, 1 for the count byte itself. r4 = expected total length.

jne r3, r4, bad_ix_data on mismatch. This catches both 'caller declared count=5 but only sent 3 pubkeys' (truncation) and 'caller declared count=5 but sent 6 pubkeys' (trailing junk). Either way, exit 2.

lines 24-27

Cache the signer's pubkey in registers before the loop

  ldxdw r6, [r1 + ACCT0_PUBKEY_0]  ldxdw r7, [r1 + ACCT0_PUBKEY_1]  ldxdw r8, [r1 + ACCT0_PUBKEY_2]  ldxdw r9, [r1 + ACCT0_PUBKEY_3]

Four ldxdw instructions load the signer's 32-byte pubkey into r6..r9 as four u64 chunks. We do this ONCE here, before the loop, so the inner loop body only does memory loads on the allowlist side.

Why r6-r9 specifically? Two reasons. First, they are the registers guaranteed to survive a syscall (we do not call any here, but Shield keeps the convention consistent across guards). Second, we have nothing else competing for them right now, the comparison is about to use them and that is it.

lines 29-30

Set up the allowlist pointer

  mov64 r3, r1  add64 r3, ALLOWED_PUBKEYS

Copy r1 (the input region base) into r3, then add ALLOWED_PUBKEYS to advance r3 to the first 32-byte allowlist entry (one byte past the count). The loop below advances r3 by 32 each iteration to walk through the entries one at a time.

lines 32-43

Compare the signer against the current allowlist entry, 8 bytes at a time

check:  ldxdw r4, [r3 + 0]  jne r4, r6, advance  ldxdw r4, [r3 + 8]  jne r4, r7, advance  ldxdw r4, [r3 + 16]  jne r4, r8, advance  ldxdw r4, [r3 + 24]  jne r4, r9, advance   mov64 r0, 0  exit

The check label is the inner loop body. Compare the current allowlist entry (in memory at r3) against the cached signer (in registers r6-r9), one 8-byte chunk at a time.

ldxdw r4, [r3 + 0] reads chunk 0 of the candidate, jne r4, r6, advance branches to advance on the first mismatch. Same pattern for offsets 8, 16, 24 against r7, r8, r9. Each compare is one load + one branch, minimal work.

Why unroll the four chunks instead of looping them? Because the signer is already in fixed registers (r6-r9), each compare is one ldxdw + one jne. A sub-loop would need its own counter, indirect addressing, and an extra branch back to the inner check, more instructions per chunk for no benefit. Loop overhead is wasted when the loop body is shorter than the loop machinery.

If all four chunks match, execution falls through past the last jne to mov64 r0, 0; exit. The signer is in the allowlist, transaction continues.

lines 45-48

Advance to the next allowlist entry

advance:  add64 r3, 32  sub64 r2, 1  jne r2, 0, check

advance is the path taken when the candidate did not match. add64 r3, 32 moves r3 to the next 32-byte pubkey in the allowlist (each entry is 32 bytes). sub64 r2, 1 decrements the remaining count.

jne r2, 0, check loops back to the comparison if we still have entries to check. When r2 reaches 0, we have walked the whole allowlist with no match. Fall through to not_allowed (exit 1).

CU scales linearly with the size of the allowlist. Empirically: roughly 17 + 11*N CU. 25 CU at N=1, ~50 CU at N=3, ~225 CU at N=20.

lines 50-69

Three failure paths, three exit codes

not_allowed:  lddw r1, msg_not_allowed  mov64 r2, 11  call sol_log_  mov64 r0, 1  exit not_signer:  lddw r1, msg_not_signer  mov64 r2, 10  call sol_log_  mov64 r0, 3  exit bad_ix_data:  lddw r1, msg_bad  mov64 r2, 11  call sol_log_  mov64 r0, 2  exit

not_allowed (exit 1): we walked the entire allowlist without finding the signer. Log 'not allowed' (11 bytes).

not_signer (exit 3): the runtime's is_signer byte was 0, the pubkey passed at position 0 did not actually sign the tx. Exit 3 means 'invalid account' across all Shield guards.

bad_ix_data (exit 2): the declared count and the actual byte length disagreed.

All three follow the same pattern: load the log string address into r1, the length into r2, call sol_log_, set r0 to the exit code, exit. The log strings let the SDK tell the user exactly why the tx failed.

lines 71-74

Read-only string table

.rodata  msg_not_allowed: .ascii "not allowed"  msg_not_signer:  .ascii "not signer"  msg_bad:         .ascii "bad ix data"

Three log strings, one per failure path. Total program footprint stays under a few hundred bytes. The heaviest part of the program is the four-ldxdw chain in the inner loop, not the rodata.

Exit codes

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