fee_ceiling

86cu · 2-ix

Abort if any SetComputeUnitPrice exceeds the priority fee ceiling.

program idSLDM7koS4UYLni15NGVoNW1DMG8ueZJmcGAA6UqMzQQ

What it does

fee_ceiling answers: does any SetComputeUnitPrice in this transaction exceed the per-CU priority fee ceiling? If yes, abort.

Caps priority-fee bids so a misconfigured client cannot quietly burn an order of magnitude more than intended. Useful for keeper bots and agent flows that submit transactions automatically.

The guard walks the Instructions sysvar to inspect every other top-level instruction. 86 CU on a 2-ix tx (limit + guard, no match), scaling roughly linearly with num_instructions and adding ~30 CU per matched SetComputeUnitPrice in the loop.

How to use it

import { feeCeilingIx } from "@solana-asm/shield"import { ComputeBudgetProgram, PublicKey, Transaction } from "@solana/web3.js" const FEE_CEILING = new PublicKey("SLDM7koS4UYLni15NGVoNW1DMG8ueZJmcGAA6UqMzQQ") const tx = new Transaction()tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 30_000 }))tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 500 }))tx.add(feeCeilingIx({  programId: FEE_CEILING,  maxMicroLamports: 1_000n,}))tx.add(yourDestinationInstruction)

Account 0 is the Instructions sysvar, wired internally by the SDK. The caller only supplies the ceiling.

Assembly walkthrough

lines 1-7

Constants for the byte offsets and the magic numbers we are scanning for

.equ ACCT0_KEY,             0x0010.equ ACCT0_DATA_LEN,        0x0058.equ ACCT0_DATA,            0x0060 .equ EXPECTED_IX_DATA_LEN,  8.equ CB_PRICE_IX_LEN,       9.equ SET_CU_PRICE_DISC,     3

Six constants. The first three say where the Instructions sysvar lives inside our input region. The last three say what we are looking for inside that sysvar.

Why ACCT0_KEY = 0x0010, ACCT0_DATA_LEN = 0x0058, ACCT0_DATA = 0x0060? Same per-account header math as balance_floor. The first 8 bytes of the input region are the u64 account count. Then for account 0: 8 bytes of dup tag + flags + padding, then 32-byte pubkey at offset 16 (0x10), then 32-byte owner at 48, then u64 lamports at 80, then u64 data_len at 88 (0x58), then the data itself starts at 96 (0x60). These are the standard offsets for any guard that declares one account.

Why EXPECTED_IX_DATA_LEN = 8? Our own ix data is one u64 ceiling (the per-CU priority fee cap), nothing else. 1 u64 = 8 bytes.

Why CB_PRICE_IX_LEN = 9? A ComputeBudget SetComputeUnitPrice instruction's data is 1 discriminator byte + 1 u64 price = 9 bytes. We will use this to filter out other ComputeBudget variants while walking the tx.

Why SET_CU_PRICE_DISC = 3? ComputeBudget uses single-byte discriminators to tell its variants apart: RequestHeapFrame = 1, SetComputeUnitLimit = 2, SetComputeUnitPrice = 3, SetLoadedAccountsDataSizeLimit = 4. We only care about variant 3.

lines 11-23

Verify account 0 is actually 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

The Instructions sysvar is a special Solana sysvar that contains the full serialized list of every instruction in the current transaction. It is the only way for a program to inspect its sibling instructions. fee_ceiling NEEDS that introspection because it has to find every ComputeBudget SetComputeUnitPrice in the tx and check it against the ceiling.

The sysvar lives at a fixed pubkey (Sysvar1nstructions1111111111111111111111111). If account 0 is anything else, this guard cannot do its job. Exit 3 with 'bad account'.

lddw r2, sysvar_ix_key loads the address of a 32-byte rodata constant holding the sysvar's raw pubkey bytes. Four pairs of ldxdw + jne compare account 0's pubkey (32 bytes starting at 0x10) against that constant, 8 bytes at a time. First mismatch jumps to bad_account.

Why check this 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.

lines 25-27

