The Xelma contract was rebuilt around an indexed per-user key layout. The
old design stored every participant's position in a single Map<Address, T>
blob under one key, forcing every read/write to deserialise and reserialise
the whole map. The new design stores each user's record under a composite
(round_id, address) key — O(1) read/write per user, regardless of round size.
| Key | Value | Purpose |
|---|---|---|
Balance(Address) |
i128 |
per-user balance (unchanged) |
PendingWinnings(Address) |
i128 |
per-user pending payout (unchanged) |
UserStats(Address) |
UserStats |
per-user wins/losses (unchanged) |
ActiveRound |
Round |
currently active round metadata |
LastRoundId |
u64 |
monotonic round counter |
Position(round_id, user) |
UserPosition |
NEW — indexed UpDown bet |
PrecisionPosition(round_id, user) |
PrecisionPrediction |
NEW — indexed precision guess |
RoundParticipants(round_id) |
Vec<Address> |
NEW — ordered list for resolution iteration |
CancelledRound(round_id) |
true |
marker for cancelled rounds |
ArchivedRound(round_id) |
ArchivedRoundSummary |
NEW — compact post-settlement history |
RecentArchivedRoundIds |
Vec<u64> |
NEW — FIFO index for archive retention |
After every terminal round transition (resolve_round, admin cancel_round, or
minimum-participant fallback refund), the contract writes a compact
ArchivedRoundSummary keyed by DataKey::ArchivedRound(round_id).
Each summary records:
| Field | Meaning |
|---|---|
round_id |
Monotonic round identifier |
price_start / price_final |
Start oracle price and settlement price (0 when cancelled) |
mode |
Up/Down or Precision |
status |
Resolved, Cancelled, or FallbackRefund |
pool_up / pool_down |
Final pool totals at settlement time |
participant_count |
Participants recorded at settlement |
settled_at_ledger |
Ledger sequence when archived |
get_archived_round(round_id)— direct lookup by id (returnsNoneif pruned or never existed).get_recent_archived_rounds(limit)— newest-first list;limit = 0returns empty.
The contract retains at most MAX_ARCHIVED_ROUNDS = 128 summaries on-chain.
When a new archive would exceed this cap, the oldest entry is removed from
both ArchivedRound(id) and the RecentArchivedRoundIds FIFO index.
This bounds persistent storage growth predictably for explorers and analytics without requiring full event replay. Consumers needing deeper history should index contract events off-chain.
For a round with N participants:
| Path | Before (single-map blob) | After (indexed keys) |
|---|---|---|
place_bet (per user) |
1 deserialise + 1 reserialise of N-entry map | 1 has-check + 1 write of single record + 1 append to participant list |
place_precision_prediction |
same N-cost as above | same O(1) cost as above |
get_user_position |
full N-entry map read | single composite-key read |
get_user_precision_prediction |
full N-entry map read | single composite-key read |
resolve_round |
1 read of N-entry map + N stat updates | 1 read of N-entry participant list + N composite-key reads + N stat updates |
claim_winnings |
unchanged | unchanged |
The win is at bet placement: instead of paying O(N) every time someone joins the round, the contract now pays O(1). At N = 60 (large-round test), the old design would deserialise + reserialise a 59-entry map on every new bet; the new design touches only the single indexed key for that user plus a small append on the participant list.
Resolution still has to iterate every participant — there is no way around
that, regardless of layout. The participant list (RoundParticipants(round_id))
preserves the iteration order so that the resolution path matches the old
behaviour exactly: same payout formula, same tie-break order, same stats
updates. Per-user position records are then read individually inside the
loop, each as an O(1) ledger entry rather than a slice of one large blob.
resolve_round now performs targeted deletes: it walks the participant list
and removes each Position(round_id, user) (and PrecisionPosition for
precision mode), then removes the participant list entry itself. The legacy
single-map keys are also removed in case they exist from pre-migration data.
The refactor preserves every observable output:
- Pool totals (
pool_up/pool_down) are still maintained on theRoundstruct exactly as before. - Refund-on-tie, proportional payout on price move, and precision-mode tie splitting all use the same formulas as before.
_update_stats_win/_update_stats_lossare called for every participant in the same iteration order as before (driven by the participant list, which is appended in bet order).
Existing tests (lifecycle, betting, resolution, mode_tests, …) all
pass without functional changes. The one test that previously poked at
DataKey::UpDownPositions directly (test_multiple_rounds_lifecycle) was
updated — it now lets place_bet write the indexed key naturally and only
overrides the round pool totals to inject a simulated losing pool.
- Legacy keys remain readable.
get_user_positionfalls back toDataKey::Positionsif no indexed entry is present. This lets a pre-existing deployment serve historical reads while the next round runs against the new layout. - Legacy keys are no longer written.
place_betandplace_precision_predictiononly emit indexed keys. - No data migration required. Once
resolve_roundis called for any in-flight round under the old layout, the contract removes the legacy single-map keys and all subsequent rounds use the indexed layout.
contracts/src/tests/storage_benchmarks.rs adds operation-count assertions
for each core path:
bench_place_bet_writes_single_user_key— verifiesplace_betwrites the composite-key entry and does not write the legacy bulk-map key.bench_place_bet_op_count_assertion— after 10 bets, exactly 10 indexed position keys + 1 participant-list key exist.bench_resolve_cleans_indexed_keys— after resolution, all per-user keys- the participant list are removed.
bench_large_round_resolves_correctly— 60-participant round resolves with correct payouts and full storage cleanup.bench_precision_mode_indexed_keys— same indexed layout used forPrecisionPositionkeys.