diff --git a/packages/interfaces/src/distribution.cairo b/packages/interfaces/src/distribution.cairo index c514bb9a..4a257b23 100644 --- a/packages/interfaces/src/distribution.cairo +++ b/packages/interfaces/src/distribution.cairo @@ -1,7 +1,92 @@ +/// How a pool is split across paid places. +/// +/// ## This enum is closed — do not add variants +/// +/// The shape space is covered: flat (`Uniform`), linear, polynomial +/// (`Exponential`), scale-free decay (`Geometric`), headline-plus-tail +/// (`Tiered`), and arbitrary (`Custom`). Before reaching for variant #7: +/// +/// - **A shape these can't express, with a fixed field?** Use `Custom` — it +/// encodes any curve exactly, up to its packed-storage ceiling. That is the +/// escape hatch; it removes the need for the enum to grow. +/// - **Anything involving external state** — dynamic payouts, oracle-driven +/// amounts, streaming, vesting? That is an integration, not a curve: use a +/// prize/entry-fee *extension*, which exists precisely for logic the host +/// cannot know about. +/// +/// Every variant added here ripples through Serde (events, calldata, +/// indexers, SDKs, clients) and two packed-storage layouts, and costs +/// consumer-contract bytecode against Starknet's 81,920-felt class limit — +/// adding `Geometric` + `Tiered` cost Budokan ~3,000 felts, leaving it ~95% +/// full. Curves are core; integrations are extensions. +/// +/// ## Choosing a variant +/// +/// | you want | use | notes | +/// | --- | --- | --- | +/// | everyone equal | `Uniform` | cheapest | +/// | gentle gradient | `Linear(w)` | any weight | +/// | steeper gradient, small field | `Exponential(10*k)` | k in 1..=5; 1st ≈ (k+1)/n | +/// | "each place gets X% of the one above" | `Geometric(a, b)` | 1st ≈ 1 - b/a at any field size; +/// reach bounded by ratio | +/// | headline 1st prize AND thousands of paid places | `Tiered` | the only variant that serves a +/// very large field | +/// | exact hand-authored percentages | `Custom(shares)` | fixed field only | #[derive(Drop, Copy, Serde, PartialEq)] pub enum Distribution { Linear: u16, Exponential: u16, Uniform, Custom: Span, + /// Geometric decay as a rational ratio `(a, b)`: each position receives + /// `b / a` of the one above it, so `W(p) = a^(n-p) * b^(p-1)`. Requires + /// `a > b > 0` — e.g. `(10, 7)` is "each place gets 70% of the previous". + /// + /// Unlike `Exponential` — which is a power law, and whose winner share + /// falls off as roughly `(k+1)/n` — a geometric curve's shape does not + /// depend on the size of the field: first place takes about `1 - b/a` of + /// the pool whether there are 10 paid places or 100. That is the shape a + /// headline first prize actually needs, and no `Exponential` weight + /// produces it over a large field. + /// + /// The trade is reach: the weights span `(a/b)^n`, so the representable + /// field size shrinks as the ratio gets finer. See + /// `max_geometric_payouts`. + /// + /// NOTE: appended deliberately. Serde indices are positional, so inserting + /// this anywhere earlier would silently reinterpret every stored and + /// indexed distribution. + Geometric: (u16, u16), + /// Two tiers: a geometric head over the first `head_count` places taking + /// `head_share_bps` of the pool, and the remaining places splitting the + /// rest evenly. + /// + /// This is the only family here that works for a very large field. A + /// single curve cannot: anything steep enough to give first place a real + /// share rounds its tail to nothing, and anything flat enough to pay the + /// tail gives first place nothing. Over 10,000 places the best a single + /// curve can do for first place is ~0.06% (`Exponential` k=5); a + /// `Geometric` head of 39 on (10, 7) taking 80% pays first place 24%, + /// while every one of the other 9,961 places still receives its slice of + /// the remaining 20%. + /// + /// The geometric reach bound applies to `head_count`, not the field, so + /// the head is always well inside it. Requires a fixed paid-places count + /// strictly greater than `head_count`. + Tiered: TieredConfig, +} + +/// Configuration for `Distribution::Tiered`. +#[derive(Drop, Copy, Serde, PartialEq)] +pub struct TieredConfig { + /// Geometric decay `(a, b)` for the head: each place gets `b / a` of the + /// one above. Same semantics and validity rules as `Geometric`. + pub head_ratio: (u16, u16), + /// How many places the head covers. Must be under the paid-places count + /// and within `max_geometric_payouts(a)`. + pub head_count: u16, + /// The head's slice of the pool, in basis points. Strictly between 0 and + /// 10000 — at either extreme one of the tiers would round to an + /// unclaimable zero, and the single-curve variants cover those shapes. + pub head_share_bps: u16, } diff --git a/packages/metagame/src/entry_fee/entry_fee_store.cairo b/packages/metagame/src/entry_fee/entry_fee_store.cairo index a35e2b28..efe69994 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_TIERED, DIST_TYPE_UNIFORM, PackedDistribution, TieredConfig, }; use starknet::ContractAddress; use crate::entry_fee::store::Store; @@ -98,6 +98,30 @@ 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( + 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 @@ -216,13 +240,32 @@ 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::Linear(w) => (DIST_TYPE_LINEAR, *w, 0_u16, 0_u16), + Distribution::Exponential(w) => (DIST_TYPE_EXPONENTIAL, *w, 0_u16, 0_u16), + Distribution::Uniform => (DIST_TYPE_UNIFORM, 0_u16, 0_u16, 0_u16), + Distribution::Custom(_) => (DIST_TYPE_CUSTOM, 0_u16, 0_u16, 0_u16), + Distribution::Geometric(( + a, b, + )) => { + // The two ratio terms share one u16 param slot as + // a*256 + b. Values past 255 would either panic on the + // u16 multiply or unpack as a silently different ratio, + // so the bound is owned here, not left to the host. + assert!( + *a <= 255 && *b <= 255, "EntryFee: geometric ratio terms must fit 8 bits", + ); + (DIST_TYPE_GEOMETRIC, *a * 256 + *b, 0_u16, 0_u16) + }, + Distribution::Tiered(cfg) => { + let (a, b) = *cfg.head_ratio; + assert!( + a <= 255 && b <= 255, "EntryFee: geometric ratio terms must fit 8 bits", + ); + (DIST_TYPE_TIERED, a * 256 + b, *cfg.head_count, *cfg.head_share_bps) + }, }, }; // For Custom, paid places are defined by the shares array length; @@ -235,7 +278,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/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/structs.cairo b/packages/metagame/src/prize/structs.cairo index 44158ea5..79ea0141 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 @@ -24,9 +25,12 @@ 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; +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 { @@ -34,6 +38,8 @@ struct PackedERC20Data { payout_type: u8, param: u16, count: u32, + param2: u16, + param3: u16, } /// u128-aligned StorePacking for PackedERC20Data. @@ -53,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() @@ -66,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(), } } } @@ -115,22 +127,41 @@ 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, + )) => { + // 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) + }, } }, }; @@ -138,7 +169,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), @@ -170,6 +203,22 @@ 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 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( @@ -240,7 +289,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) { @@ -250,6 +299,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/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..e04c1ba1 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"), } @@ -410,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.cairo b/packages/utilities/src/distribution.cairo index 84c1e468..54fc54ed 100644 --- a/packages/utilities/src/distribution.cairo +++ b/packages/utilities/src/distribution.cairo @@ -1,3 +1,7 @@ pub mod calculator; pub mod packed_shares; +pub mod payout; 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..7a2e34ee 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,14 +25,17 @@ 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), + Distribution::Geometric(_) | + Distribution::Tiered(_) => panic!( + "Distribution: no basis-point form; use payout::calculate_payout", + ), } } @@ -42,16 +45,39 @@ 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::Geometric(_) | + Distribution::Tiered(_) => panic!( + "Distribution: no basis-point form; use payout::calculate_payout", + ), + 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 +109,153 @@ 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::Geometric(_) | + Distribution::Tiered(_) => panic!( + "Distribution: no basis-point form; use payout::calculate_payout", + ), + 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::Geometric(_) | + Distribution::Tiered(_) => panic!( + "Distribution: no basis-point form; use payout::calculate_payout", + ), + 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/payout.cairo b/packages/utilities/src/distribution/payout.cairo new file mode 100644 index 00000000..4d684e24 --- /dev/null +++ b/packages/utilities/src/distribution/payout.cairo @@ -0,0 +1,401 @@ +// 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 — 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 +//! 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[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 +//! 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. +//! +//! ## Naming note +//! +//! `Exponential` is a power law, not an exponential: `W(p)` is polynomial in +//! 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::{BASIS_POINTS, 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; + +/// 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 +/// 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 + }, + // 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, + // 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, + } +} + +/// 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) + } +} + +/// 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 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 +} + +/// The unnormalized integer weight of one payout position, 1-indexed. +/// Returns 0 when `payout_index` falls outside 1..=total_payouts. +pub(crate) 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() + } + }, + // 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) + }, + // 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", + ), + } +} + +/// 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(crate) 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, + // 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::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), + // 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 = if shares.len() < total_payouts { + shares.len() + } else { + total_payouts + }; + 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. +/// +/// ## 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 { + assert!( + supports_exact_payout(distribution), + "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; + } + + // 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; + } + + let weight = payout_weight(distribution, payout_index, total_payouts); + total_amount * weight / denominator +} diff --git a/packages/utilities/src/distribution/structs.cairo b/packages/utilities/src/distribution/structs.cairo index fb40efb9..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% @@ -15,17 +15,28 @@ 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; +/// `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`. @@ -33,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 @@ -44,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 } @@ -60,8 +79,10 @@ pub impl PackedDistributionStorePacking of StorePacking 0`. Hence the relaxed +//! 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 +// ========================================================================== + +#[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"); +} + +// ========================================================================== +// 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) +// ========================================================================== + +#[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"); +} + +#[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"); +} + +// ========================================================================== +// 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..85de4def --- /dev/null +++ b/packages/utilities/src/distribution/tests/test_payout.cairo @@ -0,0 +1,487 @@ +// 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, max_geometric_payouts, payout_weight, payout_weight_sum, + supports_exact_payout, +}; +use crate::distribution::structs::{BASIS_POINTS, Distribution, TieredConfig}; + +/// 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"); +} + +/// 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"); +} + +// ==================== 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"); +} + +// ==================== 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"); +} 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", + ); + }; +}