Problem
Competition/bounty payouts are hard-capped at 50 winners per event. select_winners rejects any call with more than MAX_WINNERS_PER_SELECT recipients:
contracts/events/src/event_ops.rs:25 — const MAX_WINNERS_PER_SELECT: u32 = 50;
contracts/events/src/event_ops.rs:643 — if winners.len() > MAX_WINNERS_PER_SELECT { return Err(Error::InvalidWinnerPosition); }
This surfaces upstream as a product limit: the app can never let an organizer configure a bounty/competition with more than ~50 prize positions, no matter how the budget is split.
Root cause
select_winners pays every winner in a single atomic transaction (push model). Each winner in the loop does several cross-contract calls plus storage writes (contracts/events/src/event_ops.rs:698-716):
escrow::release(...) — token transfer (cross-contract call to the token contract)
profile.bootstrap(...) — cross-contract call to the profile contract
profile.bump_reputation(...) — cross-contract call
- a
Winner storage entry write
So N winners means N × (token transfer + 2 cross-contract invocations + storage writes) inside one transaction. Soroban imposes hard per-transaction limits on CPU instructions, memory, and the number/size of ledger entries a transaction may read/write. Past some N, the transaction exceeds that budget and reverts.
The 50 cap is a conservative bound chosen to stay safely under those limits — it is a technical ceiling of the current design, not a business rule.
Why we can't just raise the number
Removing or greatly increasing the cap on the current push-based select_winners is unsafe: a large winner list would make the payout transaction exceed Soroban's resource budget and revert every time, leaving the escrowed funds permanently stuck (the escrow can only be settled through this path). A bounded loop is what guarantees the payout always fits in one transaction and the escrow can't be bricked.
Impact
- Organizers cannot run competitions/bounties that reward more than ~50 participants (e.g. broad quest-style or airdrop-style distributions).
- The product-layer prize-tier cap (currently 15, see
boundless / boundless-nestjs MAX_PRIZE_TIERS) is boxed in by this contract limit — it can be raised at most to 50 without a contract change.
Proposed directions
Scaling beyond ~50 requires changing the payout model from push to pull, not raising the constant:
- Pull / claim model — winners claim their own prize in their own transaction (one transaction per winner), so there is no single giant transaction. This contract already implements this pattern for grants/crowdfunding via
claim_milestone (contracts/events/src/grant.rs:60); a bounty/competition variant would remove the per-call winner ceiling.
- Merkle distributor — store a Merkle root of
(recipient, amount) on-chain; each winner claims against a proof. Scales to thousands+ recipients with O(1) on-chain state.
Both are contract redesigns. If unbounded/large winner counts are a real product requirement (quests, airdrops), we should pick one of the above. If bounties remain "podium-style," we may instead just document 50 as the intentional ceiling and align MAX_PRIZE_TIERS to it.
Asks
- Decide whether large/unbounded winner counts are in scope for bounties/competitions.
- If yes, choose a payout model (pull-claim vs Merkle distributor) and scope the
select_winners redesign.
- If no, treat 50 as the documented ceiling and cap
MAX_PRIZE_TIERS accordingly.
References
contracts/events/src/event_ops.rs:25 (MAX_WINNERS_PER_SELECT = 50)
contracts/events/src/event_ops.rs:643 (cap check)
contracts/events/src/event_ops.rs:698-716 (per-winner payout loop)
contracts/events/src/grant.rs:60 (claim_milestone pull-model precedent)
Problem
Competition/bounty payouts are hard-capped at 50 winners per event.
select_winnersrejects any call with more thanMAX_WINNERS_PER_SELECTrecipients:contracts/events/src/event_ops.rs:25—const MAX_WINNERS_PER_SELECT: u32 = 50;contracts/events/src/event_ops.rs:643—if winners.len() > MAX_WINNERS_PER_SELECT { return Err(Error::InvalidWinnerPosition); }This surfaces upstream as a product limit: the app can never let an organizer configure a bounty/competition with more than ~50 prize positions, no matter how the budget is split.
Root cause
select_winnerspays every winner in a single atomic transaction (push model). Each winner in the loop does several cross-contract calls plus storage writes (contracts/events/src/event_ops.rs:698-716):escrow::release(...)— token transfer (cross-contract call to the token contract)profile.bootstrap(...)— cross-contract call to the profile contractprofile.bump_reputation(...)— cross-contract callWinnerstorage entry writeSo N winners means N × (token transfer + 2 cross-contract invocations + storage writes) inside one transaction. Soroban imposes hard per-transaction limits on CPU instructions, memory, and the number/size of ledger entries a transaction may read/write. Past some N, the transaction exceeds that budget and reverts.
The
50cap is a conservative bound chosen to stay safely under those limits — it is a technical ceiling of the current design, not a business rule.Why we can't just raise the number
Removing or greatly increasing the cap on the current push-based
select_winnersis unsafe: a large winner list would make the payout transaction exceed Soroban's resource budget and revert every time, leaving the escrowed funds permanently stuck (the escrow can only be settled through this path). A bounded loop is what guarantees the payout always fits in one transaction and the escrow can't be bricked.Impact
boundless/boundless-nestjsMAX_PRIZE_TIERS) is boxed in by this contract limit — it can be raised at most to 50 without a contract change.Proposed directions
Scaling beyond ~50 requires changing the payout model from push to pull, not raising the constant:
claim_milestone(contracts/events/src/grant.rs:60); a bounty/competition variant would remove the per-call winner ceiling.(recipient, amount)on-chain; each winner claims against a proof. Scales to thousands+ recipients with O(1) on-chain state.Both are contract redesigns. If unbounded/large winner counts are a real product requirement (quests, airdrops), we should pick one of the above. If bounties remain "podium-style," we may instead just document 50 as the intentional ceiling and align
MAX_PRIZE_TIERSto it.Asks
select_winnersredesign.MAX_PRIZE_TIERSaccordingly.References
contracts/events/src/event_ops.rs:25(MAX_WINNERS_PER_SELECT = 50)contracts/events/src/event_ops.rs:643(cap check)contracts/events/src/event_ops.rs:698-716(per-winner payout loop)contracts/events/src/grant.rs:60(claim_milestonepull-model precedent)