Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions packages/interfaces/src/distribution.cairo
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,
}
65 changes: 56 additions & 9 deletions packages/metagame/src/entry_fee/entry_fee_store.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use game_components_utilities::distribution::packed_shares::{
calculate_slot_position,
};
use game_components_utilities::distribution::structs::{
DIST_TYPE_CUSTOM, DIST_TYPE_EXPONENTIAL, DIST_TYPE_LINEAR, DIST_TYPE_UNIFORM,
PackedDistribution,
DIST_TYPE_CUSTOM, DIST_TYPE_EXPONENTIAL, DIST_TYPE_GEOMETRIC, DIST_TYPE_LINEAR,
DIST_TYPE_TIERED, DIST_TYPE_UNIFORM, PackedDistribution, TieredConfig,
};
use starknet::ContractAddress;
use crate::entry_fee::store::Store;
Expand Down Expand Up @@ -98,6 +98,30 @@ pub impl EntryFeeStoreImpl<T, +Store<T>, +Drop<T>> of EntryFeeStoreTrait<T> {
(Option::Some(Distribution::Exponential(packed_dist.dist_param)), packed_dist.positions)
} else if packed_dist.dist_type == DIST_TYPE_UNIFORM {
(Option::Some(Distribution::Uniform), packed_dist.positions)
} else if packed_dist.dist_type == DIST_TYPE_TIERED {
(
Option::Some(
Distribution::Tiered(
TieredConfig {
head_ratio: (
packed_dist.dist_param / 256, packed_dist.dist_param % 256,
),
head_count: packed_dist.dist_param2,
head_share_bps: packed_dist.dist_param3,
},
),
),
packed_dist.positions,
)
} else if packed_dist.dist_type == DIST_TYPE_GEOMETRIC {
(
Option::Some(
Distribution::Geometric(
(packed_dist.dist_param / 256, packed_dist.dist_param % 256),
),
),
packed_dist.positions,
)
} else {
// DIST_TYPE_CUSTOM — return with empty shares span. Loading the
// full array is O(N/15) storage reads and is only needed for UI
Expand Down Expand Up @@ -216,13 +240,32 @@ pub impl EntryFeeStoreImpl<T, +Store<T>, +Drop<T>> of EntryFeeStoreTrait<T> {

// Persist the distribution config (shape + paid-places count), and
// for Custom, the shares array in packed out-of-band storage.
let (dist_type, dist_param) = match config.distribution {
Option::None => (DIST_TYPE_LINEAR, 0_u16),
let (dist_type, dist_param, dist_param2, dist_param3) = match config.distribution {
Option::None => (DIST_TYPE_LINEAR, 0_u16, 0_u16, 0_u16),
Option::Some(dist) => match dist {
Distribution::Linear(w) => (DIST_TYPE_LINEAR, *w),
Distribution::Exponential(w) => (DIST_TYPE_EXPONENTIAL, *w),
Distribution::Uniform => (DIST_TYPE_UNIFORM, 0_u16),
Distribution::Custom(_) => (DIST_TYPE_CUSTOM, 0_u16),
Distribution::Linear(w) => (DIST_TYPE_LINEAR, *w, 0_u16, 0_u16),
Distribution::Exponential(w) => (DIST_TYPE_EXPONENTIAL, *w, 0_u16, 0_u16),
Distribution::Uniform => (DIST_TYPE_UNIFORM, 0_u16, 0_u16, 0_u16),
Distribution::Custom(_) => (DIST_TYPE_CUSTOM, 0_u16, 0_u16, 0_u16),
Distribution::Geometric((
a, b,
)) => {
// The two ratio terms share one u16 param slot as
// a*256 + b. Values past 255 would either panic on the
// u16 multiply or unpack as a silently different ratio,
// so the bound is owned here, not left to the host.
assert!(
*a <= 255 && *b <= 255, "EntryFee: geometric ratio terms must fit 8 bits",
);
(DIST_TYPE_GEOMETRIC, *a * 256 + *b, 0_u16, 0_u16)
},
Distribution::Tiered(cfg) => {
let (a, b) = *cfg.head_ratio;
assert!(
a <= 255 && b <= 255, "EntryFee: geometric ratio terms must fit 8 bits",
);
(DIST_TYPE_TIERED, a * 256 + b, *cfg.head_count, *cfg.head_share_bps)
},
},
};
// For Custom, paid places are defined by the shares array length;
Expand All @@ -235,7 +278,11 @@ pub impl EntryFeeStoreImpl<T, +Store<T>, +Drop<T>> of EntryFeeStoreTrait<T> {
Option::None => 0_u32,
};

self.set_distribution(context_id, PackedDistribution { dist_type, dist_param, positions });
self
.set_distribution(
context_id,
PackedDistribution { dist_type, dist_param, positions, dist_param2, dist_param3 },
);

if let Option::Some(dist) = config.distribution {
if let Distribution::Custom(shares) = dist {
Expand Down
11 changes: 11 additions & 0 deletions packages/metagame/src/prize/prize_component.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,17 @@ pub mod PrizeComponent {
PrizeStoreTrait::get_custom_shares(self, prize_id)
}

/// One custom share by 1-indexed position — a single storage read.
///
/// `_get_prize` deliberately returns Custom with an empty span, so a
/// claim settling one position reads its share through here instead of
/// paying to rebuild the whole curve.
fn _get_custom_share_at(
self: @ComponentState<TContractState>, prize_id: u64, position: u32,
) -> u16 {
PrizeStoreTrait::get_custom_share_at(self, prize_id, position)
}

/// Store a token-prize record (converts to StoredPrize for storage).
/// Extension prizes are not persisted via this path.
fn set_token_record(
Expand Down
30 changes: 27 additions & 3 deletions packages/metagame/src/prize/prize_store.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Comment on lines +78 to +90

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  'get_prize\(|get_token_record\(|Distribution::Custom|_get_custom_share_at|get_custom_share_at|get_custom_shares' \
  packages --glob '*.cairo'

Repository: Provable-Games/game-components

Length of output: 50387


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== prize interface excerpt =="
sed -n '80,105p' packages/interfaces/src/prize.cairo

echo "== prize_store getter excerpt =="
sed -n '60,115p' packages/metagame/src/prize/prize_store.cairo
sed -n '190,225p' packages/metagame/src/prize/prize_store.cairo

echo "== prize_component/prize wrapper candidates =="
fd -a 'prize.*\.cairo|prize_impl.*\.cairo|prize_store.*\.cairo' packages/metagame/src 2>/dev/null || true
rg -n --glob '*.cairo' 'get_prize|to_token_record|PrizeStoreTrait|IPrize|IPrizeImpl|impl .*Prize' packages/metagame/src packages/interfaces/src

echo "== tests excerpt around custom get_prize =="
sed -n '380,415p' packages/metagame/src/prize/tests/test_prize_store.cairo

echo "== all get_prize references outside distributions =="
rg -n --glob '*.cairo' 'get_prize\(' packages --glob '!packages/utilities/src/distribution/**'

Repository: Provable-Games/game-components

Length of output: 23459


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== prize_component implementation excerpt =="
sed -n '212,275p' packages/metagame/src/prize/prize_component.cairo
sed -n '530,558p' packages/metagame/src/prize/prize_component.cairo

echo "== structs to_token_record and custom packing excerpts =="
sed -n '125,155p' packages/metagame/src/prize/structs.cairo
sed -n '198,220p' packages/metagame/src/prize/structs.cairo

echo "== external call references to IPrize/get_prize =="
rg -n --glob '*.cairo' 'IPrizeDispatcher|IPrizeDispatcherTrait|get_prize\(' packages --glob '!packages/metagame/src/prize/**'

echo "== broader references to custom distribution shape expectation =="
rg -n --glob '*.cairo' 'get_prize.*Custom|Custom.*get_prize|reread\.len|get_prize must not rebuild|get_token_record|get_custom_shares|get_custom_share_at' packages --glob '!packages/utilities/src/distribution/**'

Repository: Provable-Games/game-components

Length of output: 13998


Preserve the full custom share span on IPrize.get_prize.

PrizeComponentImpl::get_prize returns the result of get_token_record, which converts every stored Distribution::Custom to Custom(array![].span()). The public IPrize view therefore exposes an incomplete PrizeRecord. Keep get_prize returning 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/metagame/src/prize/prize_store.cairo` around lines 78 - 90, Update
packages/metagame/src/prize/prize_store.cairo:78-90 so
PrizeComponentImpl::get_prize returns the fully reconstructed custom share span
through get_token_record, while claim paths use get_custom_share_at for the
indexed share. Update
packages/metagame/src/prize/tests/test_prize_store.cairo:401-411 to assert that
IPrize.get_prize exposes all stored custom shares.

),
)
},
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

position - 1 can underflow for position = 0, and values above count read beyond the configured distribution. Validate 1 <= position <= count before subtraction, keep the packed lookup limited to supported slot indices, and add coverages for invalid custom-share positions including empty prizes and slot boundaries.

📍 Affects 2 files
  • packages/metagame/src/prize/prize_store.cairo#L119-L124 (this comment)
  • packages/metagame/src/prize/tests/test_prize_store.cairo#L395-L399
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/metagame/src/prize/prize_store.cairo` around lines 119 - 124, Update
get_custom_share_at to validate that position is within 1..=count before
subtracting one, reject invalid positions without performing a packed lookup,
and restrict computed slot indices to supported slots. In
packages/metagame/src/prize/tests/test_prize_store.cairo:395-399, add coverage
for position zero, positions above count, empty prizes, and slot-boundary
positions.

Source: 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();
Expand Down
Loading
Loading