From 61e627806797ad165c85f9adb8f4ea6ee04dcfd6 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:02:27 -0700 Subject: [PATCH 01/11] perf(distribution): compute the weight sum once instead of per position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both weighted distributions normalize a position's raw weight against the sum of every position's weight. That sum was rebuilt inside every single share computation, so `calculate_total` — and with it `calculate_dust` and the winner's `calculate_share_with_dust` — re-derived the whole vector once per position: O(n^2) work, with an O(n^2) count of `FixedTrait::pow` calls for Exponential. Payout index 1 pays that cost on every claim, because it is the only index that collects dust. Winners were therefore the most expensive claim in a tournament by an order of magnitude, scaling quadratically with paid places. Hoist the weights into a vector built once per call, and read shares, totals and dust off it. Single-share cost is unchanged in complexity (still O(n) — the normalization sum is inherent); the winner's path drops from O(n^2) to O(n). Values are unchanged. The weight expressions and their accumulation order are preserved exactly, so the fixed-point results are bit-identical; this was verified by sweeping the old and new implementations against each other across every payout index of every size 1..=12 for Linear (weights 10/25/7), Exponential (weights 10/15/25/100) and Uniform, at full and partial available_share, plus the zero-payouts and out-of-range edges. The zero-payout quirk where index 1 collects the entire available_share as dust is deliberately preserved — Budokan can reach it with an empty leaderboard. Adds `test_share_regression` pinning the exact basis points (ranges would not catch a few-bps drift) and `test_gas_benchmark` making the cost curve visible. l2_gas, winner's share incl. dust: Exponential w10, 10 places 10,535,820 -> 1,691,510 (6.2x) Exponential w10, 25 places 56,570,220 -> 4,019,060 (14.1x) Exponential w10, 50 places 214,644,220 -> 7,898,310 (27.2x) Exponential w15, 10 places 77,452,060 -> 7,245,380 (10.7x) Exponential w15, 25 places 459,709,180 -> 18,940,140 (24.3x) Linear w10, 10 places 8,436,360 -> 1,426,910 (5.9x) Linear w10, 50 places 169,699,960 -> 6,577,310 (25.8x) Non-winner positions are unchanged (within noise), as are Uniform and Custom, which never normalized against a weight sum. End to end, Budokan's `test_exponential_100_distribution_with_10_positions` (create + 10 entries + 10 claims) drops from ~71.6M to ~52.9M l2_gas, -26%. Co-Authored-By: Claude Opus 5 (1M context) --- packages/utilities/src/distribution.cairo | 3 + .../src/distribution/calculator.cairo | 258 ++++++++++-------- .../utilities/src/distribution/tests.cairo | 2 + .../tests/test_gas_benchmark.cairo | 147 ++++++++++ .../tests/test_share_regression.cairo | 153 +++++++++++ 5 files changed, 456 insertions(+), 107 deletions(-) create mode 100644 packages/utilities/src/distribution/tests.cairo create mode 100644 packages/utilities/src/distribution/tests/test_gas_benchmark.cairo create mode 100644 packages/utilities/src/distribution/tests/test_share_regression.cairo diff --git a/packages/utilities/src/distribution.cairo b/packages/utilities/src/distribution.cairo index 84c1e468..e3e3cca0 100644 --- a/packages/utilities/src/distribution.cairo +++ b/packages/utilities/src/distribution.cairo @@ -1,3 +1,6 @@ pub mod calculator; pub mod packed_shares; pub mod structs; + +#[cfg(test)] +mod tests; diff --git a/packages/utilities/src/distribution/calculator.cairo b/packages/utilities/src/distribution/calculator.cairo index a71e4de3..0788e033 100644 --- a/packages/utilities/src/distribution/calculator.cairo +++ b/packages/utilities/src/distribution/calculator.cairo @@ -3,7 +3,7 @@ //! Pure calculation functions for distribution share computation. //! These functions are stateless and can be used without the DistributionComponent. -use game_components_utilities::math::{FixedTrait, ONE}; +use game_components_utilities::math::{Fixed, FixedTrait, ONE}; use crate::distribution::structs::Distribution; /// Calculate the distribution share for a given payout index in basis points @@ -25,11 +25,10 @@ pub fn calculate_share( } match distribution { - Distribution::Linear(weight) => { - calculate_linear_share(payout_index, total_payouts, available_share, weight) - }, - Distribution::Exponential(weight) => { - calculate_exponential_share(payout_index, total_payouts, available_share, weight) + Distribution::Linear(_) | + Distribution::Exponential(_) => { + let (weights, denominator) = weight_vector(distribution, total_payouts); + share_at(@weights, denominator, payout_index, available_share) }, Distribution::Uniform => calculate_uniform_share(total_payouts, available_share), Distribution::Custom(shares) => calculate_custom_share(payout_index, shares), @@ -42,16 +41,35 @@ pub fn calculate_share( pub fn calculate_total( distribution: Distribution, total_payouts: u32, available_share: u16, ) -> u16 { - let mut total: u16 = 0; - let mut p: u32 = 1; - loop { - if p > total_payouts { - break; - } - total += calculate_share(distribution, p, total_payouts, available_share); - p += 1; + if total_payouts == 0 || available_share == 0 { + return 0; + } + + match distribution { + // The weighted distributions normalize each share against the sum of + // every position's weight. Building that vector once and summing from + // it keeps this O(n); calling `calculate_share` per position would + // rebuild the whole vector n times over (and with it, n `pow` calls + // each) for an O(n^2) total. + Distribution::Linear(_) | + Distribution::Exponential(_) => { + let (weights, denominator) = weight_vector(distribution, total_payouts); + sum_shares(@weights, denominator, available_share) + }, + Distribution::Uniform | + Distribution::Custom(_) => { + let mut total: u16 = 0; + let mut p: u32 = 1; + loop { + if p > total_payouts { + break; + } + total += calculate_share(distribution, p, total_payouts, available_share); + p += 1; + } + total + }, } - total } /// Calculate the rounding dust (difference between available_share and sum of all shares) @@ -83,119 +101,145 @@ pub fn calculate_dust(distribution: Distribution, total_payouts: u32, available_ pub fn calculate_share_with_dust( distribution: Distribution, payout_index: u32, total_payouts: u32, available_share: u16, ) -> u16 { - let base_share = calculate_share(distribution, payout_index, total_payouts, available_share); + // Payouts other than the winner never touch dust, so they take the plain + // single-share path. + if payout_index != 1 { + return calculate_share(distribution, payout_index, total_payouts, available_share); + } - // If this is payout_index 1 (winner), add any rounding dust - if payout_index == 1 { - let dust = calculate_dust(distribution, total_payouts, available_share); - base_share + dust - } else { - base_share + match distribution { + // The winner needs both its own share AND the sum of every share (to + // derive dust). Both come off one weight vector — the expensive part + // is built once, not twice, and not once per position. + Distribution::Linear(_) | + Distribution::Exponential(_) => { + // No early-out for `total_payouts == 0`: the vector comes back + // empty, every share reads 0, and the winner collects the whole + // `available_share` as dust. That is what the per-position + // implementation did, and callers depend on the exact value. + let (weights, denominator) = weight_vector(distribution, total_payouts); + let base_share = share_at(@weights, denominator, 1, available_share); + let total = sum_shares(@weights, denominator, available_share); + if total > available_share { + base_share + } else { + base_share + (available_share - total) + } + }, + Distribution::Uniform | + Distribution::Custom(_) => { + let base_share = calculate_share( + distribution, payout_index, total_payouts, available_share, + ); + base_share + calculate_dust(distribution, total_payouts, available_share) + }, } } -/// Calculate linear decreasing distribution with weight -/// First place gets most, decreasing linearly to last payout -/// Formula: share = 1 + (positionValue - 1) * (weight / 10) -/// where positionValue = n - payout_index + 1 (n for 1st, n-1 for 2nd, ... 1 for last) -/// Weight is scaled by 10 (e.g., 10 = 1.0, 25 = 2.5, 100 = 10.0) -/// Returns share in basis points -fn calculate_linear_share( - payout_index: u32, total_payouts: u32, available_share: u16, weight: u16, -) -> u16 { - // For linear distribution: - // positionValue = total_payouts - payout_index + 1 - // share = 1 + (positionValue - 1) * (weight / 10) - // - // Examples with weight = 10 (1.0): - // - 1st place (index 1): positionValue = n, share = 1 + (n-1) * 1.0 = n - // - 2nd place (index 2): positionValue = n-1, share = 1 + (n-2) * 1.0 = n-1 - // - Last place (index n): positionValue = 1, share = 1 + 0 * 1.0 = 1 +/// Unnormalized per-position weights for the weighted distributions, in payout +/// order (element 0 = payout index 1), together with their sum. +/// +/// Both weighted distributions share the same shape — a raw weight per +/// position, normalized by the sum of all of them — and the sum is what makes +/// a single share cost O(n). Materializing the vector once lets every caller +/// (single share, total, dust) pay that O(n) exactly once instead of per +/// position. +/// +/// Weights are accumulated ascending (position 1 → n) and computed with the +/// same expressions as before this was hoisted, so the fixed-point results are +/// bit-identical to the per-share implementations they replaced. +/// +/// Linear: weight = 1 + (n - p) * (weight/10) +/// Exponential: weight = ((n - (p-1)) / n) ^ (weight/10) +/// +/// Weight is scaled by 10 (e.g., 10 = 1.0, 25 = 2.5, 100 = 10.0). +fn weight_vector(distribution: Distribution, total_payouts: u32) -> (Array, Fixed) { + let mut weights: Array = array![]; + let mut denominator = FixedTrait::ZERO(); let n: u32 = total_payouts; + match distribution { + Distribution::Linear(weight) => { + // positionValue = n - p + 1, so share = 1 + (positionValue - 1) * (weight / 10) + // + // Examples with weight = 10 (1.0): + // - 1st place (p = 1): positionValue = n, weight = 1 + (n-1) * 1.0 = n + // - 2nd place (p = 2): positionValue = n-1, weight = 1 + (n-2) * 1.0 = n-1 + // - Last place (p = n): positionValue = 1, weight = 1 + 0 * 1.0 = 1 + let weight_fp = FixedTrait::new((weight.into() * ONE) / 10, false); + let one_fp = FixedTrait::new_unscaled(1, false); + let mut p: u32 = 1; + loop { + if p > n { + break; + } + let pos_minus_one_fp = FixedTrait::new_unscaled((n - p).into(), false); + let w = one_fp + (pos_minus_one_fp * weight_fp); + denominator = denominator + w; + weights.append(w); + p += 1; + } + }, + Distribution::Exponential(weight) => { + // For payout index p (1-indexed), (1 - (p-1)/n)^(weight/10) — + // (p-1) so that payout index 1 (the winner) keeps full weight. + let weight_fp = FixedTrait::new((weight.into() * ONE) / 10, false); + let n_u64: u64 = n.into(); + let denominator_fp = FixedTrait::new_unscaled(n_u64, false); + let mut p: u32 = 1; + loop { + if p > n { + break; + } + let pi: u64 = (p - 1).into(); + let num_fp = FixedTrait::new_unscaled(n_u64 - pi, false); + let base_fp = num_fp / denominator_fp; + let w = base_fp.pow(weight_fp); + denominator = denominator + w; + weights.append(w); + p += 1; + } + }, + // Uniform and Custom are not normalized against a weight sum — they + // have their own O(1) share functions and never reach here. + Distribution::Uniform | Distribution::Custom(_) => {}, + } - // Calculate positionValue = n - payout_index + 1 - let position_value: u32 = n - payout_index + 1; - - // Calculate share = 1 + (position_value - 1) * (weight / 10) - // Using fixed-point to handle fractional weights - let weight_fp = FixedTrait::new((weight.into() * ONE) / 10, false); - let position_minus_one_fp = FixedTrait::new_unscaled((position_value - 1).into(), false); - let one_fp = FixedTrait::new_unscaled(1, false); - - // share_value = 1 + (position_value - 1) * (weight / 10) - let share_value_fp = one_fp + (position_minus_one_fp * weight_fp); + (weights, denominator) +} - // Calculate total shares for all positions - let mut total_shares_fp = FixedTrait::ZERO(); - let mut pos: u32 = 1; - loop { - if pos > n { - break; - } - let pos_value: u32 = n - pos + 1; - let pos_minus_one_fp = FixedTrait::new_unscaled((pos_value - 1).into(), false); - let pos_share_fp = one_fp + (pos_minus_one_fp * weight_fp); - total_shares_fp = total_shares_fp + pos_share_fp; - pos += 1; +/// One position's share in basis points, read off a prebuilt weight vector. +/// `payout_index` is 1-indexed. Returns 0 when out of range. +fn share_at( + weights: @Array, denominator: Fixed, payout_index: u32, available_share: u16, +) -> u16 { + if payout_index == 0 || payout_index > weights.len() || denominator == FixedTrait::ZERO() { + return 0; } - // Calculate this position's share: (share_value / total_shares) * available_share - let ratio_fp = share_value_fp / total_shares_fp; + let weight_fp: Fixed = *weights.at(payout_index - 1); + let ratio_fp = weight_fp / denominator; let available_fp = FixedTrait::new_unscaled(available_share.into(), false); - let final_share_fp = ratio_fp * available_fp; + let share_fp = ratio_fp * available_fp; - // Convert back to u16 - let share_u64: u64 = final_share_fp.try_into().unwrap_or(0); + let share_u64: u64 = share_fp.try_into().unwrap_or(0); share_u64.try_into().unwrap_or(0) } -/// Calculate exponential distribution using the formula: -/// raw_share = available * (1 - (i-1)/positions)^(weight/10) -/// Weight is scaled by 10 (e.g., 10 = 1.0, 25 = 2.5, 100 = 10.0) -/// Then normalize all shares to sum to available_share -/// Returns share in basis points -fn calculate_exponential_share( - payout_index: u32, total_payouts: u32, available_share: u16, weight: u16, -) -> u16 { - // For payout_index i (1-indexed), calculate (1 - (i-1)/n)^(weight/10) - // where i-1 because payout_index 1 (winner) should get full weight - let i: u64 = (payout_index - 1).into(); - let n: u64 = total_payouts.into(); - - // Convert weight to fixed-point: weight / 10 - let weight_fp = FixedTrait::new((weight.into() * ONE) / 10, false); // (weight * ONE) / 10 - - // Calculate base = (1 - i/n) = (n - i) / n in fixed-point - let numerator_fp = FixedTrait::new_unscaled(n - i, false); - let denominator_fp = FixedTrait::new_unscaled(n, false); - let base_fp = numerator_fp / denominator_fp; - - // Calculate base^(weight/10) using Cubit's pow - let raw_share_fp = base_fp.pow(weight_fp); - - // Now we need to normalize: calculate total of all raw shares - let mut total_raw_fp = FixedTrait::ZERO(); +/// Sum of every position's share, truncation included — i.e. what actually +/// gets paid out, which is `available_share` minus the dust. +fn sum_shares(weights: @Array, denominator: Fixed, available_share: u16) -> u16 { + let mut total: u16 = 0; let mut p: u32 = 1; + let len = weights.len(); loop { - if p > total_payouts { + if p > len { break; } - let pi: u64 = (p - 1).into(); - let num_fp = FixedTrait::new_unscaled(n - pi, false); - let base_p_fp = num_fp / denominator_fp; - total_raw_fp = total_raw_fp + base_p_fp.pow(weight_fp); + total += share_at(weights, denominator, p, available_share); p += 1; } - - // Calculate this payout's share of available_share - let ratio_fp = raw_share_fp / total_raw_fp; - let available_fp = FixedTrait::new_unscaled(available_share.into(), false); - let share_fp = ratio_fp * available_fp; - - // Convert back to u16 - let share_u64: u64 = share_fp.try_into().unwrap_or(0); - share_u64.try_into().unwrap_or(0) + total } /// Calculate uniform distribution - all payouts get equal share diff --git a/packages/utilities/src/distribution/tests.cairo b/packages/utilities/src/distribution/tests.cairo new file mode 100644 index 00000000..48f7fe3a --- /dev/null +++ b/packages/utilities/src/distribution/tests.cairo @@ -0,0 +1,2 @@ +mod test_gas_benchmark; +mod test_share_regression; diff --git a/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo b/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo new file mode 100644 index 00000000..12f2630d --- /dev/null +++ b/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Gas benchmark for distribution share computation. +//! +//! These tests assert nothing about *values* — the correctness suite in +//! `calculator.cairo` owns that. They exist so `snforge test gas_benchmark` +//! reports a comparable `l2_gas` figure per shape, making the cost curve of +//! `calculate_share` / `calculate_share_with_dust` visible in CI. +//! +//! Read the numbers as: cost(bench_X) - cost(bench_baseline_overhead). +//! +//! The shapes that matter, and why: +//! +//! * `pos1` vs `pos_last` — payout index 1 additionally pays for +//! `calculate_dust`, which sums *every* position's share. Winners are the +//! expensive claim; everyone else is cheap. +//! * `w10` vs `w15` — `FixedTrait::pow` short-circuits to `pow_int` when the +//! exponent is a whole number. Weight 10 (= 1.0) takes that fast path; +//! weight 15 (= 1.5) falls through to `exp(y * ln(x))`, which is roughly an +//! order of magnitude dearer per call. +//! * growing `n` — the per-share normalization loop is O(n), so a winner's +//! claim that re-derives it per position is O(n^2). +//! +//! `exp_w10_n10_pos1` is the live shape behind Budokan tournament 26 +//! (Exponential weight 10, 10 paid places, single entrant claiming 1st). + +use crate::distribution::calculator::{calculate_share, calculate_share_with_dust}; +use crate::distribution::structs::{BASIS_POINTS, Distribution}; + +// ========================================================================== +// BASELINE — harness overhead with no share computation +// ========================================================================== + +#[test] +fn bench_baseline_overhead() { + let dist = Distribution::Uniform; + let share = calculate_share(dist, 1, 1, BASIS_POINTS); + assert!(share == BASIS_POINTS, "single uniform payout takes everything"); +} + +// ========================================================================== +// EXPONENTIAL, WEIGHT 10 (1.0 — integer exponent, pow_int fast path) +// ========================================================================== + +#[test] +fn bench_exp_w10_n10_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 10, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_exp_w10_n10_pos_last() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 10, 10, BASIS_POINTS); + assert!(share > 0, "last-place share"); +} + +#[test] +fn bench_exp_w10_n25_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 25, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_exp_w10_n50_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 50, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_exp_w10_n50_pos_last() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 50, 50, BASIS_POINTS); + assert!(share > 0, "last-place share"); +} + +// ========================================================================== +// EXPONENTIAL, WEIGHT 15 (1.5 — fractional exponent, exp(y * ln(x)) path) +// ========================================================================== + +#[test] +fn bench_exp_w15_n10_pos1() { + let dist = Distribution::Exponential(15); + let share = calculate_share_with_dust(dist, 1, 10, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_exp_w15_n10_pos_last() { + let dist = Distribution::Exponential(15); + let share = calculate_share_with_dust(dist, 10, 10, BASIS_POINTS); + assert!(share > 0, "last-place share"); +} + +#[test] +fn bench_exp_w15_n25_pos1() { + let dist = Distribution::Exponential(15); + let share = calculate_share_with_dust(dist, 1, 25, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +// ========================================================================== +// LINEAR (fixed-point multiplies, no pow) +// ========================================================================== + +#[test] +fn bench_linear_w10_n10_pos1() { + let dist = Distribution::Linear(10); + let share = calculate_share_with_dust(dist, 1, 10, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_linear_w10_n10_pos_last() { + let dist = Distribution::Linear(10); + let share = calculate_share_with_dust(dist, 10, 10, BASIS_POINTS); + assert!(share > 0, "last-place share"); +} + +#[test] +fn bench_linear_w10_n50_pos1() { + let dist = Distribution::Linear(10); + let share = calculate_share_with_dust(dist, 1, 50, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +// ========================================================================== +// UNIFORM / CUSTOM — the O(1) distributions, for scale +// ========================================================================== + +#[test] +fn bench_uniform_n50_pos1() { + let dist = Distribution::Uniform; + let share = calculate_share_with_dust(dist, 1, 50, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_custom_n10_pos1() { + let shares: Array = array![5000, 2000, 1000, 500, 500, 250, 250, 250, 150, 100]; + let dist = Distribution::Custom(shares.span()); + let share = calculate_share_with_dust(dist, 1, 10, BASIS_POINTS); + assert!(share > 0, "winner share"); +} diff --git a/packages/utilities/src/distribution/tests/test_share_regression.cairo b/packages/utilities/src/distribution/tests/test_share_regression.cairo new file mode 100644 index 00000000..94782450 --- /dev/null +++ b/packages/utilities/src/distribution/tests/test_share_regression.cairo @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Locked share values for the weighted distributions. +//! +//! These are real payout splits — a live tournament's prize is divided by +//! exactly these basis points. The correctness tests elsewhere assert +//! *ranges* ("~50%"), which is the right shape for "is the curve sane" but +//! would not catch a refactor that shifts a share by a few bps. This file +//! pins the exact fixed-point output. +//! +//! If a change here fails, that is not a test to update — it means payouts +//! moved. Existing tournaments were created against these numbers, so a +//! deliberate change needs a migration story, not a new expected value. +//! +//! Values verified identical before and after the O(n^2) -> O(n) hoist in +//! `calculator.cairo` by sweeping the old and new implementations against each +//! other across every payout index of every size 1..=12, for Linear (weights +//! 10/25/7), Exponential (weights 10/15/25/100) and Uniform, at both full and +//! partial `available_share`. + +use crate::distribution::calculator::{calculate_share, calculate_share_with_dust, calculate_total}; +use crate::distribution::structs::{BASIS_POINTS, Distribution}; + +/// Exponential weight 10 over 10 places — the shape used by live Budokan +/// tournaments (and the one benchmarked in `test_gas_benchmark`). +#[test] +fn test_exponential_w10_n10_exact_shares() { + let dist = Distribution::Exponential(10); + let expected = array![1818_u16, 1636, 1454, 1272, 1090, 909, 727, 545, 363, 181]; + + let mut p: u32 = 1; + for e in expected { + assert!( + calculate_share(dist, p, 10, BASIS_POINTS) == e, + "exp w10 n10 position {} share changed", + p, + ); + p += 1; + } + + // Truncation leaves 5 bps unallocated; the winner absorbs it. + assert!(calculate_total(dist, 10, BASIS_POINTS) == 9995, "exp w10 n10 total"); + assert!( + calculate_share_with_dust(dist, 1, 10, BASIS_POINTS) == 1823, "exp w10 n10 winner + dust", + ); +} + +/// Fractional weight — exercises the `exp(y * ln(x))` branch of `pow`. +#[test] +fn test_exponential_w15_n5_exact_shares() { + let dist = Distribution::Exponential(15); + let expected = array![3964_u16, 2836, 1842, 1002, 354]; + + let mut p: u32 = 1; + for e in expected { + assert!( + calculate_share(dist, p, 5, BASIS_POINTS) == e, + "exp w15 n5 position {} share changed", + p, + ); + p += 1; + } + + assert!(calculate_total(dist, 5, BASIS_POINTS) == 9998, "exp w15 n5 total"); + assert!( + calculate_share_with_dust(dist, 1, 5, BASIS_POINTS) == 3966, "exp w15 n5 winner + dust", + ); +} + +#[test] +fn test_linear_w10_n5_exact_shares() { + let dist = Distribution::Linear(10); + let expected = array![3333_u16, 2666, 1999, 1333, 666]; + + let mut p: u32 = 1; + for e in expected { + assert!( + calculate_share(dist, p, 5, BASIS_POINTS) == e, + "linear w10 n5 position {} share changed", + p, + ); + p += 1; + } + + assert!(calculate_total(dist, 5, BASIS_POINTS) == 9997, "linear w10 n5 total"); + assert!( + calculate_share_with_dust(dist, 1, 5, BASIS_POINTS) == 3336, "linear w10 n5 winner + dust", + ); +} + +#[test] +fn test_linear_w25_n4_exact_shares() { + let dist = Distribution::Linear(25); + let expected = array![4473_u16, 3157, 1842, 526]; + + let mut p: u32 = 1; + for e in expected { + assert!( + calculate_share(dist, p, 4, BASIS_POINTS) == e, + "linear w25 n4 position {} share changed", + p, + ); + p += 1; + } + + assert!(calculate_total(dist, 4, BASIS_POINTS) == 9998, "linear w25 n4 total"); + assert!( + calculate_share_with_dust(dist, 1, 4, BASIS_POINTS) == 4475, "linear w25 n4 winner + dust", + ); +} + +/// Dust is only ever added to payout index 1, and always closes the gap to +/// `available_share` exactly. +#[test] +fn test_dust_closes_the_gap_exactly() { + let dists = array![ + Distribution::Linear(10), Distribution::Linear(25), Distribution::Exponential(10), + Distribution::Exponential(15), + ]; + + for dist in dists { + let mut n: u32 = 1; + while n <= 8 { + let mut paid: u16 = calculate_share_with_dust(dist, 1, n, BASIS_POINTS); + let mut p: u32 = 2; + while p <= n { + paid += calculate_share_with_dust(dist, p, n, BASIS_POINTS); + p += 1; + } + assert!(paid == BASIS_POINTS, "shares + dust must total 100% at n={}", n); + n += 1; + } + }; +} + +/// A zero-position distribution hands the whole share to payout index 1 as +/// dust. Preserved deliberately from the pre-hoist implementation — callers +/// (Budokan's `_claim_distributed_prize`) can reach this with an empty +/// leaderboard and no configured `distribution_count`. +#[test] +fn test_zero_payouts_gives_everything_to_index_one_as_dust() { + let dists = array![Distribution::Linear(10), Distribution::Exponential(10)]; + for dist in dists { + assert!( + calculate_share(dist, 1, 0, BASIS_POINTS) == 0, "no share when there are no places", + ); + assert!(calculate_total(dist, 0, BASIS_POINTS) == 0, "no total when there are no places"); + assert!( + calculate_share_with_dust(dist, 1, 0, BASIS_POINTS) == BASIS_POINTS, + "index 1 absorbs the full share as dust", + ); + }; +} From 338a490c744e846039e547969d6157d546828db9 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:27:59 -0700 Subject: [PATCH 02/11] test(distribution): benchmark the allocation crossover and large-n ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hoist trades `pow` calls for an `Array` the per-position implementation never allocated. Paths that were not quadratic get that allocation for nothing, so measure where it stops paying instead of assuming it always does. Adds benchmarks at n = 1/2/3 (where n^2 and n are the same order), at n = 100/200/1000 for non-winner positions (allocation against no saving), and at n = 500/1000 for winners, plus a Custom n=1000 comparison point. Measured, l2_gas: Winner (pos 1) before after ratio n=1 486,780 294,980 1.7x n=2 952,540 450,150 2.1x n=3 1,581,000 605,320 2.6x n=500 20,448,538,720 77,724,810 263x n=1000 did not complete 155,309,810 - Non-winner (pos n) before after delta n=3 407,990 373,370 -8.5% n=10 exp 977,440 951,220 -2.7% n=10 linear 786,580 686,620 -12.7% n=50 4,231,440 4,253,220 +0.5% n=100 8,298,940 8,380,720 +1.0% n=200 exp 16,433,940 16,635,720 +1.2% n=1000 exp 81,513,940 82,675,720 +1.4% So the allocation is never the dominant term. The winner path wins at every size including n=1. Exponential non-winners cross over around n=25 and pay at most +1.4% at n=1000 — set against 6x-263x on the path that actually blocked claims. Linear non-winners stay ahead throughout (the old code computed its weight n+1 times, the vector computes it n). Also documents a precision ceiling found while benchmarking, which is a property of the u16 basis-point representation and predates this change (values are bit-identical before and after): a position whose normalized weight falls below 1/10000 of the pool truncates to a 0 bps share, and Budokan asserts `prize_amount > 0`, so that position can never be claimed. Last place hits zero at n=141 for both Linear(10) and Exponential(10), and at n=20 for the steeper Exponential(25). The n200/n1000 tail benchmarks assert bounds rather than non-zero for this reason. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_gas_benchmark.cairo | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo b/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo index 12f2630d..99b1284d 100644 --- a/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo +++ b/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo @@ -23,6 +23,16 @@ //! //! `exp_w10_n10_pos1` is the live shape behind Budokan tournament 26 //! (Exponential weight 10, 10 paid places, single entrant claiming 1st). +//! +//! Precision note, relevant to the large-`n` benchmarks: shares are u16 basis +//! points, so a position whose normalized weight is below 1/10000 of the pool +//! truncates to 0. For Linear weight 10 that starts around 140 places (the +//! weights sum to n(n+1)/2, so the last place gets 10000 * 1 / 20100 = 0 at +//! n=200); Exponential tails off sooner still. This is a property of the +//! basis-point representation, not of any particular implementation — but it +//! means a sufficiently large weighted prize has positions that can never be +//! claimed, because Budokan asserts `prize_amount > 0`. Hence the relaxed +//! assertions on the `n200`/`n1000` tail benchmarks. use crate::distribution::calculator::{calculate_share, calculate_share_with_dust}; use crate::distribution::structs::{BASIS_POINTS, Distribution}; @@ -102,6 +112,119 @@ fn bench_exp_w15_n25_pos1() { assert!(share > 0, "winner share"); } +// ========================================================================== +// ALLOCATION CROSSOVER +// +// The weight vector is materialized into an `Array`, which the +// per-position implementation never did. That allocation buys nothing on +// paths that were not quadratic to begin with: +// +// * non-winner positions — they never summed every share, so they pay +// append + read cost against no saving; +// * very small `n` — n^2 and n are the same order when n is 1 or 2. +// +// These benchmarks bracket both, so the crossover is measured rather than +// assumed. Compare against the same shapes on the pre-hoist implementation. +// ========================================================================== + +#[test] +fn bench_exp_w10_n1_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 1, BASIS_POINTS); + assert!(share > 0, "sole payout"); +} + +#[test] +fn bench_exp_w10_n2_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 2, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_exp_w10_n3_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 3, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_exp_w10_n3_pos_last() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 3, 3, BASIS_POINTS); + assert!(share > 0, "last-place share"); +} + +#[test] +fn bench_exp_w10_n100_pos_last() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 100, 100, BASIS_POINTS); + assert!(share > 0, "last-place share"); +} + +#[test] +fn bench_exp_w10_n200_pos_last() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 200, 200, BASIS_POINTS); + // The tail rounds to 0 bps at this size — see the precision note above. + assert!(share <= BASIS_POINTS, "share must stay within basis points"); +} + +#[test] +fn bench_linear_w10_n200_pos_last() { + let dist = Distribution::Linear(10); + let share = calculate_share_with_dust(dist, 200, 200, BASIS_POINTS); + // The tail rounds to 0 bps at this size — see the precision note above. + assert!(share <= BASIS_POINTS, "share must stay within basis points"); +} + +// ========================================================================== +// CEILING — very large paid-place counts +// +// `distribution_count` is a u32, so nothing in the type system stops a +// 1000-place prize. These pin what the winner's claim actually costs there, +// which is the number to check a transaction budget against before allowing +// a tournament to be created with that shape. +// ========================================================================== + +#[test] +fn bench_exp_w10_n500_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 500, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_exp_w10_n1000_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 1000, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_exp_w10_n1000_pos_last() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1000, 1000, BASIS_POINTS); + // The tail rounds to 0 bps at this size — see the precision note above. + assert!(share <= BASIS_POINTS, "share must stay within basis points"); +} + +#[test] +fn bench_custom_n1000_pos1() { + // Custom is the O(1) comparison point: an explicit shares array, no + // normalization, no pow — what a 1000-place prize costs when the curve + // is precomputed off-chain instead of derived on-chain. + let mut shares: Array = array![]; + let mut i: u32 = 0; + while i < 1000 { + shares.append(10); + i += 1; + } + let dist = Distribution::Custom(shares.span()); + let share = calculate_share_with_dust(dist, 1, 1000, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + // ========================================================================== // LINEAR (fixed-point multiplies, no pow) // ========================================================================== From cc04cfec07468407cf703ba2749000c906ec5748 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:39:59 -0700 Subject: [PATCH 03/11] test(distribution): benchmark 100 paid places against Custom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100 places is a shape we want to support, so pin what it costs and what it costs with the curve precomputed off-chain instead. l2_gas, 100 places Exponential w10 Custom winner (pos 1) 15,656,810 4,740,820 other (pos n) 8,380,720 1,097,550 Every position pays O(n) because each claim rebuilds the weight vector, so paying out all 100 costs ~845M l2_gas of share math (15.7M + 99 x 8.4M) versus ~113M for Custom — before any transfer or storage cost. Custom needs 7 packed felt slots for 100 shares and does no `pow` at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_gas_benchmark.cairo | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo b/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo index 99b1284d..541d74e4 100644 --- a/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo +++ b/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo @@ -268,3 +268,36 @@ fn bench_custom_n10_pos1() { let share = calculate_share_with_dust(dist, 1, 10, BASIS_POINTS); assert!(share > 0, "winner share"); } + +#[test] +fn bench_exp_w10_n100_pos1() { + let dist = Distribution::Exponential(10); + let share = calculate_share_with_dust(dist, 1, 100, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_custom_n100_pos1() { + let mut shares: Array = array![]; + let mut i: u32 = 0; + while i < 100 { + shares.append(100); + i += 1; + } + let dist = Distribution::Custom(shares.span()); + let share = calculate_share_with_dust(dist, 1, 100, BASIS_POINTS); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_custom_n100_pos_last() { + let mut shares: Array = array![]; + let mut i: u32 = 0; + while i < 100 { + shares.append(100); + i += 1; + } + let dist = Distribution::Custom(shares.span()); + let share = calculate_share_with_dust(dist, 100, 100, BASIS_POINTS); + assert!(share > 0, "last-place share"); +} From 93c027f34659ce97399af20909e7ace3ff25e5c1 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:55:30 -0700 Subject: [PATCH 04/11] feat(distribution): exact O(1) payouts in token units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `distribution::payout`, computing a position's payout directly: payout(p) = total_amount * W(p) / sum(W) in u256 integer arithmetic, where W is an exact integer weight and sum(W) is closed form. The basis-point path in `calculator` stays as-is for prizes already escrowed against it. Three problems go away at once. **Cost stops depending on the size of the field.** No loop, no allocation, no Cubit `pow`. Measured l2_gas, winner's payout: places calculator payout 10 1,691,510 228,550 100 15,656,810 228,550 1000 155,309,810 228,550 Identical at every size, and identical for the last position as for the first (k=3 costs 367,150; Linear 273,630 — the shape of the weight, not the size of the field). Distributing 100 places drops from ~845M l2_gas of share math to ~23M. **Positions stop being unclaimable.** A u16 basis point is 1/10000 of the pool, so a steep curve over a large field gives late positions exactly 0 — and Budokan asserts `prize_amount > 0`, so those players cannot claim at all. Measured: Exponential(2.5) loses the tail from 20 places, Linear(1.0) from 141. In token units the floor is 1 wei; `test_no_zero_payouts_where_basis_points_die` pins a 100-place k=3 curve that starves under basis points and pays everyone here. **Dust stops being load-bearing.** Basis-point truncation strands up to n/10000 of the pool — 0.5% of a 100-place prize, silently redirected to first place. Here the remainder is under n wei, so there is nothing worth redistributing, and payout index 1 no longer has to sum every position to find it. That is what makes the winner O(1) like everyone else. Weights (any common scale cancels, so they stay small integers): Linear(w) W(p) = 10 + (n-p)*w sum = 10n + w*n(n-1)/2 Exponential(w) W(p) = (n-p+1)^k sum = Faulhaber(k, n) Uniform W(p) = 1 sum = n Custom(shares) W(p) = shares[p-1] sum = sum(shares) `Exponential` is a power law rather than a true exponential — W(p) is polynomial in the position. That is what makes this possible: a power sum has an exact integer closed form, where geometric decay would need r^n in rationals and overflows u256 past a few dozen places. Fractional exponents (1.5, 2.5) have no closed-form power sum. `supports_exact_payout` reports them, and `calculate_payout` panics rather than quietly substituting a different curve; those distributions stay on the basis-point path. Integer exponents are capped at 5 — a k=3 curve over 100 places already pays first place a million times last. Curve values are unchanged: `test_matches_the_basis_point_curve` shows a 10000-unit pool reproduces `calculate_share` to within the 1 bps the fixed-point implementation itself loses to rounding. Co-Authored-By: Claude Opus 5 (1M context) --- packages/utilities/src/distribution.cairo | 1 + .../utilities/src/distribution/payout.cairo | 209 +++++++++++++++ .../utilities/src/distribution/tests.cairo | 1 + .../tests/test_gas_benchmark.cairo | 54 ++++ .../src/distribution/tests/test_payout.cairo | 249 ++++++++++++++++++ 5 files changed, 514 insertions(+) create mode 100644 packages/utilities/src/distribution/payout.cairo create mode 100644 packages/utilities/src/distribution/tests/test_payout.cairo diff --git a/packages/utilities/src/distribution.cairo b/packages/utilities/src/distribution.cairo index e3e3cca0..54fc54ed 100644 --- a/packages/utilities/src/distribution.cairo +++ b/packages/utilities/src/distribution.cairo @@ -1,5 +1,6 @@ pub mod calculator; pub mod packed_shares; +pub mod payout; pub mod structs; #[cfg(test)] diff --git a/packages/utilities/src/distribution/payout.cairo b/packages/utilities/src/distribution/payout.cairo new file mode 100644 index 00000000..0424deab --- /dev/null +++ b/packages/utilities/src/distribution/payout.cairo @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Exact payout computation in token units. +//! +//! `calculator` derives a basis-point share and leaves the caller to multiply +//! it by the pool. That costs precision twice over: the share is computed in +//! 32.32 fixed point (Cubit `pow`, `exp`, `ln`), and it is then truncated into +//! a u16 whose resolution is 1/10000 of the pool. +//! +//! This module computes the payout directly: +//! +//! payout(p) = total_amount * W(p) / sum(W) +//! +//! in u256 integer arithmetic, where `W` is an exact integer weight and +//! `sum(W)` has a closed form. Three consequences: +//! +//! 1. **O(1), independent of the number of paid places.** No per-position +//! loop, no allocation, no `pow`. A 1000-place prize costs the same as a +//! 2-place one. +//! 2. **No unclaimable positions.** The smallest payout is 1 wei rather than +//! 1 basis point, so a tail position only rounds away when its true share +//! is under one indivisible unit of the token. Under the basis-point path, +//! a steep 100-place curve silently gives late positions exactly 0 — and +//! Budokan asserts `prize_amount > 0`, so those players cannot claim at +//! all. +//! 3. **Dust stops mattering.** Basis-point truncation strands up to +//! `n / 10000` of the pool (0.5% of a 100-place prize), which is why +//! `calculate_share_with_dust` exists and why payout index 1 has to sum +//! every position. Here the truncation remainder is under `n` wei, so +//! there is nothing worth redistributing and every position — winner +//! included — is a flat O(1) computation. +//! +//! ## Weight definitions +//! +//! Weights only ever appear as the ratio `W(p) / sum(W)`, so any common +//! scale factor cancels and they can be kept as small integers. +//! +//! | Distribution | W(p) | sum(W) | +//! | ----------------- | ------------------- | ---------------------------- | +//! | `Linear(w)` | `10 + (n - p) * w` | `10n + w * n(n-1)/2` | +//! | `Exponential(w)` | `(n - p + 1)^k` | `sum_{j=1..n} j^k` (Faulhaber)| +//! | `Uniform` | `1` | `n` | +//! | `Custom(shares)` | `shares[p-1]` | `sum(shares)` | +//! +//! `w` is the weight scaled by 10 (so `10` = 1.0), matching `calculator`. +//! `k = w / 10` — see `supports_exact_payout` for which weights qualify. +//! +//! ## Naming note +//! +//! `Exponential` is a power law, not an exponential: `W(p)` is polynomial in +//! the position, `(n-p+1)^k`, not `r^p`. That is a happy accident — a power +//! law has an exact closed-form sum (Faulhaber) in integers, whereas a true +//! geometric decay needs `r^n` in rationals and overflows or needs fixed +//! point. The existing curve shape is the one that computes cheaply. + +use crate::distribution::structs::Distribution; + +/// Highest supported integer exponent for `Exponential`. +/// +/// Faulhaber closed forms are enumerated up to this power. Prize curves in +/// practice sit at k = 1..3 (a k=3 curve over 100 places already pays first +/// place a million times last place); the cap keeps `W(p) * total_amount` +/// clear of u256 overflow for any realistic pool. +pub const MAX_EXACT_EXPONENT: u32 = 5; + +/// Whether `calculate_payout` can compute this distribution exactly. +/// +/// `Exponential` qualifies when its weight is a whole multiple of 10 (an +/// integer exponent) no greater than `MAX_EXACT_EXPONENT`. Fractional +/// exponents such as 1.5 have no closed-form power sum, so they stay on the +/// `calculator` path. `Linear`, `Uniform` and `Custom` always qualify. +pub fn supports_exact_payout(distribution: Distribution) -> bool { + match distribution { + Distribution::Exponential(weight) => { + let w: u32 = weight.into(); + w % 10 == 0 && w != 0 && (w / 10) <= MAX_EXACT_EXPONENT + }, + _ => true, + } +} + +/// sum_{j=1..n} j^k for k in 1..=MAX_EXACT_EXPONENT (Faulhaber). +/// +/// Every expression below is an integer identity, so multiplying before +/// dividing is exact — the numerators are divisible by the constants shown. +fn power_sum(k: u32, n: u256) -> u256 { + let n1 = n + 1; + if k == 1 { + n * n1 / 2 + } else if k == 2 { + n * n1 * (2 * n + 1) / 6 + } else if k == 3 { + n * n * n1 * n1 / 4 + } else if k == 4 { + n * n1 * (2 * n + 1) * (3 * n * n + 3 * n - 1) / 30 + } else if k == 5 { + n * n * n1 * n1 * (2 * n * n + 2 * n - 1) / 12 + } else { + panic!("Distribution: exponent {} exceeds the exact payout range", k) + } +} + +fn int_pow(base: u256, exponent: u32) -> u256 { + let mut acc: u256 = 1; + let mut i: u32 = 0; + while i < exponent { + acc = acc * base; + i += 1; + } + acc +} + +/// The unnormalized integer weight of one payout position, 1-indexed. +/// Returns 0 when `payout_index` falls outside 1..=total_payouts. +pub fn payout_weight(distribution: Distribution, payout_index: u32, total_payouts: u32) -> u256 { + if payout_index == 0 || payout_index > total_payouts { + return 0; + } + + match distribution { + Distribution::Linear(weight) => { + let w: u256 = weight.into(); + 10 + (total_payouts - payout_index).into() * w + }, + Distribution::Exponential(weight) => { + let k: u32 = weight.into() / 10; + int_pow((total_payouts - payout_index + 1).into(), k) + }, + Distribution::Uniform => 1, + Distribution::Custom(shares) => { + let index: u32 = payout_index - 1; + if index >= shares.len() { + 0 + } else { + let share: u16 = *shares.at(index); + share.into() + } + }, + } +} + +/// The sum of every position's weight — the normalization denominator. +/// +/// Closed form for `Linear`, `Exponential` and `Uniform`, so this is O(1). +/// `Custom` sums its explicit array, which is inherent to the variant. +pub fn payout_weight_sum(distribution: Distribution, total_payouts: u32) -> u256 { + if total_payouts == 0 { + return 0; + } + let n: u256 = total_payouts.into(); + + match distribution { + Distribution::Linear(weight) => { + // sum_{p=1..n} [10 + (n - p) * w] = 10n + w * n(n-1)/2 + let w: u256 = weight.into(); + 10 * n + w * (n * (n - 1) / 2) + }, + Distribution::Exponential(weight) => { + // sum_{p=1..n} (n - p + 1)^k = sum_{j=1..n} j^k + let k: u32 = weight.into() / 10; + power_sum(k, n) + }, + Distribution::Uniform => n, + Distribution::Custom(shares) => { + let mut total: u256 = 0; + let mut i: u32 = 0; + let len = shares.len(); + while i < len { + let share: u16 = *shares.at(i); + total += share.into(); + i += 1; + } + total + }, + } +} + +/// The payout for one position, in token units. +/// +/// `payout_index` is 1-indexed; `total_amount` is the whole pool being +/// distributed across `total_payouts` positions. Returns 0 for an +/// out-of-range index. +/// +/// Truncation is downward, so the payouts sum to at most `total_amount`; the +/// shortfall is under `total_payouts` indivisible units and is deliberately +/// not redistributed (see the module docs on dust). +/// +/// Panics for a distribution `supports_exact_payout` rejects — callers must +/// check first rather than silently receiving a different curve. +pub fn calculate_payout( + distribution: Distribution, payout_index: u32, total_payouts: u32, total_amount: u256, +) -> u256 { + assert!( + supports_exact_payout(distribution), + "Distribution: weight has no exact payout form; use the basis-point path", + ); + + if payout_index == 0 || payout_index > total_payouts || total_amount == 0 { + return 0; + } + + let denominator = payout_weight_sum(distribution, total_payouts); + if denominator == 0 { + return 0; + } + + let weight = payout_weight(distribution, payout_index, total_payouts); + total_amount * weight / denominator +} diff --git a/packages/utilities/src/distribution/tests.cairo b/packages/utilities/src/distribution/tests.cairo index 48f7fe3a..c3b9a2cc 100644 --- a/packages/utilities/src/distribution/tests.cairo +++ b/packages/utilities/src/distribution/tests.cairo @@ -1,2 +1,3 @@ mod test_gas_benchmark; +mod test_payout; mod test_share_regression; diff --git a/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo b/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo index 541d74e4..85fb6c70 100644 --- a/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo +++ b/packages/utilities/src/distribution/tests/test_gas_benchmark.cairo @@ -35,8 +35,12 @@ //! assertions on the `n200`/`n1000` tail benchmarks. use crate::distribution::calculator::{calculate_share, calculate_share_with_dust}; +use crate::distribution::payout::calculate_payout; use crate::distribution::structs::{BASIS_POINTS, Distribution}; +/// 1000 tokens at 18 decimals. +const POOL: u256 = 1000_000000000000000000; + // ========================================================================== // BASELINE — harness overhead with no share computation // ========================================================================== @@ -301,3 +305,53 @@ fn bench_custom_n100_pos_last() { let share = calculate_share_with_dust(dist, 100, 100, BASIS_POINTS); assert!(share > 0, "last-place share"); } + +// ========================================================================== +// EXACT TOKEN-UNIT PAYOUTS (distribution::payout) +// +// Closed-form weight sum, integer arithmetic, no pow and no per-position +// loop — so cost is flat in the number of paid places. Compare against the +// `exp_w10_nX_pos1` figures above, which grow linearly. +// ========================================================================== + +#[test] +fn bench_payout_exp_k1_n10_pos1() { + let p = calculate_payout(Distribution::Exponential(10), 1, 10, POOL); + assert!(p > 0, "payout"); +} + +#[test] +fn bench_payout_exp_k1_n100_pos1() { + let p = calculate_payout(Distribution::Exponential(10), 1, 100, POOL); + assert!(p > 0, "payout"); +} + +#[test] +fn bench_payout_exp_k1_n1000_pos1() { + let p = calculate_payout(Distribution::Exponential(10), 1, 1000, POOL); + assert!(p > 0, "payout"); +} + +#[test] +fn bench_payout_exp_k1_n1000_pos_last() { + let p = calculate_payout(Distribution::Exponential(10), 1000, 1000, POOL); + assert!(p > 0, "payout"); +} + +#[test] +fn bench_payout_exp_k3_n1000_pos1() { + let p = calculate_payout(Distribution::Exponential(30), 1, 1000, POOL); + assert!(p > 0, "payout"); +} + +#[test] +fn bench_payout_exp_k3_n1000_pos_last() { + let p = calculate_payout(Distribution::Exponential(30), 1000, 1000, POOL); + assert!(p > 0, "payout"); +} + +#[test] +fn bench_payout_linear_n1000_pos1() { + let p = calculate_payout(Distribution::Linear(10), 1, 1000, POOL); + assert!(p > 0, "payout"); +} diff --git a/packages/utilities/src/distribution/tests/test_payout.cairo b/packages/utilities/src/distribution/tests/test_payout.cairo new file mode 100644 index 00000000..3c46f041 --- /dev/null +++ b/packages/utilities/src/distribution/tests/test_payout.cairo @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Correctness for exact token-unit payouts. +//! +//! The properties worth holding onto, in order of how much money they move: +//! +//! 1. payouts never exceed the pool, and the shortfall is under `n` units; +//! 2. no position is paid 0 for any realistic pool — the failure that makes +//! a position unclaimable under the basis-point path; +//! 3. the curve is monotonically non-increasing; +//! 4. the ratios match the basis-point path (which is the same mathematics +//! computed less precisely), so the shape does not change. + +use crate::distribution::calculator::calculate_share; +use crate::distribution::payout::{ + MAX_EXACT_EXPONENT, calculate_payout, payout_weight, payout_weight_sum, supports_exact_payout, +}; +use crate::distribution::structs::{BASIS_POINTS, Distribution}; + +/// 1000 tokens at 18 decimals — an ordinary prize pool. +const POOL: u256 = 1000_000000000000000000; + +fn sum_payouts(dist: Distribution, n: u32, amount: u256) -> u256 { + let mut total: u256 = 0; + let mut p: u32 = 1; + while p <= n { + total += calculate_payout(dist, p, n, amount); + p += 1; + } + total +} + +// ========================================================================== +// CONSERVATION — never overpay, and lose at most n units to truncation +// ========================================================================== + +#[test] +fn test_payouts_never_exceed_the_pool() { + let dists = array![ + Distribution::Linear(10), Distribution::Linear(25), Distribution::Exponential(10), + Distribution::Exponential(20), Distribution::Exponential(30), Distribution::Uniform, + ]; + for dist in dists { + let mut n: u32 = 1; + while n <= 40 { + let paid = sum_payouts(dist, n, POOL); + assert!(paid <= POOL, "overpaid at n={}", n); + assert!(POOL - paid < n.into(), "lost more than n units at n={}", n); + n += 1; + } + }; +} + +// ========================================================================== +// NO DEAD POSITIONS — the whole point of working in token units +// ========================================================================== + +/// The basis-point path gives late positions exactly 0 on a steep 100-place +/// curve, which Budokan rejects with `prize_amount > 0`. Same curve, same +/// pool, computed in token units: everyone is paid. +#[test] +fn test_no_zero_payouts_where_basis_points_die() { + let dist = Distribution::Exponential(30); // k = 3, steep + let n: u32 = 100; + + let mut zero_shares: u32 = 0; + let mut p: u32 = 1; + while p <= n { + if calculate_share(dist, p, n, BASIS_POINTS) == 0 { + zero_shares += 1; + } + assert!(calculate_payout(dist, p, n, POOL) > 0, "position {} paid nothing", p); + p += 1; + } + + // Guard the premise: if this curve ever stops starving the basis-point + // path, the test above stops proving anything. + assert!(zero_shares > 0, "expected the bps path to zero out some positions"); +} + +#[test] +fn test_no_zero_payouts_at_one_thousand_places() { + let dist = Distribution::Linear(10); + let n: u32 = 1000; + let mut p: u32 = 1; + while p <= n { + assert!(calculate_payout(dist, p, n, POOL) > 0, "position {} paid nothing", p); + p += 1; + } +} + +// ========================================================================== +// SHAPE — non-increasing, and matching the basis-point curve +// ========================================================================== + +#[test] +fn test_payouts_are_monotonically_non_increasing() { + let dists = array![ + Distribution::Linear(10), Distribution::Linear(50), Distribution::Exponential(10), + Distribution::Exponential(20), Distribution::Exponential(50), + ]; + for dist in dists { + let n: u32 = 50; + let mut previous = calculate_payout(dist, 1, n, POOL); + let mut p: u32 = 2; + while p <= n { + let current = calculate_payout(dist, p, n, POOL); + assert!(current <= previous, "payout rose at position {}", p); + previous = current; + p += 1; + } + }; +} + +/// Same curve as the basis-point path: scaling a payout back into basis +/// points reproduces `calculate_share` to within the 1 bps that the fixed +/// point implementation loses to rounding. +#[test] +fn test_matches_the_basis_point_curve() { + let dists = array![ + Distribution::Linear(10), Distribution::Linear(25), Distribution::Exponential(10), + Distribution::Exponential(20), + ]; + for dist in dists { + let mut n: u32 = 1; + while n <= 30 { + let mut p: u32 = 1; + while p <= n { + let bps = calculate_share(dist, p, n, BASIS_POINTS); + // Payout of a 10000-unit pool IS the basis-point share. + let payout = calculate_payout(dist, p, n, 10000); + let payout_u16: u16 = payout.try_into().unwrap(); + let diff = if payout_u16 > bps { + payout_u16 - bps + } else { + bps - payout_u16 + }; + assert!(diff <= 1, "curve moved at n={} p={}: {} vs {}", n, p, bps, payout_u16); + p += 1; + } + n += 1; + } + }; +} + +// ========================================================================== +// EXACTNESS — hand-checkable values +// ========================================================================== + +#[test] +fn test_uniform_splits_exactly() { + let dist = Distribution::Uniform; + let mut p: u32 = 1; + while p <= 4 { + assert!(calculate_payout(dist, p, 4, 1000) == 250, "quarter each"); + p += 1; + } +} + +/// Linear weight 1.0 over 4 places: weights 4:3:2:1, sum 10. +#[test] +fn test_linear_weights_are_exact() { + let dist = Distribution::Linear(10); + assert!(payout_weight_sum(dist, 4) == 100, "sum of 40+30+20+10"); + assert!(payout_weight(dist, 1, 4) == 40, "first"); + assert!(payout_weight(dist, 4, 4) == 10, "last"); + assert!(calculate_payout(dist, 1, 4, 1000) == 400, "first takes 4/10"); + assert!(calculate_payout(dist, 4, 4, 1000) == 100, "last takes 1/10"); +} + +/// Exponential k=2 over 3 places: weights 9:4:1, sum 14 = Faulhaber(2, 3). +#[test] +fn test_exponential_weights_are_exact() { + let dist = Distribution::Exponential(20); + assert!(payout_weight_sum(dist, 3) == 14, "1 + 4 + 9"); + assert!(payout_weight(dist, 1, 3) == 9, "first"); + assert!(payout_weight(dist, 2, 3) == 4, "second"); + assert!(payout_weight(dist, 3, 3) == 1, "third"); + assert!(calculate_payout(dist, 1, 3, 1400) == 900, "9/14"); + assert!(calculate_payout(dist, 2, 3, 1400) == 400, "4/14"); + assert!(calculate_payout(dist, 3, 3, 1400) == 100, "1/14"); +} + +/// Every Faulhaber branch against a directly summed reference. +#[test] +fn test_power_sums_match_direct_summation() { + let mut k: u32 = 1; + while k <= MAX_EXACT_EXPONENT { + let weight: u16 = (k * 10).try_into().unwrap(); + let dist = Distribution::Exponential(weight); + let mut n: u32 = 1; + while n <= 25 { + let mut reference: u256 = 0; + let mut p: u32 = 1; + while p <= n { + reference += payout_weight(dist, p, n); + p += 1; + } + assert!(payout_weight_sum(dist, n) == reference, "power sum k={} n={} wrong", k, n); + n += 1; + } + k += 1; + } +} + +// ========================================================================== +// GUARDS +// ========================================================================== + +#[test] +fn test_fractional_exponents_are_rejected_not_approximated() { + assert!(!supports_exact_payout(Distribution::Exponential(15)), "1.5 has no closed form"); + assert!(!supports_exact_payout(Distribution::Exponential(25)), "2.5 has no closed form"); + assert!(supports_exact_payout(Distribution::Exponential(30)), "3.0 does"); + assert!(supports_exact_payout(Distribution::Linear(15)), "linear is exact at any weight"); + assert!(supports_exact_payout(Distribution::Uniform), "uniform is exact"); +} + +#[test] +#[should_panic(expected: "Distribution: weight has no exact payout form; use the basis-point path")] +fn test_fractional_exponent_payout_panics() { + calculate_payout(Distribution::Exponential(15), 1, 10, POOL); +} + +#[test] +fn test_exponent_above_the_supported_range_is_rejected() { + let too_steep: u16 = ((MAX_EXACT_EXPONENT + 1) * 10).try_into().unwrap(); + assert!(!supports_exact_payout(Distribution::Exponential(too_steep)), "beyond the cap"); +} + +#[test] +fn test_out_of_range_and_empty_inputs() { + let dist = Distribution::Linear(10); + assert!(calculate_payout(dist, 0, 10, POOL) == 0, "index 0"); + assert!(calculate_payout(dist, 11, 10, POOL) == 0, "index past the end"); + assert!(calculate_payout(dist, 1, 0, POOL) == 0, "no places"); + assert!(calculate_payout(dist, 1, 10, 0) == 0, "empty pool"); + assert!(payout_weight_sum(dist, 0) == 0, "no places, no weight"); +} + +#[test] +fn test_custom_uses_its_explicit_shares() { + let shares: Array = array![5000, 3000, 2000]; + let dist = Distribution::Custom(shares.span()); + assert!(payout_weight_sum(dist, 3) == 10000, "sums the array"); + assert!(calculate_payout(dist, 1, 3, 1000) == 500, "half"); + assert!(calculate_payout(dist, 2, 3, 1000) == 300, "three tenths"); + assert!(calculate_payout(dist, 3, 3, 1000) == 200, "one fifth"); +} From 54b78bedd998990134ed8a5ad40126c8cf94fb04 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:51:57 -0700 Subject: [PATCH 05/11] fix(distribution): bound the Custom weight sum by total_payouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the [MEDIUM] finding from the Claude review on #120. `payout_weight` yields 0 for a position past `total_payouts`, but `payout_weight_sum` summed the entire shares array. The two parameters are independent — a caller may pay fewer places than the stored curve describes — so whenever `total_payouts < shares.len()` the denominator carried weight for positions that never get paid. Every payout came out proportionally short and the difference was stranded: the truncated tail's full weight, not the sub-unit rounding the module documents. With shares [5000, 3000, 2000] paying 2 places from a 1000 pool, that was 500 + 300 = 800 paid of 1000, with 200 stuck. Now 625 + 375 = 1000. Not reachable from Budokan, which validates `shares.len() == distribution_count` and passes that same count as `total_payouts` — but this is a library function and the invariant belongs here, not in the caller. Also documents the `Exponential` overflow envelope on `calculate_payout` ([LOW] from the same review). The largest intermediate is `total_amount * n^k`; against a 10^27 pool the ceiling is k=18 at 100 places, k=15 at 1,000 and k=13 at 10,000. `MAX_EXACT_EXPONENT` is 5, so every accepted curve is far inside it — the bound only matters if that cap is ever raised. Co-Authored-By: Claude Opus 5 (1M context) --- .../utilities/src/distribution/payout.cairo | 33 ++++++++++++++++++- .../src/distribution/tests/test_payout.cairo | 24 ++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/utilities/src/distribution/payout.cairo b/packages/utilities/src/distribution/payout.cairo index 0424deab..c380495f 100644 --- a/packages/utilities/src/distribution/payout.cairo +++ b/packages/utilities/src/distribution/payout.cairo @@ -162,9 +162,22 @@ pub fn payout_weight_sum(distribution: Distribution, total_payouts: u32) -> u256 }, Distribution::Uniform => n, Distribution::Custom(shares) => { + // Sum only the positions that actually get paid. `payout_weight` + // returns 0 for an index past `total_payouts` (and past the array), + // so counting the whole array here would put weight for unpaid + // positions into the denominator and shrink every payout — the + // shortfall being the truncated tail's full weight, not the + // sub-`n`-unit rounding this module otherwise guarantees. + // + // The two parameters are independent: a caller may pay fewer places + // than the stored curve describes. let mut total: u256 = 0; let mut i: u32 = 0; - let len = shares.len(); + let len = if shares.len() < total_payouts { + shares.len() + } else { + total_payouts + }; while i < len { let share: u16 = *shares.at(i); total += share.into(); @@ -187,6 +200,24 @@ pub fn payout_weight_sum(distribution: Distribution, total_payouts: u32) -> u256 /// /// Panics for a distribution `supports_exact_payout` rejects — callers must /// check first rather than silently receiving a different curve. +/// +/// ## Overflow envelope +/// +/// The largest intermediate is `total_amount * W(1) = total_amount * n^k`, so +/// `Exponential` has a ceiling in `(total_payouts, k, total_amount)` past which +/// this reverts rather than returning a wrong number. Against a pool of 10^27 +/// (a billion tokens at 18 decimals), which is far beyond any realistic +/// tournament: +/// +/// | `total_payouts` | highest safe `k` | +/// | --------------- | ---------------- | +/// | 100 | 18 (i.e. any k) | +/// | 1,000 | 15 | +/// | 10,000 | 13 | +/// +/// `MAX_EXACT_EXPONENT` is 5, so every accepted curve sits well inside this at +/// any field size the leaderboard can hold. The bound only becomes reachable if +/// that cap is raised, which is why it is written down here. pub fn calculate_payout( distribution: Distribution, payout_index: u32, total_payouts: u32, total_amount: u256, ) -> u256 { diff --git a/packages/utilities/src/distribution/tests/test_payout.cairo b/packages/utilities/src/distribution/tests/test_payout.cairo index 3c46f041..ca02d1c9 100644 --- a/packages/utilities/src/distribution/tests/test_payout.cairo +++ b/packages/utilities/src/distribution/tests/test_payout.cairo @@ -247,3 +247,27 @@ fn test_custom_uses_its_explicit_shares() { assert!(calculate_payout(dist, 2, 3, 1000) == 300, "three tenths"); assert!(calculate_payout(dist, 3, 3, 1000) == 200, "one fifth"); } + +/// Paying fewer places than the stored curve describes must not strand funds. +/// +/// `payout_weight` yields 0 past `total_payouts`, so the denominator has to +/// stop there too. Summing the whole array instead would leave the truncated +/// tail's weight in the denominator — here 2000 of 10000, silently shrinking +/// every payout by a fifth and stranding that fifth of the pool. +#[test] +fn test_custom_truncated_to_fewer_places_still_pays_the_whole_pool() { + let shares: Array = array![5000, 3000, 2000]; + let dist = Distribution::Custom(shares.span()); + + assert!(payout_weight_sum(dist, 2) == 8000, "denominator covers only paid places"); + + let first = calculate_payout(dist, 1, 2, 1000); + let second = calculate_payout(dist, 2, 2, 1000); + assert!(first == 625, "5000/8000 of the pool, got {}", first); + assert!(second == 375, "3000/8000 of the pool, got {}", second); + assert!(calculate_payout(dist, 3, 2, 1000) == 0, "position past total_payouts pays nothing"); + + // Conservation: the shortfall is rounding, not a truncated tail. + assert!(first + second <= 1000, "never overpays"); + assert!(1000 - (first + second) < 2, "shortfall under one unit per paid place"); +} From 3ffbe57f57796c508da739d7993075420f763d5d Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:56:19 -0700 Subject: [PATCH 06/11] refactor(distribution): scope the weight helpers to the crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the [LOW] finding from the Claude review on #120. `payout_weight` and `payout_weight_sum` were `pub` but do no `supports_exact_payout` gating, and `Exponential` derives `k = weight / 10` by integer truncation. An external caller passing `Exponential(15)` would have silently received the k=1 curve instead of the k=1.5 one — a different distribution, no panic. `calculate_payout` guards correctly, so making it the only public entry point closes the hole rather than duplicating the assert. Tests live in the same crate and are unaffected. Also corrects the module doc table, which still described `Custom`'s denominator as the whole shares array after the previous commit bounded it by the paid-place count. Co-Authored-By: Claude Opus 5 (1M context) --- .../utilities/src/distribution/payout.cairo | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/utilities/src/distribution/payout.cairo b/packages/utilities/src/distribution/payout.cairo index c380495f..1536f50a 100644 --- a/packages/utilities/src/distribution/payout.cairo +++ b/packages/utilities/src/distribution/payout.cairo @@ -40,7 +40,11 @@ //! | `Linear(w)` | `10 + (n - p) * w` | `10n + w * n(n-1)/2` | //! | `Exponential(w)` | `(n - p + 1)^k` | `sum_{j=1..n} j^k` (Faulhaber)| //! | `Uniform` | `1` | `n` | -//! | `Custom(shares)` | `shares[p-1]` | `sum(shares)` | +//! | `Custom(shares)` | `shares[p-1]` | `sum(shares[0..n])` | +//! +//! `Custom`'s sum is bounded by the paid-place count, not the array length — +//! a caller may pay fewer places than the stored curve describes, and weight +//! for positions that never pay must not sit in the denominator. //! //! `w` is the weight scaled by 10 (so `10` = 1.0), matching `calculator`. //! `k = w / 10` — see `supports_exact_payout` for which weights qualify. @@ -112,7 +116,9 @@ fn int_pow(base: u256, exponent: u32) -> u256 { /// The unnormalized integer weight of one payout position, 1-indexed. /// Returns 0 when `payout_index` falls outside 1..=total_payouts. -pub fn payout_weight(distribution: Distribution, payout_index: u32, total_payouts: u32) -> u256 { +pub(crate) fn payout_weight( + distribution: Distribution, payout_index: u32, total_payouts: u32, +) -> u256 { if payout_index == 0 || payout_index > total_payouts { return 0; } @@ -141,9 +147,15 @@ pub fn payout_weight(distribution: Distribution, payout_index: u32, total_payout /// The sum of every position's weight — the normalization denominator. /// +/// Crate-internal: unlike `calculate_payout` this does not gate on +/// `supports_exact_payout`, and `Exponential` truncates `k = weight / 10`. An +/// external caller passing a fractional weight would silently receive the k=1 +/// curve rather than a panic, so the gated entry point stays the only public +/// way in. +/// /// Closed form for `Linear`, `Exponential` and `Uniform`, so this is O(1). /// `Custom` sums its explicit array, which is inherent to the variant. -pub fn payout_weight_sum(distribution: Distribution, total_payouts: u32) -> u256 { +pub(crate) fn payout_weight_sum(distribution: Distribution, total_payouts: u32) -> u256 { if total_payouts == 0 { return 0; } From d182c69dcd761bddf003381f92774233da149c98 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:26:04 -0700 Subject: [PATCH 07/11] =?UTF-8?q?feat(distribution):=20add=20Geometric=20?= =?UTF-8?q?=E2=80=94=20a=20curve=20whose=20shape=20survives=20the=20field?= =?UTF-8?q?=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototype for review, not a commitment. `Exponential` is a power law, so its winner share thins as roughly (k+1)/n: 45% over 10 places, 5.8% over 100, and no weight fixes that. A headline first prize on a large field needs geometric decay, which the current set cannot express at any setting. `Geometric(a, b)` gives each place `b / a` of the one above: W(p) = a^(n-p) * b^(p-1) sum = (a^n - b^n) / (a - b) Exact in integers — no fixed point — and the winner takes about `1 - b/a` regardless of n: ratio (10,7), first place: 30.9% at n=10, 30.0% at n=39 Exponential k=5, first place: 45.3% at n=10, 5.8% at n=100 Three things the prototype surfaced that are worth knowing before committing to the enum change: 1. **The packed storage only carries one u16 per distribution.** Both `entry_fee_store` and `prize/structs` pack `(u8 tag, u16 param)`, so a two-term ratio does not fit. Rather than widen the layout — a storage change for every consumer — `a` and `b` are capped at 255 and packed as `a * 256 + b`. That is still every ratio with terms under 256, far more precision than a decay setting needs, and the layout is untouched. 2. **Reach is bounded, and tightens as the ratio gets finer.** The heaviest weight is `a^(n-1)`; holding it under 2^128 keeps `pool * W(1)` inside u256 for any u128 pool, with no pool-size caveat. That gives 129 places at (2,1), 81 at (3,2), 46 at (7,5), 39 at (10,7). `calculate_payout` asserts it rather than overflowing, and `max_geometric_payouts` lets a caller reject at creation. A host wanting more places picks a coarser ratio for the same decay. 3. **It is not as cheap as the power law.** Its exponent scales with the field instead of being capped at MAX_EXACT_EXPONENT, so it costs O(log n) large u256 multiplications. `int_pow` is now exponentiation by squaring, which took a 39-place winner from 6.9M to 3.4M l2_gas — still ~15x the power law's ~230k, flat across position. Against Custom at the same reach that is a bargain; against Exponential it is a real cost for a shape you cannot otherwise have. `calculator` (the basis-point path) panics for Geometric rather than approximating it — a geometric tail dies under basis points even worse than a steep power law, so there is no honest bps form to offer. The enum variant is appended, not inserted: Serde indices are positional, and inserting earlier would reinterpret every stored and indexed distribution. snforge: 301 passed. The 2 failures are the pre-existing local step-limit ones on the basis-point comparison tests, which pass in CI. Co-Authored-By: Claude Opus 5 (1M context) --- packages/interfaces/src/distribution.cairo | 19 ++++ .../src/entry_fee/entry_fee_store.cairo | 14 ++- packages/metagame/src/prize/structs.cairo | 10 ++ .../src/distribution/calculator.cairo | 12 ++ .../utilities/src/distribution/payout.cairo | 105 ++++++++++++++++-- .../utilities/src/distribution/structs.cairo | 4 + .../src/distribution/tests/test_payout.cairo | 99 ++++++++++++++++- 7 files changed, 252 insertions(+), 11 deletions(-) diff --git a/packages/interfaces/src/distribution.cairo b/packages/interfaces/src/distribution.cairo index c514bb9a..8443b0d9 100644 --- a/packages/interfaces/src/distribution.cairo +++ b/packages/interfaces/src/distribution.cairo @@ -4,4 +4,23 @@ pub enum Distribution { Exponential: u16, Uniform, Custom: Span, + /// 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), } diff --git a/packages/metagame/src/entry_fee/entry_fee_store.cairo b/packages/metagame/src/entry_fee/entry_fee_store.cairo index a35e2b28..8337e7a3 100644 --- a/packages/metagame/src/entry_fee/entry_fee_store.cairo +++ b/packages/metagame/src/entry_fee/entry_fee_store.cairo @@ -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_UNIFORM, PackedDistribution, }; use starknet::ContractAddress; use crate::entry_fee::store::Store; @@ -98,6 +98,15 @@ pub impl EntryFeeStoreImpl, +Drop> of EntryFeeStoreTrait { (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_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 @@ -223,6 +232,7 @@ pub impl EntryFeeStoreImpl, +Drop> of EntryFeeStoreTrait { Distribution::Exponential(w) => (DIST_TYPE_EXPONENTIAL, *w), Distribution::Uniform => (DIST_TYPE_UNIFORM, 0_u16), Distribution::Custom(_) => (DIST_TYPE_CUSTOM, 0_u16), + Distribution::Geometric((a, b)) => (DIST_TYPE_GEOMETRIC, *a * 256 + *b), }, }; // For Custom, paid places are defined by the shares array length; diff --git a/packages/metagame/src/prize/structs.cairo b/packages/metagame/src/prize/structs.cairo index 44158ea5..16047c39 100644 --- a/packages/metagame/src/prize/structs.cairo +++ b/packages/metagame/src/prize/structs.cairo @@ -24,6 +24,7 @@ pub const PAYOUT_TYPE_LINEAR: u8 = 1; pub const PAYOUT_TYPE_EXPONENTIAL: u8 = 2; pub const PAYOUT_TYPE_UNIFORM: u8 = 3; pub const PAYOUT_TYPE_CUSTOM: u8 = 4; +pub const PAYOUT_TYPE_GEOMETRIC: u8 = 5; /// Internal packed representation for ERC20 data storage /// Layout: [amount: 128 bits][payout_type: 8 bits][param: 16 bits][count: 32 bits] = 184 bits @@ -131,6 +132,9 @@ fn pack_token_type(token_type: TokenTypeData) -> PackedTokenTypeData { game_components_utilities::distribution::structs::Distribution::Custom(_) => ( PAYOUT_TYPE_CUSTOM, 0_u16, ), + game_components_utilities::distribution::structs::Distribution::Geometric(( + a, b, + )) => (PAYOUT_TYPE_GEOMETRIC, a * 256 + b), } }, }; @@ -170,6 +174,12 @@ fn unpack_token_type(packed_token_type: PackedTokenTypeData) -> TokenTypeData { Option::Some( game_components_utilities::distribution::structs::Distribution::Uniform, ) + } else if packed.payout_type == PAYOUT_TYPE_GEOMETRIC { + Option::Some( + game_components_utilities::distribution::structs::Distribution::Geometric( + (packed.param / 256, packed.param % 256), + ), + ) } else { Option::Some( game_components_utilities::distribution::structs::Distribution::Custom( diff --git a/packages/utilities/src/distribution/calculator.cairo b/packages/utilities/src/distribution/calculator.cairo index 0788e033..c1de9ad3 100644 --- a/packages/utilities/src/distribution/calculator.cairo +++ b/packages/utilities/src/distribution/calculator.cairo @@ -32,6 +32,9 @@ pub fn calculate_share( }, Distribution::Uniform => calculate_uniform_share(total_payouts, available_share), Distribution::Custom(shares) => calculate_custom_share(payout_index, shares), + Distribution::Geometric(_) => panic!( + "Distribution: Geometric has no basis-point form; use payout::calculate_payout", + ), } } @@ -56,6 +59,9 @@ pub fn calculate_total( let (weights, denominator) = weight_vector(distribution, total_payouts); sum_shares(@weights, denominator, available_share) }, + Distribution::Geometric(_) => panic!( + "Distribution: Geometric has no basis-point form; use payout::calculate_payout", + ), Distribution::Uniform | Distribution::Custom(_) => { let mut total: u16 = 0; @@ -126,6 +132,9 @@ pub fn calculate_share_with_dust( base_share + (available_share - total) } }, + Distribution::Geometric(_) => panic!( + "Distribution: Geometric has no basis-point form; use payout::calculate_payout", + ), Distribution::Uniform | Distribution::Custom(_) => { let base_share = calculate_share( @@ -202,6 +211,9 @@ fn weight_vector(distribution: Distribution, total_payouts: u32) -> (Array panic!( + "Distribution: Geometric has no basis-point form; use payout::calculate_payout", + ), Distribution::Uniform | Distribution::Custom(_) => {}, } diff --git a/packages/utilities/src/distribution/payout.cairo b/packages/utilities/src/distribution/payout.cairo index 1536f50a..ce73d2dc 100644 --- a/packages/utilities/src/distribution/payout.cairo +++ b/packages/utilities/src/distribution/payout.cairo @@ -41,6 +41,7 @@ //! | `Exponential(w)` | `(n - p + 1)^k` | `sum_{j=1..n} j^k` (Faulhaber)| //! | `Uniform` | `1` | `n` | //! | `Custom(shares)` | `shares[p-1]` | `sum(shares[0..n])` | +//! | `Geometric(a,b)` | `a^(n-p) * b^(p-1)` | `(a^n - b^n) / (a - b)` | //! //! `Custom`'s sum is bounded by the paid-place count, not the array length — //! a caller may pay fewer places than the stored curve describes, and weight @@ -52,10 +53,18 @@ //! ## Naming note //! //! `Exponential` is a power law, not an exponential: `W(p)` is polynomial in -//! the position, `(n-p+1)^k`, not `r^p`. That is a happy accident — a power -//! law has an exact closed-form sum (Faulhaber) in integers, whereas a true -//! geometric decay needs `r^n` in rationals and overflows or needs fixed -//! point. The existing curve shape is the one that computes cheaply. +//! the position, `(n-p+1)^k`, not `r^p`. A power law has an exact closed-form +//! sum (Faulhaber) in integers, and its exponent is a small constant, so it is +//! the cheapest curve here by a wide margin. +//! +//! True geometric decay is `Geometric(a, b)`. Kept as a *rational* ratio it +//! also has an exact integer closed form, `(a^n - b^n) / (a - b)`, so it needs +//! no fixed point — but its exponent scales with the field rather than being +//! bounded by `MAX_EXACT_EXPONENT`, so it costs O(log n) large-u256 +//! multiplications where the power law costs a handful of small ones. Measured +//! at a 39-place field: ~3.4M l2_gas against ~230k. It buys a shape the power +//! law cannot express — a winner share that does not thin out as the field +//! grows — and that is the trade. use crate::distribution::structs::Distribution; @@ -67,6 +76,42 @@ use crate::distribution::structs::Distribution; /// clear of u256 overflow for any realistic pool. pub const MAX_EXACT_EXPONENT: u32 = 5; +/// The largest paid-place count a `Geometric(a, b)` curve can represent. +/// +/// The heaviest weight is `a^(n-1)`, and `calculate_payout` multiplies it by +/// the pool. Pools are `u128`, so keeping `a^(n-1)` within `2^128` guarantees +/// the product fits `u256` for *any* pool — no pool-size caveat to carry +/// around at the call site. +/// +/// The bound tightens as the ratio gets finer, because a finer ratio needs a +/// bigger base: +/// +/// | ratio | decay | max places | +/// | ------- | ----- | ---------- | +/// | `(2,1)` | 50% | 129 | +/// | `(3,2)` | 67% | 81 | +/// | `(7,5)` | 71% | 46 | +/// | `(10,7)`| 70% | 39 | +/// +/// A host wanting more places picks a coarser ratio for the same decay — +/// `(3,2)` and `(10,7)` are both roughly a third off per place, but `(3,2)` +/// reaches twice as far. +pub fn max_geometric_payouts(a: u16) -> u32 { + if a < 2 { + return 0; + } + let limit: u256 = 0x100000000000000000000000000000000; // 2^128 + let base: u256 = a.into(); + let mut acc: u256 = 1; + let mut n: u32 = 1; + // acc == a^(n-1); grow while the next step stays inside the bound. + while acc <= limit / base { + acc = acc * base; + n += 1; + } + n +} + /// Whether `calculate_payout` can compute this distribution exactly. /// /// `Exponential` qualifies when its weight is a whole multiple of 10 (an @@ -79,6 +124,12 @@ pub fn supports_exact_payout(distribution: Distribution) -> bool { let w: u32 = weight.into(); w % 10 == 0 && w != 0 && (w / 10) <= MAX_EXACT_EXPONENT }, + // Shape only. Whether the *field* fits is `max_geometric_payouts`, + // which needs the paid-place count and so is checked in + // `calculate_payout`. + // `a` and `b` are capped at 255 so both survive the single-u16 param + // slot the packed storage gives a distribution (`a * 256 + b`). + Distribution::Geometric((a, b)) => a > b && b > 0 && a <= 255, _ => true, } } @@ -104,12 +155,25 @@ fn power_sum(k: u32, n: u256) -> u256 { } } +/// Exponentiation by squaring — O(log e) multiplications. +/// +/// `Exponential` only ever raises to k <= 5, where the naive loop was fine. +/// `Geometric` raises to `n - p`, which scales with the field, so a linear +/// loop would make the "flat cost regardless of field size" property false: +/// measured 6.9M l2_gas for a 39-place geometric winner before this, against +/// ~230k for the power law. fn int_pow(base: u256, exponent: u32) -> u256 { let mut acc: u256 = 1; - let mut i: u32 = 0; - while i < exponent { - acc = acc * base; - i += 1; + let mut b: u256 = base; + let mut e: u32 = exponent; + while e > 0 { + if e % 2 == 1 { + acc = acc * b; + } + e = e / 2; + if e > 0 { + b = b * b; + } } acc } @@ -142,6 +206,14 @@ pub(crate) fn payout_weight( share.into() } }, + // W(p) = a^(n-p) * b^(p-1) — the ratio between adjacent positions is + // a constant b/a, which is what makes the curve's shape independent + // of n. + Distribution::Geometric(( + a, b, + )) => { + int_pow(a.into(), total_payouts - payout_index) * int_pow(b.into(), payout_index - 1) + }, } } @@ -173,6 +245,15 @@ pub(crate) fn payout_weight_sum(distribution: Distribution, total_payouts: u32) power_sum(k, n) }, Distribution::Uniform => n, + // sum_{p=1..n} a^(n-p) b^(p-1) = (a^n - b^n) / (a - b), exact in + // integers because the numerator is divisible by (a - b). + Distribution::Geometric(( + a, b, + )) => { + let av: u256 = a.into(); + let bv: u256 = b.into(); + (int_pow(av, total_payouts) - int_pow(bv, total_payouts)) / (av - bv) + }, Distribution::Custom(shares) => { // Sum only the positions that actually get paid. `payout_weight` // returns 0 for an index past `total_payouts` (and past the array), @@ -238,6 +319,14 @@ pub fn calculate_payout( "Distribution: weight has no exact payout form; use the basis-point path", ); + if let Distribution::Geometric((a, _)) = distribution { + assert!( + total_payouts <= max_geometric_payouts(a), + "Distribution: geometric ratio reaches at most {} places; use a coarser ratio", + max_geometric_payouts(a), + ); + } + if payout_index == 0 || payout_index > total_payouts || total_amount == 0 { return 0; } diff --git a/packages/utilities/src/distribution/structs.cairo b/packages/utilities/src/distribution/structs.cairo index fb40efb9..eca139ad 100644 --- a/packages/utilities/src/distribution/structs.cairo +++ b/packages/utilities/src/distribution/structs.cairo @@ -15,6 +15,10 @@ pub const DIST_TYPE_LINEAR: u8 = 0; pub const DIST_TYPE_EXPONENTIAL: u8 = 1; pub const DIST_TYPE_UNIFORM: u8 = 2; pub const DIST_TYPE_CUSTOM: u8 = 3; +/// `Geometric(a, b)` packs both ratio terms into the single u16 param slot as +/// `a * 256 + b`, which is why `supports_exact_payout` caps them at 255. That +/// keeps the packed layout — one u8 tag plus one u16 param — unchanged. +pub const DIST_TYPE_GEOMETRIC: u8 = 4; // Constants for PackedDistribution bit-packing. const TWO_POW_8: u128 = 0x100; // 2^8 diff --git a/packages/utilities/src/distribution/tests/test_payout.cairo b/packages/utilities/src/distribution/tests/test_payout.cairo index ca02d1c9..cbf58173 100644 --- a/packages/utilities/src/distribution/tests/test_payout.cairo +++ b/packages/utilities/src/distribution/tests/test_payout.cairo @@ -13,7 +13,8 @@ use crate::distribution::calculator::calculate_share; use crate::distribution::payout::{ - MAX_EXACT_EXPONENT, calculate_payout, payout_weight, payout_weight_sum, supports_exact_payout, + MAX_EXACT_EXPONENT, calculate_payout, max_geometric_payouts, payout_weight, payout_weight_sum, + supports_exact_payout, }; use crate::distribution::structs::{BASIS_POINTS, Distribution}; @@ -271,3 +272,99 @@ fn test_custom_truncated_to_fewer_places_still_pays_the_whole_pool() { assert!(first + second <= 1000, "never overpays"); assert!(1000 - (first + second) < 2, "shortfall under one unit per paid place"); } + +// ==================== Geometric ==================== +// +// The property that motivates the variant: a geometric curve's shape does not +// depend on the size of the field. `Exponential` cannot do this — as a power +// law its winner share falls off as roughly (k+1)/n. + +#[test] +fn test_geometric_ratio_holds_between_adjacent_positions() { + let dist = Distribution::Geometric((10, 7)); + let pool: u256 = 1_000_000_000_000_000_000; + let first = calculate_payout(dist, 1, 10, pool); + let second = calculate_payout(dist, 2, 10, pool); + assert!(first == 308720592627384808, "winner share, got {}", first); + assert!(second == 216104414839169366, "runner-up share, got {}", second); + // Each place takes 7/10 of the one above. Both sides are independently + // truncated from the exact ratio, so they can differ by a unit — comparing + // `second` against `first * 7 / 10` re-truncates an already-truncated + // value and is off by one here. + let expected = first * 7 / 10; + let drift = if second > expected { + second - expected + } else { + expected - second + }; + assert!(drift <= 1, "adjacent ratio is b/a to within a unit, drifted {}", drift); +} + +#[test] +fn test_geometric_winner_share_is_independent_of_field_size() { + let dist = Distribution::Geometric((10, 7)); + let pool: u256 = 1_000_000_000_000_000_000; + // 1 - b/a = 30%, whether the field is 10 places or 39. + let small = calculate_payout(dist, 1, 10, pool); + let large = calculate_payout(dist, 1, 39, pool); + assert!(small > 308_000_000_000_000_000 && small < 309_000_000_000_000_000, "~30.9% at n=10"); + assert!(large > 299_000_000_000_000_000 && large < 301_000_000_000_000_000, "~30.0% at n=39"); +} + +#[test] +fn test_geometric_conserves_the_pool() { + let dist = Distribution::Geometric((3, 2)); + let pool: u256 = 1_000_000_000_000_000_000; + let n: u32 = 50; + let mut total: u256 = 0; + let mut p: u32 = 1; + while p <= n { + let amount = calculate_payout(dist, p, n, pool); + assert!(amount > 0, "position {} must be payable", p); + total += amount; + p += 1; + } + assert!(total <= pool, "never overpays"); + assert!(pool - total < n.into(), "shortfall under one unit per position"); +} + +#[test] +fn test_max_geometric_payouts_matches_the_documented_reach() { + assert!(max_geometric_payouts(2) == 129, "50% decay reaches 129 places"); + assert!(max_geometric_payouts(3) == 81, "2/3 decay reaches 81"); + assert!(max_geometric_payouts(7) == 46, "5/7 decay reaches 46"); + assert!(max_geometric_payouts(10) == 39, "7/10 decay reaches 39"); +} + +#[test] +#[should_panic(expected: "geometric ratio reaches at most 39 places")] +fn test_geometric_beyond_reach_is_refused_not_overflowed() { + let dist = Distribution::Geometric((10, 7)); + calculate_payout(dist, 1, 40, 1_000_000_000_000_000_000); +} + +#[test] +fn test_geometric_shape_guards() { + // b must be under a (otherwise the curve is flat or inverted), non-zero, + // and both must survive the single-u16 packed param slot. + assert!(supports_exact_payout(Distribution::Geometric((10, 7))), "valid ratio"); + assert!(!supports_exact_payout(Distribution::Geometric((7, 10))), "inverted"); + assert!(!supports_exact_payout(Distribution::Geometric((10, 10))), "flat"); + assert!(!supports_exact_payout(Distribution::Geometric((10, 0))), "zero tail"); + assert!(!supports_exact_payout(Distribution::Geometric((256, 7))), "exceeds the u8 slot"); +} + +// Cost: flat, like every other exact curve. +#[test] +fn bench_payout_geometric_n39_pos1() { + let dist = Distribution::Geometric((10, 7)); + let share = calculate_payout(dist, 1, 39, 1_000_000_000_000_000_000); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_payout_geometric_n39_pos_last() { + let dist = Distribution::Geometric((10, 7)); + let share = calculate_payout(dist, 39, 39, 1_000_000_000_000_000_000); + assert!(share > 0, "last-place share"); +} From 6d4389d7492f75db991e4bb6ee3e58750fc3f5a4 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:53:51 -0700 Subject: [PATCH 08/11] perf(prize): read one custom share per claim instead of rebuilding the curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_prize` eagerly called `get_custom_shares`, rebuilding the entire shares array from packed storage as part of reconstructing the record. That runs on every claim, and a claim settles exactly one position — so a 200-place Custom prize paid O(count/15) storage reads plus 200 unpack steps, 200 times over, to use one number. The entry-fee store has never done this: `get_entry_fee` returns Custom with an empty span and claims read a single share through `get_custom_share_at`. This brings the prize side in line. - `get_prize` reports the Custom *shape* with an empty span - new `get_custom_share_at(prize_id, position)` — one storage read at any position, mirroring the entry-fee accessor - view surfaces call `get_custom_shares` explicitly, as before No prefix sums or precomputed totals are needed: shares are validated at creation to number `distribution_count` and sum to exactly BASIS_POINTS, so the denominator is the constant BASIS_POINTS and there is nothing to sum. (Running totals would only be needed to pay the top n of a *longer* stored curve, which is a separate capability.) Measured in budokan, marginal cost of one distributed-prize claim: places before after saving 10 4,574,327 3,001,323 1.5x 50 11,294,147 3,001,323 3.8x 100 19,638,887 3,001,323 6.5x 200 36,401,747 3,001,323 12.1x Flat, and now marginally cheaper than Exponential (3,174,237), which computes a power where this reads a slot. snforge: 437 passed. The round-trip test now asserts the new contract — that `get_prize` does not rebuild the curve, and that any position reads correctly through the accessor. Co-Authored-By: Claude Opus 5 (1M context) --- .../metagame/src/prize/prize_component.cairo | 11 +++++++ packages/metagame/src/prize/prize_store.cairo | 30 +++++++++++++++++-- .../src/prize/tests/mocks/prize_mock.cairo | 6 ++++ .../src/prize/tests/test_prize_store.cairo | 13 ++++++-- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/metagame/src/prize/prize_component.cairo b/packages/metagame/src/prize/prize_component.cairo index d00560e6..4d7fbb5c 100644 --- a/packages/metagame/src/prize/prize_component.cairo +++ b/packages/metagame/src/prize/prize_component.cairo @@ -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, 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( diff --git a/packages/metagame/src/prize/prize_store.cairo b/packages/metagame/src/prize/prize_store.cairo index c89e5962..369cc84b 100644 --- a/packages/metagame/src/prize/prize_store.cairo +++ b/packages/metagame/src/prize/prize_store.cairo @@ -16,8 +16,16 @@ pub trait PrizeStoreTrait { /// 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; + /// 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, +Drop> of PrizeStoreTrait { 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, +Drop> of PrizeStoreTrait { 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) + } + fn get_custom_shares(self: @T, prize_id: u64) -> Array { let count = Store::get_custom_shares_count(self, prize_id); let mut shares = ArrayTrait::new(); diff --git a/packages/metagame/src/prize/tests/mocks/prize_mock.cairo b/packages/metagame/src/prize/tests/mocks/prize_mock.cairo index 4a295951..265cf113 100644 --- a/packages/metagame/src/prize/tests/mocks/prize_mock.cairo +++ b/packages/metagame/src/prize/tests/mocks/prize_mock.cairo @@ -131,6 +131,12 @@ pub mod PrizeMock { self.prize._get_custom_shares(prize_id) } + /// One custom share by 1-indexed position, without rebuilding the array + #[external(v0)] + fn get_custom_share_at(self: @ContractState, prize_id: u64, position: u32) -> u16 { + self.prize._get_custom_share_at(prize_id, position) + } + /// Get extension address for a context and prize #[external(v0)] fn get_extension_address( diff --git a/packages/metagame/src/prize/tests/test_prize_store.cairo b/packages/metagame/src/prize/tests/test_prize_store.cairo index aaf0fb4c..18b84d14 100644 --- a/packages/metagame/src/prize/tests/test_prize_store.cairo +++ b/packages/metagame/src/prize/tests/test_prize_store.cairo @@ -29,6 +29,7 @@ trait IPrizeMockFull { fn assert_prize_exists(self: @TContractState, prize_id: u64); fn assert_prize_not_claimed(self: @TContractState, context_id: u64, prize_type: PrizeType); fn get_custom_shares(self: @TContractState, prize_id: u64) -> Array; + fn get_custom_share_at(self: @TContractState, prize_id: u64, position: u32) -> u16; fn get_extension_address( self: @TContractState, context_id: u64, prize_id: u64, ) -> ContractAddress; @@ -391,7 +392,15 @@ fn test_add_prize_custom_distribution_round_trip_through_packed_storage() { assert!(*reconstructed.at(15) == 1600, "first share of second slot preserved"); assert!(*reconstructed.at(16) == 1700, "last share preserved"); - // get_prize itself walks the Custom branch in get_token_record. + // Any position reads in one storage access, without rebuilding the curve. + assert!(mock.get_custom_share_at(prize_id, 1) == 100, "share at position 1"); + assert!(mock.get_custom_share_at(prize_id, 15) == 1500, "last of the first slot"); + assert!(mock.get_custom_share_at(prize_id, 16) == 1600, "first of the second slot"); + assert!(mock.get_custom_share_at(prize_id, 17) == 1700, "share at the last position"); + + // `get_prize` reports the *shape* with an empty span and does not rebuild + // the curve: it runs on every claim, where only one share is wanted. + // Callers that need the whole array ask for it (above). let record = mock.get_prize(prize_id); match record.prize { Prize::Token(token_payload) => { @@ -399,7 +408,7 @@ fn test_add_prize_custom_distribution_round_trip_through_packed_storage() { TokenTypeData::erc20(data) => { match data.distribution { Option::Some(Distribution::Custom(reread)) => { - assert!(reread.len() == 17, "get_prize should restore all shares"); + assert!(reread.len() == 0, "get_prize must not rebuild the curve"); }, _ => panic!("expected Custom distribution"), } From 5b7ae96a6b621336200192634a0f6c9672e01419 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:34:40 -0700 Subject: [PATCH 09/11] =?UTF-8?q?feat(distribution):=20add=20Tiered=20?= =?UTF-8?q?=E2=80=94=20a=20geometric=20head=20over=20a=20flat=20tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variant that makes a very large field expressive. 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 any single curve here can do for first place is ~0.06% (Exponential k=5). `Tiered { head_ratio, head_count, head_share_bps }`: a geometric head over the first `head_count` places takes `head_share_bps` of the pool; every remaining place splits the rest evenly. The flagship configuration — top 39 on (10, 7) taking 80% — over 10,000 places: 1st 240000218290681776 (24.00% of a 1e18 pool) 2nd 168000152803477243 (70% of 1st, exactly the decay) 39th 311843831108 40th..10000th 20078305391024 each (0.002%, all payable) residue 9,955 wei — under one unit per position Both tiers are exact integer maths, and both are O(1): the head reuses Geometric's closed form, the tail is one division. Measured: 3.50M l2_gas for the winner, 2.31M for a tail place, flat across the field. The geometric reach bound applies to `head_count`, not the field, which is exactly why the head can stay steep on a field a hundred times larger. Guards, refused at the boundary rather than misbehaving inside it: - ratio rules are Geometric's (a > b > 0, a <= 255 for the packed param slot) - 0 < head_share_bps < BASIS_POINTS — at either end one tier becomes an unclaimable zero, and the single-curve variants cover those shapes - head_count within max_geometric_payouts(a); paid places strictly beyond the head Storage: `PackedDistribution` and `PackedERC20Data` gain two u16 param slots (head_count, head_share_bps), appended above the existing bit layouts so previously packed values unpack unchanged. 88 and 216 bits respectively, both comfortably inside felt252. The weight helpers panic on Tiered: it is two pools, not one weight family, and `calculate_payout` settles it before they are reached. The basis-point calculator refuses it like Geometric — a tail place's true share is far under a basis point by construction, so there is no honest bps form. snforge: utilities 309 passed (the 2 failures are the pre-existing local step-limit pair, green in CI), metagame 438 passed. Co-Authored-By: Claude Opus 5 (1M context) --- packages/interfaces/src/distribution.cairo | 32 +++++ .../src/entry_fee/entry_fee_store.cairo | 43 +++++-- packages/metagame/src/prize/structs.cairo | 66 ++++++++-- .../src/distribution/calculator.cairo | 20 +-- .../utilities/src/distribution/payout.cairo | 54 +++++++- .../utilities/src/distribution/structs.cairo | 43 +++++-- .../src/distribution/tests/test_payout.cairo | 119 +++++++++++++++++- 7 files changed, 339 insertions(+), 38 deletions(-) diff --git a/packages/interfaces/src/distribution.cairo b/packages/interfaces/src/distribution.cairo index 8443b0d9..b821f69d 100644 --- a/packages/interfaces/src/distribution.cairo +++ b/packages/interfaces/src/distribution.cairo @@ -23,4 +23,36 @@ pub enum Distribution { /// 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, } diff --git a/packages/metagame/src/entry_fee/entry_fee_store.cairo b/packages/metagame/src/entry_fee/entry_fee_store.cairo index 8337e7a3..97e4f18f 100644 --- a/packages/metagame/src/entry_fee/entry_fee_store.cairo +++ b/packages/metagame/src/entry_fee/entry_fee_store.cairo @@ -9,7 +9,7 @@ use game_components_utilities::distribution::packed_shares::{ }; use game_components_utilities::distribution::structs::{ DIST_TYPE_CUSTOM, DIST_TYPE_EXPONENTIAL, DIST_TYPE_GEOMETRIC, DIST_TYPE_LINEAR, - DIST_TYPE_UNIFORM, PackedDistribution, + DIST_TYPE_TIERED, DIST_TYPE_UNIFORM, PackedDistribution, TieredConfig, }; use starknet::ContractAddress; use crate::entry_fee::store::Store; @@ -98,6 +98,21 @@ pub impl EntryFeeStoreImpl, +Drop> of EntryFeeStoreTrait { (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( @@ -225,14 +240,20 @@ pub impl EntryFeeStoreImpl, +Drop> of EntryFeeStoreTrait { // 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::Geometric((a, b)) => (DIST_TYPE_GEOMETRIC, *a * 256 + *b), + 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, + )) => (DIST_TYPE_GEOMETRIC, *a * 256 + *b, 0_u16, 0_u16), + Distribution::Tiered(cfg) => { + let (a, b) = *cfg.head_ratio; + (DIST_TYPE_TIERED, a * 256 + b, *cfg.head_count, *cfg.head_share_bps) + }, }, }; // For Custom, paid places are defined by the shares array length; @@ -245,7 +266,11 @@ pub impl EntryFeeStoreImpl, +Drop> of EntryFeeStoreTrait { 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 { diff --git a/packages/metagame/src/prize/structs.cairo b/packages/metagame/src/prize/structs.cairo index 16047c39..9ef59664 100644 --- a/packages/metagame/src/prize/structs.cairo +++ b/packages/metagame/src/prize/structs.cairo @@ -16,6 +16,7 @@ use starknet::storage_access::StorePacking; mod nz128 { pub const TWO_POW_8: NonZero = 0x100; pub const TWO_POW_16: NonZero = 0x10000; + pub const TWO_POW_32: NonZero = 0x100000000; } // Payout type constants for storage @@ -25,9 +26,11 @@ pub const PAYOUT_TYPE_EXPONENTIAL: u8 = 2; pub const PAYOUT_TYPE_UNIFORM: u8 = 3; pub const PAYOUT_TYPE_CUSTOM: u8 = 4; pub const PAYOUT_TYPE_GEOMETRIC: u8 = 5; +pub const PAYOUT_TYPE_TIERED: u8 = 6; /// Internal packed representation for ERC20 data storage -/// Layout: [amount: 128 bits][payout_type: 8 bits][param: 16 bits][count: 32 bits] = 184 bits +/// Layout: [amount: 128 bits][payout_type: 8][param: 16][count: 32][param2: 16][param3: 16] +/// = 216 bits. param2/param3 carry Tiered's head_count and head_share_bps; 0 otherwise. /// This is used internally by StorePacking and not exposed in the API #[derive(Copy, Drop)] struct PackedERC20Data { @@ -35,6 +38,8 @@ struct PackedERC20Data { payout_type: u8, param: u16, count: u32, + param2: u16, + param3: u16, } /// u128-aligned StorePacking for PackedERC20Data. @@ -54,7 +59,9 @@ impl PackedERC20DataPacking of StorePacking { let high: u128 = value.payout_type.into() + value.param.into() * 0x100_u128 // shift 8 - + value.count.into() * 0x1000000_u128; // shift 24 + + value.count.into() * 0x1000000_u128 // shift 24 + + value.param2.into() * 0x100000000000000_u128 // shift 56 + + value.param3.into() * 0x1000000000000000000_u128; // shift 72 let packed = u256 { low, high }; packed.try_into().unwrap() @@ -67,13 +74,17 @@ impl PackedERC20DataPacking of StorePacking { let high = packed.high; let (hi, payout_type) = DivRem::div_rem(high, nz128::TWO_POW_8); - let (count, param) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (hi2, param) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (hi3, count) = DivRem::div_rem(hi2, nz128::TWO_POW_32); + let (param3, param2) = DivRem::div_rem(hi3, nz128::TWO_POW_16); PackedERC20Data { amount, payout_type: payout_type.try_into().unwrap(), param: param.try_into().unwrap(), count: count.try_into().unwrap(), + param2: param2.try_into().unwrap(), + param3: param3.try_into().unwrap(), } } } @@ -116,25 +127,29 @@ fn pack_token_type(token_type: TokenTypeData) -> PackedTokenTypeData { match token_type { TokenTypeData::erc20(erc20_data) => { // Convert ERC20Data to packed format - let (payout_type, param) = match erc20_data.distribution { - Option::None => (PAYOUT_TYPE_POSITION, 0_u16), + let (payout_type, param, param2, param3) = match erc20_data.distribution { + Option::None => (PAYOUT_TYPE_POSITION, 0_u16, 0_u16, 0_u16), Option::Some(dist) => { match dist { game_components_utilities::distribution::structs::Distribution::Linear(w) => ( - PAYOUT_TYPE_LINEAR, w, + PAYOUT_TYPE_LINEAR, w, 0_u16, 0_u16, ), game_components_utilities::distribution::structs::Distribution::Exponential(w) => ( - PAYOUT_TYPE_EXPONENTIAL, w, + PAYOUT_TYPE_EXPONENTIAL, w, 0_u16, 0_u16, ), game_components_utilities::distribution::structs::Distribution::Uniform => ( - PAYOUT_TYPE_UNIFORM, 0_u16, + PAYOUT_TYPE_UNIFORM, 0_u16, 0_u16, 0_u16, ), game_components_utilities::distribution::structs::Distribution::Custom(_) => ( - PAYOUT_TYPE_CUSTOM, 0_u16, + PAYOUT_TYPE_CUSTOM, 0_u16, 0_u16, 0_u16, ), game_components_utilities::distribution::structs::Distribution::Geometric(( a, b, - )) => (PAYOUT_TYPE_GEOMETRIC, a * 256 + b), + )) => (PAYOUT_TYPE_GEOMETRIC, a * 256 + b, 0_u16, 0_u16), + game_components_utilities::distribution::structs::Distribution::Tiered(cfg) => { + let (a, b) = cfg.head_ratio; + (PAYOUT_TYPE_TIERED, a * 256 + b, cfg.head_count, cfg.head_share_bps) + }, } }, }; @@ -142,7 +157,9 @@ fn pack_token_type(token_type: TokenTypeData) -> PackedTokenTypeData { Option::Some(c) => c, Option::None => 0_u32, }; - let packed = PackedERC20Data { amount: erc20_data.amount, payout_type, param, count }; + let packed = PackedERC20Data { + amount: erc20_data.amount, payout_type, param, count, param2, param3, + }; PackedTokenTypeData::erc20(PackedERC20DataPacking::pack(packed)) }, TokenTypeData::erc721(erc721_data) => PackedTokenTypeData::erc721(erc721_data), @@ -180,6 +197,16 @@ fn unpack_token_type(packed_token_type: PackedTokenTypeData) -> TokenTypeData { (packed.param / 256, packed.param % 256), ), ) + } else if packed.payout_type == PAYOUT_TYPE_TIERED { + Option::Some( + game_components_utilities::distribution::structs::Distribution::Tiered( + game_components_utilities::distribution::structs::TieredConfig { + head_ratio: (packed.param / 256, packed.param % 256), + head_count: packed.param2, + head_share_bps: packed.param3, + }, + ), + ) } else { Option::Some( game_components_utilities::distribution::structs::Distribution::Custom( @@ -250,7 +277,7 @@ mod packed_erc20_data_tests { fn build_packed_erc20( amount: u128, payout_type: u8, param: u16, count: u32, ) -> PackedERC20Data { - PackedERC20Data { amount, payout_type, param, count } + PackedERC20Data { amount, payout_type, param, count, param2: 0, param3: 0 } } fn assert_roundtrip(data: PackedERC20Data) { @@ -260,6 +287,21 @@ mod packed_erc20_data_tests { assert!(unpacked.payout_type == data.payout_type, "payout_type mismatch"); assert!(unpacked.param == data.param, "param mismatch"); assert!(unpacked.count == data.count, "count mismatch"); + assert!(unpacked.param2 == data.param2, "param2 mismatch"); + assert!(unpacked.param3 == data.param3, "param3 mismatch"); + } + + #[test] + fn test_tiered_params_roundtrip_in_the_widened_slots() { + let data = PackedERC20Data { + amount: 0xffffffffffffffffffffffffffffffff, // u128::MAX alongside full params + payout_type: 6, + param: 10 * 256 + 7, + count: 10000, + param2: 39, + param3: 8000, + }; + assert_roundtrip(data); } // ------------------------------------------------------------------------- diff --git a/packages/utilities/src/distribution/calculator.cairo b/packages/utilities/src/distribution/calculator.cairo index c1de9ad3..7a2e34ee 100644 --- a/packages/utilities/src/distribution/calculator.cairo +++ b/packages/utilities/src/distribution/calculator.cairo @@ -32,8 +32,9 @@ pub fn calculate_share( }, Distribution::Uniform => calculate_uniform_share(total_payouts, available_share), Distribution::Custom(shares) => calculate_custom_share(payout_index, shares), - Distribution::Geometric(_) => panic!( - "Distribution: Geometric has no basis-point form; use payout::calculate_payout", + Distribution::Geometric(_) | + Distribution::Tiered(_) => panic!( + "Distribution: no basis-point form; use payout::calculate_payout", ), } } @@ -59,8 +60,9 @@ pub fn calculate_total( let (weights, denominator) = weight_vector(distribution, total_payouts); sum_shares(@weights, denominator, available_share) }, - Distribution::Geometric(_) => panic!( - "Distribution: Geometric has no basis-point form; use payout::calculate_payout", + Distribution::Geometric(_) | + Distribution::Tiered(_) => panic!( + "Distribution: no basis-point form; use payout::calculate_payout", ), Distribution::Uniform | Distribution::Custom(_) => { @@ -132,8 +134,9 @@ pub fn calculate_share_with_dust( base_share + (available_share - total) } }, - Distribution::Geometric(_) => panic!( - "Distribution: Geometric has no basis-point form; use payout::calculate_payout", + Distribution::Geometric(_) | + Distribution::Tiered(_) => panic!( + "Distribution: no basis-point form; use payout::calculate_payout", ), Distribution::Uniform | Distribution::Custom(_) => { @@ -211,8 +214,9 @@ fn weight_vector(distribution: Distribution, total_payouts: u32) -> (Array panic!( - "Distribution: Geometric has no basis-point form; use payout::calculate_payout", + Distribution::Geometric(_) | + Distribution::Tiered(_) => panic!( + "Distribution: no basis-point form; use payout::calculate_payout", ), Distribution::Uniform | Distribution::Custom(_) => {}, } diff --git a/packages/utilities/src/distribution/payout.cairo b/packages/utilities/src/distribution/payout.cairo index ce73d2dc..6ac028da 100644 --- a/packages/utilities/src/distribution/payout.cairo +++ b/packages/utilities/src/distribution/payout.cairo @@ -66,7 +66,7 @@ //! law cannot express — a winner share that does not thin out as the field //! grows — and that is the trade. -use crate::distribution::structs::Distribution; +use crate::distribution::structs::{BASIS_POINTS, Distribution}; /// Highest supported integer exponent for `Exponential`. /// @@ -130,6 +130,21 @@ pub fn supports_exact_payout(distribution: Distribution) -> bool { // `a` and `b` are capped at 255 so both survive the single-u16 param // slot the packed storage gives a distribution (`a * 256 + b`). Distribution::Geometric((a, b)) => a > b && b > 0 && a <= 255, + // Head ratio rules are Geometric's. The share must leave both tiers + // something to pay — at 0 the head is unclaimable, at BASIS_POINTS + // the tail is, and the single-curve variants cover those shapes. + // Field-size rules (head_count within geometric reach, paid places + // beyond the head) are checked in `calculate_payout`, which knows the + // paid-place count. + Distribution::Tiered(cfg) => { + let (a, b) = cfg.head_ratio; + a > b + && b > 0 + && a <= 255 + && cfg.head_count > 0 + && cfg.head_share_bps > 0 + && cfg.head_share_bps < BASIS_POINTS + }, _ => true, } } @@ -214,6 +229,12 @@ pub(crate) fn payout_weight( )) => { int_pow(a.into(), total_payouts - payout_index) * int_pow(b.into(), payout_index - 1) }, + // Tiered is two pools, not one weight family — a single W(p)/sum(W) + // ratio cannot express "the head takes exactly head_share_bps". + // `calculate_payout` settles it before reaching the weight helpers. + Distribution::Tiered(_) => panic!( + "Distribution: Tiered is settled by calculate_payout, not weights", + ), } } @@ -254,6 +275,9 @@ pub(crate) fn payout_weight_sum(distribution: Distribution, total_payouts: u32) let bv: u256 = b.into(); (int_pow(av, total_payouts) - int_pow(bv, total_payouts)) / (av - bv) }, + Distribution::Tiered(_) => panic!( + "Distribution: Tiered is settled by calculate_payout, not weights", + ), Distribution::Custom(shares) => { // Sum only the positions that actually get paid. `payout_weight` // returns 0 for an index past `total_payouts` (and past the array), @@ -331,6 +355,34 @@ pub fn calculate_payout( return 0; } + // Tiered is two pools with independent maths, settled here rather than + // through the single-family weight helpers below. + if let Distribution::Tiered(cfg) = distribution { + let (a, b) = cfg.head_ratio; + let m: u32 = cfg.head_count.into(); + // The geometric reach bound applies to the head, not the field — + // that is exactly what lets the head stay steep on a 10,000-place + // tournament. + assert!( + m <= max_geometric_payouts(a), + "Distribution: tiered head reaches at most {} places; use a coarser ratio", + max_geometric_payouts(a), + ); + assert!( + total_payouts > m, "Distribution: tiered needs more paid places than its head covers", + ); + + let head_pool: u256 = total_amount * cfg.head_share_bps.into() / BASIS_POINTS.into(); + if payout_index <= m { + let head = Distribution::Geometric((a, b)); + return head_pool * payout_weight(head, payout_index, m) / payout_weight_sum(head, m); + } + // Every tail place takes an equal floor-division slice of what the + // head left. Truncation is per tier, so the total shortfall stays + // under one unit per paid position. + return (total_amount - head_pool) / (total_payouts - m).into(); + } + let denominator = payout_weight_sum(distribution, total_payouts); if denominator == 0 { return 0; diff --git a/packages/utilities/src/distribution/structs.cairo b/packages/utilities/src/distribution/structs.cairo index eca139ad..22ee9d6a 100644 --- a/packages/utilities/src/distribution/structs.cairo +++ b/packages/utilities/src/distribution/structs.cairo @@ -1,5 +1,5 @@ // Re-export Distribution from game_components_interfaces -pub use game_components_interfaces::distribution::Distribution; +pub use game_components_interfaces::distribution::{Distribution, TieredConfig}; use starknet::storage_access::StorePacking; /// Basis points constant: 10000 = 100% @@ -19,17 +19,24 @@ pub const DIST_TYPE_CUSTOM: u8 = 3; /// `a * 256 + b`, which is why `supports_exact_payout` caps them at 255. That /// keeps the packed layout — one u8 tag plus one u16 param — unchanged. pub const DIST_TYPE_GEOMETRIC: u8 = 4; +/// `Tiered` carries three u16s: the head ratio (packed `a * 256 + b`, as for +/// Geometric) in `dist_param`, then `head_count` and `head_share_bps` in the +/// two extra param slots added for it. +pub const DIST_TYPE_TIERED: u8 = 5; // Constants for PackedDistribution bit-packing. const TWO_POW_8: u128 = 0x100; // 2^8 const TWO_POW_24: u128 = 0x1000000; // 2^24 +const TWO_POW_56: u128 = 0x100000000000000; // 2^56 +const TWO_POW_72: u128 = 0x1000000000000000000; // 2^72 const MASK_8: u128 = 0xFF; const MASK_16: u128 = 0xFFFF; const MASK_32: u128 = 0xFFFFFFFF; /// Distribution configuration packed into a single `felt252`. /// -/// Layout: `dist_type(8) | dist_param(16) | positions(32)` = 56 bits. +/// Layout: `dist_type(8) | dist_param(16) | positions(32) | dist_param2(16) | +/// dist_param3(16)` = 88 bits. /// /// - `dist_type` — one of `DIST_TYPE_LINEAR` / `DIST_TYPE_EXPONENTIAL` / /// `DIST_TYPE_UNIFORM` / `DIST_TYPE_CUSTOM`. @@ -37,6 +44,10 @@ const MASK_32: u128 = 0xFFFFFFFF; /// - `positions` — fixed paid-places count. `0` means "dynamic — use the /// actual leaderboard size at payout time". For `Custom`, this always /// equals the backing shares array length. +/// - `dist_param2` / `dist_param3` — `head_count` and `head_share_bps` for +/// `Tiered`; 0 for every other variant. Appended above the original 56-bit +/// layout, so pre-existing packed values unpack unchanged (their high bits +/// are zero). /// /// This struct is the packed companion to `Distribution`; consumers /// rehydrate the full enum by pairing it with a `CustomShares` Vec when @@ -48,13 +59,17 @@ pub struct PackedDistribution { pub dist_type: u8, pub dist_param: u16, pub positions: u32, + pub dist_param2: u16, + pub dist_param3: u16, } pub impl PackedDistributionStorePacking of StorePacking { fn pack(value: PackedDistribution) -> felt252 { let packed: felt252 = value.dist_type.into() + (value.dist_param.into() * TWO_POW_8.into()) - + (value.positions.into() * TWO_POW_24.into()); + + (value.positions.into() * TWO_POW_24.into()) + + (value.dist_param2.into() * TWO_POW_56.into()) + + (value.dist_param3.into() * TWO_POW_72.into()); packed } @@ -64,8 +79,10 @@ pub impl PackedDistributionStorePacking of StorePacking 0, "last-place share"); } + +// ==================== Tiered ==================== +// +// The variant that exists for very large fields: a geometric head with a real +// first prize, and a flat tail that still pays every remaining place. No +// single curve can do both — steep enough for the head zeroes the tail, flat +// enough for the tail erases the head. + +fn flagship_tiered() -> Distribution { + // Top 39 places on a 70% geometric decay take 80% of the pool; the other + // places split 20% evenly. + Distribution::Tiered(TieredConfig { head_ratio: (10, 7), head_count: 39, head_share_bps: 8000 }) +} + +#[test] +fn test_tiered_pays_a_headline_first_prize_over_ten_thousand_places() { + let pool: u256 = 1_000_000_000_000_000_000; + let n: u32 = 10000; + let dist = flagship_tiered(); + + // First place takes 24% of the whole pool — a single curve tops out at + // ~0.06% (Exponential k=5) over the same field. + assert!(calculate_payout(dist, 1, n, pool) == 240000218290681776, "first place"); + assert!(calculate_payout(dist, 2, n, pool) == 168000152803477243, "second place"); + // The last head place still clears its geometric slice... + assert!(calculate_payout(dist, 39, n, pool) == 311843831108, "head boundary"); + // ...and every tail place gets an identical, non-zero share of the rest. + let tail = calculate_payout(dist, 40, n, pool); + assert!(tail == 20078305391024, "first tail place"); + assert!(calculate_payout(dist, 10000, n, pool) == tail, "last place matches"); +} + +#[test] +fn test_tiered_conserves_the_pool() { + let pool: u256 = 1_000_000_000_000_000_000; + let n: u32 = 10000; + let dist = flagship_tiered(); + + // Head positions summed directly; the identical tail slices multiplied. + let mut total: u256 = 0; + let mut p: u32 = 1; + while p <= 39 { + total += calculate_payout(dist, p, n, pool); + p += 1; + } + total += calculate_payout(dist, 40, n, pool) * (n - 39).into(); + + assert!(total <= pool, "never overpays"); + assert!(pool - total < n.into(), "shortfall under one unit per position, got {}", pool - total); +} + +#[test] +fn test_tiered_shape_guards() { + // The head follows Geometric's ratio rules; the share must leave both + // tiers something to pay. + let ok = TieredConfig { head_ratio: (10, 7), head_count: 39, head_share_bps: 8000 }; + assert!(supports_exact_payout(Distribution::Tiered(ok)), "valid config"); + + let inverted = TieredConfig { head_ratio: (7, 10), head_count: 10, head_share_bps: 8000 }; + assert!(!supports_exact_payout(Distribution::Tiered(inverted)), "inverted ratio"); + + let all_to_head = TieredConfig { head_ratio: (10, 7), head_count: 10, head_share_bps: 10000 }; + assert!(!supports_exact_payout(Distribution::Tiered(all_to_head)), "tail would be zero"); + + let nothing_to_head = TieredConfig { head_ratio: (10, 7), head_count: 10, head_share_bps: 0 }; + assert!(!supports_exact_payout(Distribution::Tiered(nothing_to_head)), "head would be zero"); + + let empty_head = TieredConfig { head_ratio: (10, 7), head_count: 0, head_share_bps: 8000 }; + assert!(!supports_exact_payout(Distribution::Tiered(empty_head)), "headless"); +} + +#[test] +#[should_panic(expected: "tiered head reaches at most 39 places")] +fn test_tiered_head_beyond_geometric_reach_is_refused() { + let dist = Distribution::Tiered( + TieredConfig { head_ratio: (10, 7), head_count: 40, head_share_bps: 8000 }, + ); + calculate_payout(dist, 1, 10000, 1_000_000_000_000_000_000); +} + +#[test] +#[should_panic(expected: "tiered needs more paid places than its head covers")] +fn test_tiered_field_must_extend_past_the_head() { + let dist = Distribution::Tiered( + TieredConfig { head_ratio: (10, 7), head_count: 39, head_share_bps: 8000 }, + ); + calculate_payout(dist, 1, 39, 1_000_000_000_000_000_000); +} + +#[test] +fn test_tiered_small_field_hand_check() { + // n=10, head = top 3 on (2,1) with 60% of a 10,000 pool. + // Head weights 4/2/1 of 7: 6000*4/7=3428, 6000*2/7=1714, 6000*1/7=857. + // Tail: 4000 / 7 places = 571 each. + let dist = Distribution::Tiered( + TieredConfig { head_ratio: (2, 1), head_count: 3, head_share_bps: 6000 }, + ); + assert!(calculate_payout(dist, 1, 10, 10000) == 3428, "p1"); + assert!(calculate_payout(dist, 2, 10, 10000) == 1714, "p2"); + assert!(calculate_payout(dist, 3, 10, 10000) == 857, "p3"); + assert!(calculate_payout(dist, 4, 10, 10000) == 571, "p4"); + assert!(calculate_payout(dist, 10, 10, 10000) == 571, "p10"); + assert!(calculate_payout(dist, 11, 10, 10000) == 0, "out of range"); +} + +// Cost: two O(1) tiers. +#[test] +fn bench_payout_tiered_n10000_pos1() { + let share = calculate_payout(flagship_tiered(), 1, 10000, 1_000_000_000_000_000_000); + assert!(share > 0, "winner share"); +} + +#[test] +fn bench_payout_tiered_n10000_pos_last() { + let share = calculate_payout(flagship_tiered(), 10000, 10000, 1_000_000_000_000_000_000); + assert!(share > 0, "last-place share"); +} From eec6466ca6dc9c505f44fda1772732fd085f867a Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:57:12 -0700 Subject: [PATCH 10/11] =?UTF-8?q?docs(distribution):=20close=20the=20enum?= =?UTF-8?q?=20=E2=80=94=20curves=20are=20core,=20integrations=20are=20exte?= =?UTF-8?q?nsions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shape space is covered (flat / linear / polynomial / scale-free / two-tier / arbitrary), Custom is the escape hatch for exotic fixed-field shapes, and every variant costs consumer contracts real bytecode against the 81,920-felt class limit — Geometric + Tiered cost Budokan ~3,000 felts, leaving it ~95% full. Records the decision and a choosing table so the next person reaching for variant #7 hits the reasoning first. Co-Authored-By: Claude Opus 5 (1M context) --- packages/interfaces/src/distribution.cairo | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/interfaces/src/distribution.cairo b/packages/interfaces/src/distribution.cairo index b821f69d..4a257b23 100644 --- a/packages/interfaces/src/distribution.cairo +++ b/packages/interfaces/src/distribution.cairo @@ -1,3 +1,37 @@ +/// 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, From afcb367f4e8aed53235ae83db9a50e44c54e447b Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:15:09 -0700 Subject: [PATCH 11/11] fix(distribution): own the 8-bit ratio bound at the pack sites; document the pool floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the [HIGH] and [MEDIUM] from the Claude review. [HIGH] `Geometric`/`Tiered` ratio terms pack as `a * 256 + b` into one u16 param slot, but the pack sites never checked the terms fit. Through Budokan that is unreachable — its creation-time validation enforces a <= 255 before any pack — but these components are a library: a host that skipped its own validation would hit a u16 overflow panic for a >= 256, or worse, values that still fit u16 would unpack as a silently different ratio (a=10, b=300 packs to 2860 and unpacks as (11, 44)). Both pack sites now assert the bound with a named message; a test drives the prize path to it. [MEDIUM] The module advertised "no unclaimable positions" without stating the condition: every curve floors a position to zero when its true share is under one indivisible unit. For `Tiered` the tail is the binding case — each tail place pays iff `total * (BASIS_POINTS - head_share_bps) / BASIS_POINTS >= n - m`. The docs now say so, and point hosts that know the pool at creation at validating the last place, which Budokan's `add_prize` now does. snforge: metagame 439 passed, utilities 309 passed. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/entry_fee/entry_fee_store.cairo | 14 +++++++++- packages/metagame/src/prize/structs.cairo | 14 +++++++++- .../src/prize/tests/test_prize_store.cairo | 28 +++++++++++++++++++ .../utilities/src/distribution/payout.cairo | 20 +++++++++---- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/metagame/src/entry_fee/entry_fee_store.cairo b/packages/metagame/src/entry_fee/entry_fee_store.cairo index 97e4f18f..efe69994 100644 --- a/packages/metagame/src/entry_fee/entry_fee_store.cairo +++ b/packages/metagame/src/entry_fee/entry_fee_store.cairo @@ -249,9 +249,21 @@ pub impl EntryFeeStoreImpl, +Drop> of EntryFeeStoreTrait { Distribution::Custom(_) => (DIST_TYPE_CUSTOM, 0_u16, 0_u16, 0_u16), Distribution::Geometric(( a, b, - )) => (DIST_TYPE_GEOMETRIC, *a * 256 + *b, 0_u16, 0_u16), + )) => { + // 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) }, }, diff --git a/packages/metagame/src/prize/structs.cairo b/packages/metagame/src/prize/structs.cairo index 9ef59664..79ea0141 100644 --- a/packages/metagame/src/prize/structs.cairo +++ b/packages/metagame/src/prize/structs.cairo @@ -145,9 +145,21 @@ fn pack_token_type(token_type: TokenTypeData) -> PackedTokenTypeData { ), game_components_utilities::distribution::structs::Distribution::Geometric(( a, b, - )) => (PAYOUT_TYPE_GEOMETRIC, a * 256 + b, 0_u16, 0_u16), + )) => { + // Bound owned at the pack site — see the + // entry-fee store twin for the rationale. + assert!( + a <= 255 && b <= 255, + "Prize: geometric ratio terms must fit 8 bits", + ); + (PAYOUT_TYPE_GEOMETRIC, a * 256 + b, 0_u16, 0_u16) + }, game_components_utilities::distribution::structs::Distribution::Tiered(cfg) => { let (a, b) = cfg.head_ratio; + assert!( + a <= 255 && b <= 255, + "Prize: geometric ratio terms must fit 8 bits", + ); (PAYOUT_TYPE_TIERED, a * 256 + b, cfg.head_count, cfg.head_share_bps) }, } diff --git a/packages/metagame/src/prize/tests/test_prize_store.cairo b/packages/metagame/src/prize/tests/test_prize_store.cairo index 18b84d14..e04c1ba1 100644 --- a/packages/metagame/src/prize/tests/test_prize_store.cairo +++ b/packages/metagame/src/prize/tests/test_prize_store.cairo @@ -419,3 +419,31 @@ fn test_add_prize_custom_distribution_round_trip_through_packed_storage() { Prize::Extension(_) => panic!("expected token prize"), } } + +/// The two geometric ratio terms share one u16 param slot (a*256 + b), so the +/// pack site owns the 8-bit bound — a host that skipped its own validation +/// must hit a named assert here, not a u16 overflow or a silently different +/// ratio on unpack. +#[test] +#[should_panic(expected: "Prize: geometric ratio terms must fit 8 bits")] +fn test_geometric_ratio_past_8_bits_refused_at_pack() { + let mock = deploy(); + // Escrow precedes packing, so satisfy the transfer to reach the assert. + mock_call(addr(0xE2C20), selector!("transfer_from"), true, 1); + mock + .add_prize( + 1, + Prize::Token( + TokenPrizePayload { + token_address: addr(0xE2C20), + token_type: TokenTypeData::erc20( + ERC20Data { + amount: 10_000, + distribution: Option::Some(Distribution::Geometric((300, 7))), + distribution_count: Option::Some(10), + }, + ), + }, + ), + ); +} diff --git a/packages/utilities/src/distribution/payout.cairo b/packages/utilities/src/distribution/payout.cairo index 6ac028da..4d684e24 100644 --- a/packages/utilities/src/distribution/payout.cairo +++ b/packages/utilities/src/distribution/payout.cairo @@ -17,12 +17,20 @@ //! 1. **O(1), independent of the number of paid places.** No per-position //! loop, no allocation, no `pow`. A 1000-place prize costs the same as a //! 2-place one. -//! 2. **No unclaimable positions.** The smallest payout is 1 wei rather than -//! 1 basis point, so a tail position only rounds away when its true share -//! is under one indivisible unit of the token. Under the basis-point path, -//! a steep 100-place curve silently gives late positions exactly 0 — and -//! Budokan asserts `prize_amount > 0`, so those players cannot claim at -//! all. +//! 2. **No unclaimable positions — for a sufficient pool.** The smallest +//! payout is 1 wei rather than 1 basis point, so a tail position only +//! rounds away when its true share is under one indivisible unit of the +//! token. Under the basis-point path, a steep 100-place curve silently +//! gives late positions exactly 0 — and Budokan asserts +//! `prize_amount > 0`, so those players cannot claim at all. +//! +//! The guarantee is conditional on the pool: any curve floors to zero +//! when a position's true share is under one unit. For `Tiered` the tail +//! is the binding case — every tail place pays iff +//! `total * (BASIS_POINTS - head_share_bps) / BASIS_POINTS >= n - m`. +//! Hosts that know the pool at creation should verify the *last* place +//! pays at least one unit and refuse the configuration otherwise, which +//! is what Budokan's `add_prize` does. //! 3. **Dust stops mattering.** Basis-point truncation strands up to //! `n / 10000` of the pool (0.5% of a 100-place prize), which is why //! `calculate_share_with_dust` exists and why payout index 1 has to sum