DEV Community

Sipho Yawe
Sipho Yawe

Posted on

How I shrank a Solana deadline guard from 148 compute units to 4.

An arbitrage bot signs a transaction at slot N. The spread is good at slot N. Then the network hiccups, the transaction waits in a forwarding queue, and it lands 200 slots later, about 80 seconds, in a market where the spread has inverted. The transaction is still valid, so it executes. The bot loses money on a trade it priced ninety seconds ago.

Solana's built-in defense is recentBlockhash expiry, but that window runs roughly 150 slots and you can't tune it per transaction. What you want is a programmable deadline: if this lands after slot X, fail everything. That is the subject of Blueshift's Assembly Timeout challenge: a guard program, written in raw sBPF assembly, that you bundle into a transaction. It reads an 8-byte little-endian u64 from instruction data (max_slot_height), compares it to the current slot, and returns nonzero when the transaction is late. A nonzero return aborts the whole transaction, every instruction in it. Stale arbitrage, delayed execution, replayed instructions: dead on arrival.

I wrote this program three times. The verifier rejected it twice. The version that passed is 5 instructions, a 320-byte .so, and 4 compute units on the success path. Getting there meant debugging the specification, not my code. Twice.

The input buffer, since you have probably never seen it

Write Anchor and you never look at how a Solana program receives its inputs, because Anchor looks for you. The runtime serializes everything (account count, accounts, instruction data) into one memory region and hands your entrypoint a single pointer in r1. Offset 0 holds the account count as a u64. Each account follows, fully serialized. Then an 8-byte instruction-data length, then the instruction data.

That layout is the whole story here. Every bug and every fix below is an offset into that buffer.

Why assembly? This program runs inside someone else's transaction, and every compute unit it burns comes out of their budget. A guard that costs 10,000 CUs gets deleted under load. A guard that costs 4 gets left in. The blueshift-gg/sbpf toolchain builds the .so in about 2 ms, so the edit-test loop beats anchor build's dependency check.

Program 1: test-driven against the wrong spec

The challenge page teaches a solution built on the sol_get_clock_sysvar syscall: load the account count, load the deadline from offset 0x10, fetch the Clock struct into a stack buffer, compare slots, exit.

I wrote the tests first. Mollusk 0.7.2, pointed at the sbpf init scaffold, so every test failed before I wrote a line of assembly. Then I implemented the documented contract, and my with-accounts tests stayed red.

The canonical code has what looks like an elegant account veto. It loads the account count into r0, reasoning that r0 is the exit-code register, so a nonzero count fails the program at exit with no branch needed. The next instruction calls sol_get_clock_sysvar. Syscalls return their status in r0. The syscall succeeds, returns 0, and erases the veto. The check is dead code.

It gets worse. With an account serialized in front of the instruction data, offset 0x10 no longer holds the deadline. It holds the first 8 bytes of that account's pubkey. Pubkeys are random 32-byte values, so those 8 bytes read as an astronomically large number. The deadline check degenerates to "always pass." A guard whose one job is rejecting bad transactions waves them through.

The fix is one instruction of reordering: jne r0, 0, end right after loading the count, before the syscall, while r0 still holds the truth. Fail closed, 3 CUs on the veto path, never even pays for the syscall. Tests green. I uploaded the .so feeling clever.

Rejection 1: three compute units is a fingerprint

The verifier came back:

Custom program error: 0x1, consumed 3 of 1400000 compute units
Enter fullscreen mode Exit fullscreen mode

Three CUs is not a number. It is a stack trace: ldxdw, jne taken, exit. My account veto fired on the verifier's success vector. The verifier invokes the program with one account attached and expects it to succeed.

Read that twice, because I had to. The canonical code's r0-clobber is not a bug to the verifier. The dead veto and the garbage-pubkey "deadline" are exactly what its success vector exercises. The bug was load-bearing. I had test-driven my way to a correct implementation of the wrong contract.

The lesson started to form: the spec is the verifier's behavior. The page's comments are commentary.

Rejection 2: the verifier rejects its own course page

So I reverted to byte-for-byte canonical. I even kept the two-slot lddw r0, 1 instead of the cheaper mov64, on the theory that deviation got me rejected last time. Uploaded.

Exceeded compute units: used 148, max 4
Enter fullscreen mode Exit fullscreen mode

The log shows the program succeeded, at 148 CUs. The verifier caps the success path at 4.