Set up the sysvar data pointer

  ldxdw r2, [r1 + ACCT0_DATA_LEN]  mov64 r3, r1  add64 r3, ACCT0_DATA

Account 0 is confirmed to be the Instructions sysvar. ldxdw r2 reads the total length of the sysvar's data block into r2. mov64 r3, r1; add64 r3, ACCT0_DATA puts r3 at byte 0 of the sysvar's data. Every offset from here on is computed from r3, not from r1.

lines 29-37

Find our own ix using current_instruction_index

  mov64 r4, r3  add64 r4, r2  sub64 r4, 2  ldxh r5, [r4 + 0]   mov64 r4, r5  lsh64 r4, 1  add64 r4, r3  ldxh r9, [r4 + 2]

The Instructions sysvar data is laid out as: a 2-byte num_instructions count at the start, then a 2-byte offset entry for each instruction (each entry is the byte offset within the sysvar data where that instruction's serialized form lives), then the serialized instructions themselves, and finally the LAST 2 bytes are current_instruction_index (the index of the instruction the runtime is currently executing, which is THIS guard).

r4 = r3 + r2 - 2 puts r4 at the address of the last 2 bytes. ldxh reads 2 bytes (a u16) into r5. That is our own index in the tx.

Now compute the address of offsets[current_idx]. Each offset entry is 2 bytes, so we multiply current_idx by 2 (lsh64 r4, 1 is left-shift by 1 = multiply by 2, cheaper than mul64). Add r3 to get the absolute address. The +2 in ldxh r9, [r4 + 2] skips the num_instructions u16 at the very start of the sysvar data, so we land on the right offsets-table entry.

lines 39-50

Skip the ix header to land on our own ceiling u64

  mov64 r4, r3  add64 r4, r9   ldxh r5, [r4 + 0]  mov64 r9, r5  mul64 r9, 33  add64 r9, 34  add64 r9, r4   ldxh r5, [r9 + 0]  jne r5, EXPECTED_IX_DATA_LEN, bad_ix_data  ldxdw r6, [r9 + 2]

r4 = r3 + r9 = the base of our own ix's serialized form within the sysvar. Each serialized ix has this layout: 2-byte num_accounts, then num_accounts account metas (33 bytes each = 1 flag byte + 32-byte pubkey), then 32-byte program_id, then 2-byte ix_data_len, then ix_data.

ldxh r5, [r4 + 0] reads num_accounts. We need to skip past: 2 bytes (num_accounts) + 33 * num_accounts (account metas) + 32 (program_id) = 34 + 33 * num_accounts. lsh64 r4, 1 then add64 r4, 1 would not work in one go, so the code splits: mul64 r9, 33; add64 r9, 34; add64 r9, r4. r9 = pointer to our ix_data_len.

ldxh r5, [r9 + 0] reads ix_data_len. jne r5, 8, bad_ix_data verifies it equals 8.

ldxdw r6, [r9 + 2] reads the u64 ceiling from our ix data, 8 bytes past the length field. The +2 is because ix_data_len is a u16 (2 bytes), not a u64. r6 now holds the per-CU ceiling for the rest of the program.

lines 52-53

Loop setup

  ldxh r8, [r3 + 0]  mov64 r7, 0

ldxh r8, [r3 + 0] reads num_instructions (the very first u16 of the sysvar data). r8 is the loop bound. mov64 r7, 0 initializes the outer counter. We will walk every instruction in the tx, checking each one for a SetComputeUnitPrice that exceeds the ceiling.

lines 55-71

Walk every ix, compute its program_id pointer

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

jge r7, r8, ok: counter reached num_instructions with no violation found, success exit.

Same offsets-table dance as the previous block, but for ix at index r7 instead of our own ix. offsets[r7] gives the byte offset of ix r7's serialization. r9 = base of ix r7.

This time we want to skip to the program_id (not the ix_data_len). Skip 2 (num_accounts) + 33 * num_accounts (account metas). r5 = pointer to ix r7's 32-byte program_id.

lines 73-89

Is this ix a ComputeBudget call?

  lddw r4, cb_program_id   ldxdw r0, [r5 + 0]  ldxdw r1, [r4 + 0]  jne r0, r1, next_ix   ldxdw r0, [r5 + 8]  ldxdw r1, [r4 + 8]  jne r0, r1, next_ix   ldxdw r0, [r5 + 16]  ldxdw r1, [r4 + 16]  jne r0, r1, next_ix   ldxdw r0, [r5 + 24]  ldxdw r1, [r4 + 24]  jne r0, r1, next_ix

lddw r4, cb_program_id loads the address of a 32-byte rodata constant holding ComputeBudget's program_id (ComputeBudget111111111111111111111111111111).

Four pairs of ldxdw + jne compare ix r7's program_id against the constant. First mismatch branches to next_ix (this ix is not a ComputeBudget call, move on). All four match means this ix targets ComputeBudget, continue checking.

lines 91-98

Is it specifically SetComputeUnitPrice, and does it exceed the ceiling?

  ldxh r4, [r5 + 32]  jne r4, CB_PRICE_IX_LEN, next_ix   ldxb r4, [r5 + 34]  jne r4, SET_CU_PRICE_DISC, next_ix   ldxdw r4, [r5 + 35]  jgt r4, r6, fee_too_high

We know ix r7 targets ComputeBudget. ComputeBudget has four variants; we only care about SetComputeUnitPrice. Check the shape:

ldxh r4, [r5 + 32] reads ix_data_len at offset 32 from the program_id (program_id is 32 bytes wide, so ix_data_len follows it). Must equal 9. The other ComputeBudget variants (SetComputeUnitLimit = 5 bytes, RequestHeapFrame = 5 bytes, SetLoadedAccountsDataSizeLimit = 5 bytes) all have shorter data, so a length of 9 effectively narrows us to SetComputeUnitPrice.

ldxb r4, [r5 + 34] reads the discriminator byte (offset 34 = program_id end at 32 + ix_data_len u16 at 32-33 = first ix data byte at 34). Must equal 3.

If both match, ldxdw r4, [r5 + 35] reads the u64 price (8 bytes after the discriminator).

jgt r4, r6, fee_too_high: if the bid price is STRICTLY greater than our ceiling, fail with exit 1. Equal passes (the boundary is inclusive on purpose, paying exactly the ceiling is fine).

lines 100-106

Advance and ok exit

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

next_ix: add64 r7, 1; ja loop. Increment the counter, jump back. We walk EVERY instruction in the tx. Solana's runtime already rejects txs with duplicate ComputeBudget variants, but the guard is more conservative: if a duplicate ever slips through, both get checked.

ok: mov64 r0, 0; exit. We walked the whole tx, no SetComputeUnitPrice exceeded the ceiling. Success.

lines 108-127

Three failure paths, three exit codes

fee_too_high:  lddw r1, msg_high  mov64 r2, 12  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

fee_too_high (exit 1): a SetComputeUnitPrice was higher than the ceiling. Log 'fee too high' (12 bytes).

bad_ix_data (exit 2): our own ix data was not exactly 8 bytes. Log 'bad ix data' (11 bytes).

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

All three follow the same pattern: load the log string into r1, the length into r2, call sol_log_, set r0 to the exit code, exit.

lines 129-134

Read-only data: two pubkeys and three strings

.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  cb_program_id:  .byte 0x03, 0x06, 0x46, 0x6f, 0xe5, 0x21, 0x17, 0x32, 0xff, 0xec, 0xad, 0xba, 0x72, 0xc3, 0x9b, 0xe7, 0xbc, 0x8c, 0xe5, 0xbb, 0xc5, 0xf7, 0x12, 0x6b, 0x2c, 0x43, 0x9b, 0x3a, 0x40, 0x00, 0x00, 0x00  msg_high:       .ascii "fee too high"  msg_bad:        .ascii "bad ix data"  msg_acct:       .ascii "bad account"

Two 32-byte pubkey constants (the Instructions sysvar key and the ComputeBudget program id) plus three log strings. The pubkeys are baked into the .so at link time, same as the log strings. No relocations, no dynamic allocation, the whole program is one self-contained chunk.

Exit codes

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