-
Notifications
You must be signed in to change notification settings - Fork 6
feat(distribution): exact O(1) payouts in token units, and fix the O(n^2) basis-point path #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
61e6278
338a490
cc04cfe
93c027f
d5e8e4f
54b78be
3ffbe57
d182c69
6d4389d
5b7ae96
eec6466
afcb367
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,92 @@ | ||
| /// How a pool is split across paid places. | ||
| /// | ||
| /// ## This enum is closed — do not add variants | ||
| /// | ||
| /// The shape space is covered: flat (`Uniform`), linear, polynomial | ||
| /// (`Exponential`), scale-free decay (`Geometric`), headline-plus-tail | ||
| /// (`Tiered`), and arbitrary (`Custom`). Before reaching for variant #7: | ||
| /// | ||
| /// - **A shape these can't express, with a fixed field?** Use `Custom` — it | ||
| /// encodes any curve exactly, up to its packed-storage ceiling. That is the | ||
| /// escape hatch; it removes the need for the enum to grow. | ||
| /// - **Anything involving external state** — dynamic payouts, oracle-driven | ||
| /// amounts, streaming, vesting? That is an integration, not a curve: use a | ||
| /// prize/entry-fee *extension*, which exists precisely for logic the host | ||
| /// cannot know about. | ||
| /// | ||
| /// Every variant added here ripples through Serde (events, calldata, | ||
| /// indexers, SDKs, clients) and two packed-storage layouts, and costs | ||
| /// consumer-contract bytecode against Starknet's 81,920-felt class limit — | ||
| /// adding `Geometric` + `Tiered` cost Budokan ~3,000 felts, leaving it ~95% | ||
| /// full. Curves are core; integrations are extensions. | ||
| /// | ||
| /// ## Choosing a variant | ||
| /// | ||
| /// | you want | use | notes | | ||
| /// | --- | --- | --- | | ||
| /// | everyone equal | `Uniform` | cheapest | | ||
| /// | gentle gradient | `Linear(w)` | any weight | | ||
| /// | steeper gradient, small field | `Exponential(10*k)` | k in 1..=5; 1st ≈ (k+1)/n | | ||
| /// | "each place gets X% of the one above" | `Geometric(a, b)` | 1st ≈ 1 - b/a at any field size; | ||
| /// reach bounded by ratio | | ||
| /// | headline 1st prize AND thousands of paid places | `Tiered` | the only variant that serves a | ||
| /// very large field | | ||
| /// | exact hand-authored percentages | `Custom(shares)` | fixed field only | | ||
| #[derive(Drop, Copy, Serde, PartialEq)] | ||
| pub enum Distribution { | ||
| Linear: u16, | ||
| Exponential: u16, | ||
| Uniform, | ||
| Custom: Span<u16>, | ||
| /// Geometric decay as a rational ratio `(a, b)`: each position receives | ||
| /// `b / a` of the one above it, so `W(p) = a^(n-p) * b^(p-1)`. Requires | ||
| /// `a > b > 0` — e.g. `(10, 7)` is "each place gets 70% of the previous". | ||
| /// | ||
| /// Unlike `Exponential` — which is a power law, and whose winner share | ||
| /// falls off as roughly `(k+1)/n` — a geometric curve's shape does not | ||
| /// depend on the size of the field: first place takes about `1 - b/a` of | ||
| /// the pool whether there are 10 paid places or 100. That is the shape a | ||
| /// headline first prize actually needs, and no `Exponential` weight | ||
| /// produces it over a large field. | ||
| /// | ||
| /// The trade is reach: the weights span `(a/b)^n`, so the representable | ||
| /// field size shrinks as the ratio gets finer. See | ||
| /// `max_geometric_payouts`. | ||
| /// | ||
| /// NOTE: appended deliberately. Serde indices are positional, so inserting | ||
| /// this anywhere earlier would silently reinterpret every stored and | ||
| /// indexed distribution. | ||
| Geometric: (u16, u16), | ||
| /// Two tiers: a geometric head over the first `head_count` places taking | ||
| /// `head_share_bps` of the pool, and the remaining places splitting the | ||
| /// rest evenly. | ||
| /// | ||
| /// This is the only family here that works for a very large field. A | ||
| /// single curve cannot: anything steep enough to give first place a real | ||
| /// share rounds its tail to nothing, and anything flat enough to pay the | ||
| /// tail gives first place nothing. Over 10,000 places the best a single | ||
| /// curve can do for first place is ~0.06% (`Exponential` k=5); a | ||
| /// `Geometric` head of 39 on (10, 7) taking 80% pays first place 24%, | ||
| /// while every one of the other 9,961 places still receives its slice of | ||
| /// the remaining 20%. | ||
| /// | ||
| /// The geometric reach bound applies to `head_count`, not the field, so | ||
| /// the head is always well inside it. Requires a fixed paid-places count | ||
| /// strictly greater than `head_count`. | ||
| Tiered: TieredConfig, | ||
| } | ||
|
|
||
| /// Configuration for `Distribution::Tiered`. | ||
| #[derive(Drop, Copy, Serde, PartialEq)] | ||
| pub struct TieredConfig { | ||
| /// Geometric decay `(a, b)` for the head: each place gets `b / a` of the | ||
| /// one above. Same semantics and validity rules as `Geometric`. | ||
| pub head_ratio: (u16, u16), | ||
| /// How many places the head covers. Must be under the paid-places count | ||
| /// and within `max_geometric_payouts(a)`. | ||
| pub head_count: u16, | ||
| /// The head's slice of the pool, in basis points. Strictly between 0 and | ||
| /// 10000 — at either extreme one of the tiers would round to an | ||
| /// unclaimable zero, and the single-curve variants cover those shapes. | ||
| pub head_share_bps: u16, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,8 +16,16 @@ pub trait PrizeStoreTrait<T> { | |
| /// are routed via the component's `resolve_prize` before this is | ||
| /// called and never reach the store bridge. | ||
| fn get_token_record(self: @T, prize_id: u64) -> PrizeRecord; | ||
| /// Get custom shares for a prize (reconstructs from packed storage) | ||
| /// Get custom shares for a prize (reconstructs from packed storage). | ||
| /// | ||
| /// O(count/15) storage reads. Only view surfaces need the whole curve — | ||
| /// a claim wants exactly one share, so it calls `get_custom_share_at`. | ||
| fn get_custom_shares(self: @T, prize_id: u64) -> Array<u16>; | ||
| /// One share by 1-indexed position, without rebuilding the array. | ||
| /// | ||
| /// Mirrors `EntryFeeStoreTrait::get_custom_share_at`. Shares are packed | ||
| /// 15 to a felt, so this is a single storage read at any position. | ||
| fn get_custom_share_at(self: @T, prize_id: u64, position: u32) -> u16; | ||
| /// Store a token prize. Takes the host-assigned context + sponsor | ||
| /// alongside the variant payload; converts to StoredPrize for | ||
| /// storage. | ||
|
|
@@ -67,10 +75,19 @@ pub impl PrizeStoreImpl<T, +Store<T>, +Drop<T>> of PrizeStoreTrait<T> { | |
| Option::Some(dist) => { | ||
| match dist { | ||
| game_components_utilities::distribution::structs::Distribution::Custom(_) => { | ||
| let shares = self.get_custom_shares(prize_id); | ||
| // Return the shape with an empty span | ||
| // rather than rebuilding the curve. | ||
| // Loading it is O(count/15) storage | ||
| // reads on a path that runs on every | ||
| // claim, and a claim needs exactly one | ||
| // share — `get_custom_share_at`. | ||
| // View surfaces call | ||
| // `get_custom_shares` explicitly. | ||
| // Mirrors the entry-fee store, which | ||
| // has always done this. | ||
| Option::Some( | ||
| game_components_utilities::distribution::structs::Distribution::Custom( | ||
| shares.span(), | ||
| array![].span(), | ||
| ), | ||
| ) | ||
| }, | ||
|
|
@@ -99,6 +116,13 @@ pub impl PrizeStoreImpl<T, +Store<T>, +Drop<T>> of PrizeStoreTrait<T> { | |
| record | ||
| } | ||
|
|
||
| fn get_custom_share_at(self: @T, prize_id: u64, position: u32) -> u16 { | ||
| let index: u32 = position - 1; | ||
| let slot_index: u8 = (index / CUSTOM_SHARES_PER_SLOT.into()).try_into().unwrap(); | ||
| let index_in_slot: u8 = (index % CUSTOM_SHARES_PER_SLOT.into()).try_into().unwrap(); | ||
| Store::get_custom_shares_packed(self, prize_id, slot_index).get_share(index_in_slot) | ||
| } | ||
|
Comment on lines
+119
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 8 \
'get_custom_share_at|_get_custom_share_at|PrizeType::Distributed|set_prize_claimed|payout' \
packages --glob '*.cairo'Repository: Provable-Games/game-components Length of output: 50387 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Files:"
fd -a 'prize_store.cail|test_prize_store\.cairo|prize_component\.cairo|prize.*\.cairo' packages/metagame/src/prize packages/metagame/src 2>/dev/null | sed 's#^\./##'
echo
echo "prize_store outline:"
ast-grep outline packages/metagame/src/prize/prize_store.cairo --view expanded || true
echo
echo "Relevant store implementation:"
sed -n '1,190p' packages/metagame/src/prize/prize_store.cairo | cat -n
echo
echo "Relevant tests around custom share:"
sed -n '330,420p' packages/metagame/src/prize/tests/test_prize_store.cairo | cat -n
echo
echo "Claim/payout references in prize files only:"
rg -n -C 10 'set_prize_claimed|payout_prize|claim|Custom|get_custom_share_at|get_custom_shares|positions' packages/metagame/src/prize --glob '*.cairo'Repository: Provable-Games/game-components Length of output: 50387 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Packed share behavior probe:"
python3 - <<'PY'
# Mirrors the documented packed-share arithmetic in packages/metagame/src/prize/prize_store.cairo.
CUSTOM_SHARES_PER_SLOT = 15
positions = [0, 1, 15, 16, 17, 18, 384, 385]
count = 17
for position in positions:
index = position - 1
if index < 0:
print(f"position {position}: underflows u32; no direct slot conversion")
continue
slot_index_u8 = (index // CUSTOM_SHARES_PER_SLOT)
index_in_slot_u8 = (index % CUSTOM_SHARES_PER_SLOT)
readable = 0 <= slot_index_u8 <= 255 and 0 <= index_in_slot_u8 <= 14
status = "readable slot if slot exists" if readable else f"u8 conversion failure ({slot_index_u8=})"
print(f"position {position}: zindex={index}, slot={slot_index_u8}, offset={index_in_slot_u8}, status={status}, in_stored_range={0 < position <= count}")
PY
echo
echo "Error constants and custom share tests:"
rg -n -C 4 'Prize:|Custom share|get_custom_share_at|position + 1|position - 1|CUSTOM_SHARE|SHARES_PER_SLOT' packages/metagame/src/prize --glob '*.cairo'
echo
echo "Read claim resolution in prize component around custom shares:"
rg -n -C 12 'get_custom_share_at|get_payout_position|set_prize_claimed|claim|Distributed|pout' packages/metagame/src/prize/prize_component.cairo | sed -n '1,260p'Repository: Provable-Games/game-components Length of output: 50386 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Determine claim-related code shape around get_custom_share_at callers"
rg -n 'get_custom_share_at|_get_custom_share_at|calculate_share|payout_prize|set_prize_claimed|get_claims|Claimable|claim_position|position' packages/metagame/src/prize/prize_component.cairo
echo
sed -n '1,340p' packages/metagame/src/prize/prize_component.cairo | cat -n
echo
sed -n '340,660p' packages/metagame/src/prize/prize_component.cairo | cat -n
echo
echo "Interface and claim type definitions in package interfaces"
fd -a 'prize.*\.cairo|claim.*\.cairo' packages packages/metagame 2>/dev/null | sed -n '1,200p'
rg -n 'iprize|IPRIZE|claim_prize|claims|ClaimType|position' packages --glob '*.cairo' | sed -n '1,260p'Repository: Provable-Games/game-components Length of output: 50386 Reject out-of-range custom shares before packed lookup.
📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| fn get_custom_shares(self: @T, prize_id: u64) -> Array<u16> { | ||
| let count = Store::get_custom_shares_count(self, prize_id); | ||
| let mut shares = ArrayTrait::new(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Provable-Games/game-components
Length of output: 50387
🏁 Script executed:
Repository: Provable-Games/game-components
Length of output: 23459
🏁 Script executed:
Repository: Provable-Games/game-components
Length of output: 13998
Preserve the full custom share span on
IPrize.get_prize.PrizeComponentImpl::get_prizereturns the result ofget_token_record, which converts every storedDistribution::CustomtoCustom(array![].span()). The publicIPrizeview therefore exposes an incompletePrizeRecord. Keepget_prizereturning the reconstructed custom shares, and have claim paths call the indexed share helper instead.📍 Affects 2 files
packages/metagame/src/prize/prize_store.cairo#L78-L90(this comment)packages/metagame/src/prize/tests/test_prize_store.cairo#L401-L411🤖 Prompt for AI Agents