Sign in

Pay-as-you-act

Suggest an edit

Many protocols have a step that is done at the end, for everyone at once: the votes are counted, the pool is paid out, the average is updated. Every such step is a loop over everyone's data, and on Solana nobody sends that transaction. What if there was no final step at all?

The transaction nobody sends

In an election, the bad design stores the votes, one account or one slot per vote. When the voting period closes, someone walks through all of the votes to find the winner. That is the loop from the previous lecture: the sender pays for every vote, a common effort shifted onto a single person. With thousands of votes the loop also runs out of compute, because every transaction has a compute limit, and so does the block. A paid executor does not help here, because no amount of money raises that limit.

The final step is spread over the actions

The good design spreads the final step over the actions that happen anyway. When a vote comes in, the program immediately adds one to that candidate's count and compares the count with the leader's count. If the count is higher, this candidate becomes the new leader. Each voter runs one addition and one comparison inside their own transaction, and by the end of the voting period the count is already done. The leader is recorded in the state, and the closing transaction reads one field.

This is pay-as-you-act. The program stores the answer instead of the inputs, and every action updates the answer. Most steps that a protocol would do at the end can be spread this way: finding the winner, computing a payout, keeping an average. The answer has to be something that one action can update without reading the other inputs, such as a count, a total, a maximum, a leader, or an average stored as a sum and a count.

Counting at the end versus counting as you goTwo boxes side by side. On the left, votes one to ten thousand are stored as separate accounts, and a closing count has to read all of them, paid by one person for everyone. On the right, each vote arrives, adds one to the candidate's count, compares it with the leader and replaces the leader if higher. The leader field is always current, and the closing transaction reads one field. Each voter pays for one addition and one comparison. Who pays for the count Count at the end vote 1 vote 2 vote 3 ... vote 10,000 closing count reads every vote 10,000 accounts to read paid by one person, for everyone Count as you go vote arrives +1 to the count above the leader? swap leader field paid by each voter, for their vote one addition, one comparison, closing reads 1 field The work is the same. One design puts it on one person at the end, the other puts it on each voter at the moment they vote.

Hxro: each bet updates the total

Hxro runs parimutuel markets. Everyone bets on whether a price goes up or down over an interval, and when the interval ends, the losing side's money is split between the winners in proportion to their bets. The step at the end would be to add up every bet on both sides and then pay every winner. Hxro does neither, because each bet adds its amount to its side's total at the moment it is placed, so when the round ends, both totals are already there. The oracle names the winning side, and each winner claims on their own:

// the winner's share of the whole pool before fees, from the two totals that the bets already built
payout = bet / winning_total * (winning_total + losing_total)

The bettors paid for the totals when they bet, and each winner pays for their own payout when they claim.

Orca: the swap updates the fee total

Orca Whirlpools is a DEX. Every swap pays a fee, and that fee belongs to the liquidity providers whose liquidity the swap used. A pool has thousands of such positions, and none of them is in the swap transaction. The step at the end would be to go through every position and hand out its part of the fees.

Instead, the pool stores one number per token: how much fee one unit of liquidity has earned since the pool opened. When a swap happens, the program takes the swap's fee, divides it by the liquidity that was active in that swap, and adds the result to this number. This happens inside the swap transaction, so the swapper pays for it. Each position keeps its own copy of this number from the last time it collected fees. When the owner collects, the program pays the difference:

// what one unit of liquidity earned since this position last collected, times its liquidity
owed = (pool.fee_per_liquidity - position.last_fee_per_liquidity) * position.liquidity

The swap pays for the shared number, and each owner pays for their own share.

When an action cannot be undone

Undoing an action is not always as easy as doing it. A total can go down as easily as it goes up: a withdrawal subtracts what a deposit added. The same is true for a count and for an average stored as a sum and a count. A maximum cannot go down like that. In the election, the leader is a maximum. If a voter takes their vote back from the leading candidate, the leader may change, and the new leader is whoever had the second highest count. The program does not know who that was, and the only way to find out is to read every candidate's count. With a few fixed candidates that is a short loop, and the program can afford it. With an open list of candidates it is the same loop that does not fit.

So when the answer is a maximum, the design has to decide what happens on a withdrawal before the first vote arrives, because the structure of the answer is part of the account layout. The simplest decision is to forbid the withdrawal: a vote is final, and the maximum only grows. If votes have to be withdrawable, the program needs a structure that can remove an element without reading everything, for example a sorted list of candidates where the client finds the new position and the program only checks the two neighbors.

Adding a vote and removing a voteTwo boxes side by side. Both start with candidate A at 5 votes, B at 3 and C at 2, with A as the leader. On the left, one vote is added to B, so B has 4. The program compares 4 with the leader's 5, and A stays the leader. That is one comparison. On the right, one vote is removed from A, so A has 4. To know whether A is still the leader, the program has to read the counts of B and C, so it reads every count. Who is the leader now? Add a vote A 5, B 3, C 2, leader A +1 to B, B is 4 4 above 5? no leader A, one comparison Remove a vote A 5, B 3, C 2, leader A -1 from A, A is 4 is anyone above 4? read B and C, every count Adding an input is one comparison. Removing one means reading every count.

Everyone pays for their own piece

A protocol on Solana has no moment when it stops and sums up what happened, because nobody would pay for that moment. So the protocol never keeps a pile of inputs waiting to be processed. It keeps the answer from the very first action, and every action that arrives moves the answer one step further. By the time the protocol would have needed the final step, the step has already been done by everyone who took part. Everyone paid for their own piece of the work at the moment of their own action, and nobody paid for the whole protocol at once.