compute_unit_floor

93cu · 3-ix

Abort if SetComputeUnitLimit is missing or below the floor.

program idSLDfqR7EtW1Fgb8y8oEM6aFuho6Yccf8a3j2ebrGQEy

What it does

compute_unit_floor answers: does this transaction declare a SetComputeUnitLimit of at least N CU? If yes, continue. If no (or no SetComputeUnitLimit at all), abort.

Guarantees a minimum compute budget for keeper or agent transactions where a client might forget to set one or under-allocate, preventing partial execution and surprise 'Computational budget exceeded' aborts mid-tx.

Boundary is non-strict: units == minUnits passes (jlt is strict less-than). ~93 CU on a 3-ix tx (limit + guard + destination).

How to use it

import { computeUnitFloorIx } from "@solana-asm/shield"import { ComputeBudgetProgram, PublicKey, Transaction } from "@solana/web3.js" const COMPUTE_UNIT_FLOOR = new PublicKey("SLDfqR7EtW1Fgb8y8oEM6aFuho6Yccf8a3j2ebrGQEy") const tx = new Transaction()tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }))tx.add(computeUnitFloorIx({  programId: COMPUTE_UNIT_FLOOR,  minUnits: 100_000,}))tx.add(yourDestinationInstruction)

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,  4.equ CB_LIMIT_IX_LEN,       5.equ SET_CU_LIMIT_DISC,     2

Six constants. The first three are the standard sysvar input-region offsets (same as fee_ceiling and program_allowlist): account 0's pubkey at 0x10, its data length at 0x58, its data block at 0x60. See balance_floor for why those specific numbers fall out of the per-account header layout.

Why EXPECTED_IX_DATA_LEN = 4? Our ix data is one u32 holding the floor (4 bytes). We use a u32 instead of a u64 because SetComputeUnitLimit's units field is itself a u32, there is no point storing a floor that ComputeBudget cannot represent. 1 u32 = 4 bytes.

Why CB_LIMIT_IX_LEN = 5? A ComputeBudget SetComputeUnitLimit instruction's data is 1 discriminator byte + 1 u32 units = 5 bytes total.

Why SET_CU_LIMIT_DISC = 2? ComputeBudget's discriminator table: RequestHeapFrame = 1, SetComputeUnitLimit = 2, SetComputeUnitPrice = 3, SetLoadedAccountsDataSizeLimit = 4. We want variant 2 (distinct from fee_ceiling, which wanted variant 3).

lines 11-23

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

Same four-chunk sysvar pubkey compare as the other two sysvar-walking guards. Any mismatch exits 3 with 'bad account'. See fee_ceiling for the full explanation.

lines 25-37

Locate our own ix inside the sysvar data

  ldxdw r2, [r1 + ACCT0_DATA_LEN]  mov64 r3, r1  add64 r3, ACCT0_DATA   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]

Same sysvar walk as fee_ceiling. r3 = pointer to the sysvar's serialized data. r5 = current_instruction_index, read from the LAST 2 bytes of the sysvar data (where the runtime stamps it). r9 = offsets[current_idx] (the byte offset of our own ix's serialization within the sysvar data).

r4 = r3 + r9 = base of our own ix's serialized form. Every byte we read from here is relative to r4.

lines 39-50

Skip the per-ix header to land on our ceiling u32

  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  ldxw r6, [r9 + 2]

Same num_accounts * 33 + 34 hop as fee_ceiling to skip past num_accounts (2 bytes) + the account metas (33 bytes each) + the program_id (32 bytes), landing on our ix_data_len.

Validate ix_data_len == 4. Mismatch exits 2.