Here is the problem with that cap. sol_get_clock_sysvar alone costs 140 CUs. I measured it from both directions. The success path runs 8 instructions and totals 148, so the syscall is 148 minus 8, or 140. The failure path runs 9 and totals 149: 149 minus 9, again 140. Both decompositions agree. Any solution that touches the syscall starts 136 CUs over budget.

So the verifier rejects the exact solution its own course page teaches. I re-scraped the page expecting an update I had missed. Same content, same commit hash. The page and the verifier had diverged, and only one of them grades your submission.

The deduction

A 4-CU budget admits very few designs. Four CUs is four executed instructions. You cannot call anything. You can load, compare, and exit. And the verifier passes one account in.

That forces a single conclusion: the account is the Clock sysvar, and you read the slot straight out of the input buffer. The cheapest syscall is the one you skip. The runtime already serialized the data you need into memory you already hold.

Now the offset arithmetic. One account, 40 bytes of data:

  • 0x0000: account count, 8 bytes
  • 0x0008: account header (dup flag, is_signer, is_writable, executable, 4-byte orig_data_len), 8 bytes
  • 0x0010: pubkey, 32 bytes (the same bytes Program 1 mistook for a deadline)
  • 0x0030: owner, 32 bytes
  • 0x0050: lamports, 8 bytes
  • 0x0058: data length, 8 bytes
  • 0x0060: account data, the Clock struct, with slot as the first u64

Past the data sit 40 bytes of Clock, 10,240 bytes of realloc padding, 8 bytes of rent_epoch, and 8 bytes of instruction-data length. Add those from 0x0060 and you land at 0x2898, where the caller's deadline lives.

I checked the arithmetic against a prior graduate's public solution. Identical offsets: 0x0060 and 0x2898.

Program 3: five instructions

.equ CLOCK_SLOT, 0x0060
.equ MAX_SLOT_HEIGHT, 0x2898

.globl entrypoint
entrypoint:
    ldxdw r2, [r1+MAX_SLOT_HEIGHT]
    ldxdw r1, [r1+CLOCK_SLOT]
    jle r1, r2, end
    lddw r0, 1
end:
    exit
Enter fullscreen mode Exit fullscreen mode

Load the deadline, load the slot, compare, exit. The .so is 320 bytes. Measured at 4 CUs on success, 5 on failure. The verifier passed it 2 for 2.

One detail earns its own paragraph. The success path never writes r0. The SVM zero-initializes registers before entry, and that free zero is the entire success exit code. Four instructions run on success (ldxdw, ldxdw, jle, exit) and not one of them spends a cycle saying "OK." The runtime said it at initialization.

jle is inclusive, which is a decision rather than an accident. A transaction landing exactly on the deadline slot passes. I test that boundary on its own.

The test suite

Rewritten each round, always red before green:

  • passes_when_current_slot_below_deadline
  • passes_when_current_slot_equals_deadline, the jle boundary
  • fails_when_current_slot_exceeds_deadline, asserting ProgramError::Custom(1)
  • passes_with_max_u64_deadline
  • fails_when_max_slot_is_zero_and_current_nonzero
  • cu_budget_in_success_path, at most 4, the verifier's cap, pinned as a regression test
  • cu_budget_in_failure_path, at most 5

cargo test: 7 of 7 green, 4 CUs on success and 5 on failure

The tests build the Clock account by hand: 40 bytes of bincode (slot, epoch_start_timestamp, epoch, leader_schedule_epoch, unix_timestamp, each 8-byte little-endian) passed as account #1, matching the verifier's shape.

What I am keeping

A test harness tells you what your code does. Only the integration target tells you what it must do. Mollusk found the r0-clobber in milliseconds. No amount of local testing could have found the real contract, because the real contract lived only in the verifier's two live invocations.

Compute-unit counts are fingerprints. "Consumed 3" named which branch fired, down to the instruction. "Max 4" named the entire intended design, ruling out every syscall solution in a single number.

The cheapest syscall is the one you skip. With sol_get_clock_sysvar, the floor was 140 CUs no matter how tight the assembly around it. Reading the sysvar from the input buffer removed the floor. The data was already there. I had been paying 140 CUs to ask for a copy.

One production caveat: the 4-CU version trusts the caller to pass the real Clock sysvar at account #1. For untrusted callers, check the account key against the Clock sysvar address. A few more CUs, well spent.

Next I want a slot-deadline helper: tx.with_deadline(secs) that resolves to a max_slot_height, so arb bots, DAO vote windows, and timed airdrops can use this without doing slot arithmetic by hand. If that would be useful to you, the repo below is where it will land.

Resources

Top comments (0)