← Writing

Fitting a loan into 1,232 bytes

· By Rizkyy. · 7 min read

Fitting a loan into 1,232 bytes
On this page

The first time I tried to open a loan against tokenized shares, the transaction was 135 bytes too big.

Not too expensive. Not too slow. Too big.

Solana has a limit on how large a single transaction can be, and it is small enough that it ends up deciding how you design a product. The thing that ran out was not compute. It was bytes.

Where 1,232 comes from

A transaction must fit in one network packet. The ceiling is the IPv6 minimum MTU of 1,280 bytes, minus 40 for the IP header and 8 for the fragment header. That leaves 1,232 bytes for everything you want to say.

Everything has to share that space:

  • Signatures, 64 bytes each. Two signers is 128 bytes before you have written anything.
  • Account keys, 32 bytes each. Every distinct address an instruction touches appears once in the message.
  • Instructions, each with a program index, a list of account indexes and its data.
  • A recent blockhash, 32 bytes.

Account keys are the part that grows. A DeFi instruction routinely touches ten or fifteen accounts. Two or three such instructions in a row and the keys alone cost you a kilobyte.

What I was building

Morrow lets someone borrow cash against shares they hold, on Kamino's market for tokenized stocks. The shares stay locked as collateral and the cash lands in the person's own account.

The first time a person borrows, the market needs two accounts created before it can do anything: a user metadata account and an obligation that tracks the position. Then comes the deposit, the borrow, and the refresh instructions that update prices before a borrow is allowed. A relayer pays the fees, so the person never needs SOL, which adds a second signer. A small cash fee goes to the treasury too.

I put it all in one transaction, because that is what a person would expect: one tap, one loan. It came to 1,367 bytes.

Measure before you design

I did not guess where the bytes went. I built each combination and measured it:

What Bytes
Account openings alone 629
The money moves alone 1,125
Everything together 1,367

The pieces sum to more than 1,232 even though they are not additive, because a shared account key appears once. That shared overlap is why 629 + 1,125 is 1,754 and not 1,367. It also means you cannot predict the total by adding the parts. You have to build the real thing and count.

The same discipline applied to the small additions around the money transaction. Measured on the money transaction alone: 1,137 bytes bare, 1,184 once the fee transfer is added, 1,211 once the cash-account opening is added. All three together do not fit.

Trick one: split it, then order it

The accounts have to exist before the money can move, so they were already a natural first step. The fix was to stop pretending it was one operation.

The server returns two transactions, setup and main. The browser signs and submits them in that order. On every loan after the first, setup is empty and the person only sees one.

Most of the work is deciding what belongs where. The SDK returns a flat list of instructions, so my code walks them and sorts each one: account openings go to setup, with the relayer swapped in as the rent payer, and the price refreshes stay with the money transaction because they have to run right before it. The cash-account opening rides with setup when there is one, and goes at the front of the main transaction when there is not.

TS
// The accounts that have to exist first, and the money that moves once they do
export type LoanSteps = { setup: TransactionInstruction[]; main: TransactionInstruction[] }

Trick two: do not send what you did not write

Two byte-savers came from deleting things.

The SDK includes its own compute budget instructions. I drop them. They cost bytes, and a second pair is an error on-chain rather than an override. My own code sets the compute limit and priority fee exactly once.

And I do not use address lookup tables. Lookup tables are the standard way to shrink a transaction: you refer to an address by a one-byte index instead of its 32 bytes. I turned the SDK's lookup table creation off and the submit endpoint rejects any transaction that uses them. My reading of why that is worth it: every account a transaction touches is then written out in the message, so the server can read exactly what it is about to broadcast and compare it against what it expects. A lookup table hides addresses behind an indirection that is harder to check, and for a relayer that pays for what it signs, being able to read everything is the point.

The trade is that you pay full price in bytes, which makes tricks one and three matter more.

Trick three: size the compute, do not guess it

Compute limits do not take space, but they take money, and getting them wrong fails transactions. The relayer builds every transaction twice.

TS
const simulation = await connection.simulateTransaction(compile(MAX_COMPUTE_UNITS), {
  sigVerify: false,
  replaceRecentBlockhash: true,
})
const used = simulation.value.unitsConsumed
const units = used
  ? Math.min(MAX_COMPUTE_UNITS, Math.ceil(used * 1.2) + 1_000)
  : MAX_COMPUTE_UNITS

The first build uses a generous ceiling of 400,000 units and only exists to be simulated. The second sets the limit to what the simulation actually used, plus 20% and a thousand units of headroom. The reasoning is in the comment in the code: validators favour a higher fee per compute unit, and you pay for the limit you set, not the amount you use. A tight, honest limit is both cheaper and more attractive to land.

Simulating first has a second benefit. A transaction that would fail is rejected before anyone signs it, so a person is never asked to approve something that cannot work.

There is one case where simulation is impossible: the money transaction for a first loan depends on accounts that the setup transaction has not created yet. For that one I skip simulation and use the ceiling limit. It loses the early failure and the tight sizing, so it is a flag you opt into, not a default.

Trick four: say no to features that cost bytes

Every new collateral stock adds a refresh instruction to everything that follows it. At around 1,211 bytes against a limit of 1,232, there is no room for another one. So the product has a rule that looks arbitrary in the interface, one collateral stock per loan, and is exact in the transaction.

I would rather have an honest limit written in the error message than a feature that works on a Tuesday and fails on the stock whose account list is two keys longer.

What I took from it

  1. The limit is bytes, not compute. Check size early, because it constrains the shape of the product.
  2. Measure combinations, not parts. Shared account keys make sizes non-additive.
  3. Splitting a flow into steps is a product decision. Keep the common case a single step.
  4. A transaction you can fully read is a transaction you can fully verify. That is worth spending bytes on.
  5. Put a test on the limit. The gift flow has one that fails if its biggest transaction ever exceeds the byte limit.

The loan screen has one button. Behind it, a lot of effort went into making sure that one button fits in a packet.