ldxw r6, [r9 + 2] reads the u32 floor into r6. ldxw loads 4 bytes (vs ldxdw's 8), zero-extending the top 32 bits of r6. r6 now holds the floor for the rest of the program. The +2 is because ix_data_len is itself a u16 (2 bytes), so the actual ix data starts 2 bytes past the length field.

lines 52-54

Loop setup with a 'found' flag

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

r8 = num_instructions. r7 = 0 (outer counter). r2 = 0 (the found flag).

Why a found flag? This guard has to enforce something subtler than fee_ceiling. It requires that AT LEAST ONE SetComputeUnitLimit greater than or equal to the floor exists in the tx, AND that no SetComputeUnitLimit is below the floor. Single pass through the ixs: set r2 = 1 when we see a valid one. After the loop, if r2 is still 0, no SetComputeUnitLimit was present at all, so we fail.

lines 56-73

Walk every ix, compute its program_id pointer

loop:  jge r7, r8, check_found   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, check_found exits the loop into the found-flag check (not directly into success). Even with no violation seen, we still need to verify we found at least one matching ix.

Same pattern as fee_ceiling for locating ix r7's program_id: offsets table, skip num_accounts * 33 + 2 bytes to land on the 32-byte program_id.

lines 74-90

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

Load cb_program_id (the ComputeBudget program's 32-byte pubkey from rodata). Four-chunk u64 compare. First mismatch branches to next_ix (this ix is not a ComputeBudget call, move on).

lines 92-101

Is it specifically SetComputeUnitLimit, and does it meet the floor?

  ldxh r4, [r5 + 32]  jne r4, CB_LIMIT_IX_LEN, next_ix   ldxb r4, [r5 + 34]  jne r4, SET_CU_LIMIT_DISC, next_ix   ldxw r4, [r5 + 35]  jlt r4, r6, cu_too_low   mov64 r2, 1

ix_data_len at offset 32 (right after the 32-byte program_id) must equal 5. Discriminator at offset 34 (program_id + ix_data_len u16) must equal 2. A length of 5 plus a discriminator of 2 effectively narrows us to SetComputeUnitLimit, distinct from fee_ceiling's price check (length 9, disc 3).

If both match, ldxw r4, [r5 + 35] reads the u32 units (4 bytes after the discriminator). ldxw zero-extends so the top 32 bits of r4 are 0.

jlt r4, r6, cu_too_low is strict less-than: if units is less than the floor, exit 1 immediately. Even one undersized SetComputeUnitLimit fails the whole tx, there is no point continuing the walk after a violation.

Otherwise, mov64 r2, 1 marks 'found a valid SetComputeUnitLimit' and falls through to next_ix.

lines 103-108

Advance and check the found flag at the end

next_ix:  add64 r7, 1  ja loop check_found:  jeq r2, 0, cu_too_low

next_ix: add64 r7, 1; ja loop. Walk every ix.

check_found: jeq r2, 0, cu_too_low. If r2 is still 0 after the loop, no SetComputeUnitLimit was found anywhere in the tx. Fail with cu_too_low (exit 1).

This is the 'missing SetComputeUnitLimit also fails' contract. The guard treats 'no explicit limit declared' the same as 'limit below floor'. Solana would otherwise apply a default per-ix CU budget; we do not trust that default. The caller must declare what budget they expect.

lines 110-112

Happy path exit

ok:  mov64 r0, 0  exit

ok: mov64 r0, 0; exit. At least one SetComputeUnitLimit was found and met the floor, and no SetComputeUnitLimit was below the floor. Success.

lines 114-133

Three failure paths, three exit codes

cu_too_low:  lddw r1, msg_low  mov64 r2, 10  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

cu_too_low (exit 1): either no SetComputeUnitLimit was found at all, or one was below the floor. Log 'cu too low' (10 bytes). Same exit code for both cases because they mean the same thing semantically: the tx's compute budget does not meet the caller's minimum.

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

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

lines 135-140

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  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_low:        .ascii "cu too low"  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. Same shape as fee_ceiling because both guards walk the same sysvar looking for ComputeBudget instructions, just for different opcodes (and a different comparison direction).

Exit codes

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