Even with a ranked leaderboard and an escrowed prize pool ,
nothing currently moves XLM out of the contract to winners.
src/token.rs::TokenHelper::distribute_winnings exists but is never called.
Goal
A permissionless finalize_event function that, once a campaign has ended and
every match is resolved, ranks participants, splits the prize pool according to
reward_distribution, and pays the top-N addresses.
Requirements
-
New EventError (or new FinalizeError) variants:
EventNotEnded = 16 — env.ledger().timestamp() < event.end_time
MatchesNotComplete = 17 — at least one of the event's matches has
result_submitted == false
AlreadyFinalized = 18 — event.is_finalized == true
EventCancelled (reuse existing variant)
-
New function, suggested home src/finalize.rs:
pub fn finalize_event(env: &Env, caller: Address, event_id: u64) -> Result<Vec<(Address, i128)>, EventError>
caller.require_auth() — anyone may call this (permissionless, like
the old verify_event_winners); it just triggers payout once conditions
are met.
- Checks, in order: not paused → event exists → not cancelled → not already
finalized → event.has_ended(now) → all matches resolved.
let leaderboard = leaderboard::get_event_leaderboard(env, event_id)?;
let n = event.reward_distribution.len();
- Tie handling at the payout boundary: if
leaderboard.len() < n, only
pay out leaderboard.len() ranks. The unallocated percentage (sum of
reward_distribution[leaderboard.len()..]) is refunded to event.creator.
- Equal-points ties within the paid ranks: because Issue 4's leaderboard
already produces a fully deterministic order (points → exact_scores →
earliest prediction → address), there are no shared ranks — every
participant gets a distinct rank and thus a distinct (possibly zero)
payout. Document this explicitly in the function's doc comment so reviewers
don't expect "split the rank" logic — determinism is handled upstream.
- For each rank
i in 0..n.min(leaderboard.len()):
amount = event.prize_pool * reward_distribution[i] as i128 / 100
→ TokenHelper::distribute_winnings(env, &xlm_token, &leaderboard[i].user, amount)
- Remainder/dust: integer division can leave a few stroops undistributed.
Send any leftover (prize_pool - sum(amounts_paid) - refund_to_creator)
to event.creator along with their refund (single transfer).
- Zero participants: if
leaderboard.is_empty(), refund the entire
prize_pool to event.creator.
- Set
event.is_finalized = true, persist.
- Store a payout snapshot under a new
DataKey::EventPayouts(u64) →
Vec<(Address, i128)> for historical queries.
- Emit
(Symbol("event"), Symbol("finalized")) with
(event_id, winners_paid, total_distributed).
- Return the payout vec.
-
New view:
pub fn get_event_payouts(env: Env, event_id: u64) -> Vec<(Address, i128)>;
-
Expose finalize_event on the contract in src/lib.rs.
Acceptance criteria
Testing checklist (new tests/finalize_tests.rs)
Even with a ranked leaderboard and an escrowed prize pool ,
nothing currently moves XLM out of the contract to winners.
src/token.rs::TokenHelper::distribute_winningsexists but is never called.Goal
A permissionless
finalize_eventfunction that, once a campaign has ended andevery match is resolved, ranks participants, splits the prize pool according to
reward_distribution, and pays the top-N addresses.Requirements
New
EventError(or newFinalizeError) variants:EventNotEnded = 16—env.ledger().timestamp() < event.end_timeMatchesNotComplete = 17— at least one of the event's matches hasresult_submitted == falseAlreadyFinalized = 18—event.is_finalized == trueEventCancelled(reuse existing variant)New function, suggested home
src/finalize.rs:caller.require_auth()— anyone may call this (permissionless, likethe old
verify_event_winners); it just triggers payout once conditionsare met.
finalized →
event.has_ended(now)→ all matches resolved.let leaderboard = leaderboard::get_event_leaderboard(env, event_id)?;let n = event.reward_distribution.len();leaderboard.len() < n, onlypay out
leaderboard.len()ranks. The unallocated percentage (sum ofreward_distribution[leaderboard.len()..]) is refunded toevent.creator.already produces a fully deterministic order (points → exact_scores →
earliest prediction → address), there are no shared ranks — every
participant gets a distinct rank and thus a distinct (possibly zero)
payout. Document this explicitly in the function's doc comment so reviewers
don't expect "split the rank" logic — determinism is handled upstream.
iin0..n.min(leaderboard.len()):amount = event.prize_pool * reward_distribution[i] as i128 / 100→
TokenHelper::distribute_winnings(env, &xlm_token, &leaderboard[i].user, amount)Send any leftover (
prize_pool - sum(amounts_paid) - refund_to_creator)to
event.creatoralong with their refund (single transfer).leaderboard.is_empty(), refund the entireprize_pooltoevent.creator.event.is_finalized = true, persist.DataKey::EventPayouts(u64)→Vec<(Address, i128)>for historical queries.(Symbol("event"), Symbol("finalized"))with(event_id, winners_paid, total_distributed).New view:
Expose
finalize_eventon the contract insrc/lib.rs.Acceptance criteria
end_time, or while matches are unresolved, or twicereward_distribution, verified against realTokenClient::balancechanges in testsstroops only if you choose not to send dust anywhere — document the choice
either way)
get_event_payoutsreturns the stored snapshotTesting checklist (new
tests/finalize_tests.rs)test_finalize_event_distributes_top5_split— 5+ participants,[40,30,20,5,5], assert each winner's balance increases by the correct amounttest_finalize_event_before_end_time_rejectedtest_finalize_event_with_unresolved_match_rejectedtest_finalize_event_twice_rejectedtest_finalize_event_fewer_participants_than_ranks_refunds_creator— e.g. 2 participants, 5 rankstest_finalize_event_zero_participants_refunds_full_pooltest_finalize_event_zero_prize_pool_noop—is_finalizedbecomes true, no transfers occurtest_finalize_event_permissionless— called by a random non-admin, non-creator address succeeds