The maximum users per batch in real execution conditions is 59 users as found by the compute units stress limits test. Previously Rowship developer @WalquerX who is in charge of unit test mentioned that the limit lied between 7 and 100 users. Through boundary exploration we found out the hard limit of this implementation of the Vault program is 59 users.
After several tests under different conditions computing units per user lie between 23728 and 51852 CU/User. Considering an optimistic computing cost of 23728 CU/User, an average of 37790 CU/User and a pessimistic 51852 CU/User we determine the following limits. Keep in mind that Solana's maximum transaction computing units limit caps transactions at 1.4 million CU.
Scenario | Max Batch | Total CU |
---|---|---|
Optimistic | 59 | 1,399,952 |
Average | 37 | 1,398,230 |
Pessimistic | 26 | 1,348,152 |
- Maximum Limit: 59 users per transaction
- Primary Bottleneck: Account Limits
Here we analyze the different scaling scenarios for Birdshot with different user base sizes alongside feasible batch sizes and the expected execution time for each execution of update_user_balances_batch.
Users | Tokens | Batches | Time Est | Feasible |
---|---|---|---|---|
10 | 3 | 1 | 2s | ✅ YES |
20 | 8 | 4 | 8s | ✅ YES |
1,000 | 15 | 18 | 36s | ✅ YES |
5,000 | 25 | 59 | 180s | ❌ NO |
- Solana account limit: 59 users
- Practical safe limit: 27 users
- Primary bottleneck: Account Limits
- Memory usage at limit: 21 KB
- Memory per user: 0.39 KB
Considering:
- User array iteration is causes the computing cost of the transaction to rise proportionally as the number of users per batch grows.
- Error handling does not account for partial batch failures, transaction executions might fail.
The use of the current vault DOES NOT meet the batch size requirements necessary to handle more than 60 users. It could theoreticallyl be used in production under the follosing conditions:
- Batch Max Limit: 50 users (although there's still risk of failure)
- Batch Optimal Size: 27 users
- Fallback: Reduce batch size if failures occur
As a solution we suggest adding a constant-complexity function to handle the amounts each user can later withdraw addresses the need to iterate over an array of users. Dynamic array iteration makes computing costs unpredictable and causes them to grow proportionally as the batch size increments.
We propose an implementation of a function that only deals with round snapshots as opposed to individually updating every user balance. This is a pattern called 'lazy updating' and it's used by major DeFi protocols like Synthetix, Aave and Compound.
// === O(1) ALTERNATIVE: Just store round metadata ===
pub fn finalize_round_o1(
ctx: Context<UpdateUserBalancesBatch>,
token_mint: Pubkey,
total_tokens_received: u64,
) -> Result<()> {
let vault = &mut ctx.accounts.vault;
require_executor_role(&ctx.accounts.caller.key(), vault)?;
require_stage(vault, VaultStage::Update)?;
let total_sol_spent = vault.current_round_spend;
require!(total_sol_spent > 0, VaultErrorCode::InsufficientFunds);
// O(1): Just store the round snapshot - no user iteration
vault.round_snapshots.push(RoundSnapshot {
round: vault.current_round,
total_sol_spent,
total_tokens_received,
token_mint,
});
// Update vault's token holdings
vault.add_or_update_token_balance(token_mint, total_tokens_received)?;
emit!(RoundFinalized {
round: vault.current_round,
token_mint,
total_sol_spent,
total_tokens_received,
});
Ok(())
}
The above function has been tested for computational feasibility and significantly reduces the cost of updating the state of the vault after every round. However this requires a function to calculate the pending tokens for a user within the withdraw function.
pub fn calculate_pending_tokens(&self, vault: &Vault, target_round: u64) -> Result<Vec<TokenBalance>> {
let mut pending_tokens = Vec::new();
// Process all rounds since last update
for snapshot in vault.round_snapshots.iter() {
if snapshot.round > self.last_processed_round && snapshot.round <= target_round {
let contribution = if self.sol_balance >= self.investment_per_token && self.is_active {
self.investment_per_token
} else {
0
};
if contribution > 0 {
let user_fee = (contribution * 2) / 100;
let user_net_contribution = contribution - user_fee;
let tokens = (user_net_contribution as u128 * snapshot.total_tokens_received as u128 / snapshot.total_sol_spent as u128) as u64;
// Add to pending tokens
let mut found = false;
for pending in pending_tokens.iter_mut() {
if pending.mint == snapshot.token_mint {
pending.amount += tokens;
found = true;
break;
}
}
if !found {
pending_tokens.push(TokenBalance {
mint: snapshot.token_mint,
amount: tokens,
});
}
}
}
}
Ok(pending_tokens)
}
- This implementation has not been audited for security yet.
- This implementation would require deposits of a fixed amount of SOL (e.g. 5, 10, 20, 50, 100) from users in order to avoid having to iterate over a user-balances array.