Lazy loading
Suggest an editA Solana program can only work with the accounts that the transaction brings, and no transaction happens by itself: someone has to send it and pay for it. Every design that has to update all holders or charge every position every hour runs into this, and most of them end up with a server that loops over the accounts. But what if we made the program lazy?
For all holders
A staking pool has thousands of stakers, and when rewards arrive, the simplest design pays every staker their share right away. To do that, the program has to write to every staker's account, and a transaction holds only a few dozen accounts. So one reward turns into hundreds of transactions, and somebody has to build, sign and pay for all of them. The naive solution is your own server that sends them, and then the protocol lives exactly as long as your server does.
The same problem appears wherever one event changes many accounts. A perpetuals exchange charges funding on every open position every hour. A wireless network pays rewards to a million devices every day, and a multisig has to invalidate every open proposal when its signers change. In every case one event has to reach thousands of accounts, and no user will send those transactions, because the sender pays the fees for everyone else and gets nothing for it.
Compute on touch
Instead of pushing the result into every account, the program stores in each account only what is needed to compute the result later, and computes it only when a transaction accesses that specific account. Whoever sent that transaction wanted the result, and that person pays for the computation. This is lazy loading. In traditional programming it is an optimization, where you don't compute something until someone asks for it. On Solana it is one of the best ways to build a protocol.
In the staking pool, the pool account keeps one number, the reward per staked token. It says how much reward every staked token has earned since the pool started. When 10 tokens of rewards arrive on 1,000 staked tokens, the pool pays nobody. It only raises the reward per staked token by 0.01, because every staked token has now earned 0.01 more. Each staker's account keeps the reward per staked token as it was at their last claim. Everything their tokens earned since then is owed to them:
// what every staked token earned since this staker's last claim, times their stake
owed = (pool.reward_per_token - staker.last_reward_per_token) * staker.stakeLater a staker with 100 tokens comes to claim. The pool's reward per staked token is now 0.05, and the staker's account still says 0.03 from their last claim. The program puts these numbers into the formula: (0.05 - 0.03) * 100 = 2 tokens. It pays the 2 tokens and writes 0.05 into the staker's account, so the next claim starts from there. No other staker was in this transaction, and none of them is wrong. Each of them keeps their own last value, and the same formula will give them the right amount when they come.
Every protocol below works the same way. One shared number grows over time and is kept in one place. Each account keeps its own copy of that number from the last time it was updated. The difference between the shared number and the copy is everything that this account has missed since then.
A deposit or a withdrawal changes the stake, and the payout has to happen before that change. Our staker with 100 tokens is owed 2 tokens, and now they deposit 50 more. If the program adds the deposit first, the stake becomes 150, and the formula gives (0.05 - 0.03) * 150 = 3 tokens. The extra token is a reward for 50 tokens that were not in the pool during that period. So the program first pays what the old stake earned, writes 0.05 as the last value, and only then adds the 50 tokens. The same order applies to a withdrawal.
Squads: one counter instead of a loop
Squads is a multisig. When its signers change, every proposal that the old signers approved has to become stale, so that it cannot execute under the new signers. The proposals are separate accounts, and none of them is in the transaction that changes the signers. So the multisig does not go through them at all. Every proposal gets a number when it is created, and the multisig keeps one counter. When the signers change, the counter is set to the number of the latest proposal. Later, when someone tries to execute a proposal, the program compares the proposal's number with the counter. If the number is at or below the counter, the proposal was created before the signers changed, so it is stale. One write when the signers change, one comparison per proposal, no matter how many proposals there are.
Fragmetric: the update happens on every transfer
Fragmetric is a liquid restaking protocol. Holders of its token, fragSOL, earn rewards for how much they held and for how long, so the protocol has to know when every balance changed. Checking every holder on a schedule would be the same loop over every account that nobody sends. Instead, fragSOL is issued through Token-2022, the newer version of Solana's token program, which has extensions that the old token program does not. One of them is the transfer hook: the token's creator registers a program of their own, and Token-2022 calls that program every time someone transfers the token. Fragmetric registered its reward program as the hook. A balance can only change in a transfer, and a transfer already has both the sender's and the receiver's accounts in the transaction. So Fragmetric's program updates the reward accounting of both holders right there, and the accounting comes for free.
Helium: a million devices, one claim each
Helium pays rewards to the owners of about a million devices. Paying each device once a day would be a million transactions a day, all sent and paid for by Helium. So Helium sends nothing. Its oracle signs one number per device: the total that the device has earned since the beginning. The device's account stores how much of that total was already paid out. When the owner claims, the program pays the difference between the two and writes the signed total as the new paid amount. These are the same two numbers as in the staking pool, except that the shared number comes in a signed message instead of an account.
Drift: funding is settled when the position is touched
Drift is a perpetuals exchange. Every hour its markets charge funding, a payment between longs and shorts, on every open position. The program never goes through the positions for that. Instead, the market stores one shared number, the total funding per unit of position since the market opened, and the hourly update changes only this number. Each position stores its copy of the number from the last time it was updated. When a trade, a close or a liquidation brings a position into a transaction, the program first charges the difference between the two numbers times the position size, and then updates the position's copy. A position that nobody has used for a week pays a week of funding in one subtraction.
Untouched accounts never update
Lazy means that untouched accounts never update. For rewards this is fine: the money waits, and the difference is paid whenever the claim comes. For liquidations it is not fine, because a position that nobody uses is never liquidated, and the protocol takes the loss. The same goes for an order that has to expire at a deadline. So if an action really needs to happen at a certain time, lazy loading alone is not enough. Either you pay an executor to send that transaction, or you rework the design so that an old value does no harm.
Nothing is up to date, nothing is wrong
On Solana there is no background. Between two transactions nothing runs, so a protocol cannot keep thousands of accounts up to date, and a lazy protocol does not try. Every event changes only the shared number, so that one number already includes everything that happened. Every other account is behind, and its own copy of the number says exactly how far behind. The moment someone brings the account into a transaction, the program pays or charges the difference and writes the shared number into the copy. So nothing in a lazy protocol is up to date, and nothing in it is wrong: every result is computed at the moment it is needed, by the person who needs it.