-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcalculator.cairo
More file actions
1119 lines (939 loc) · 45.8 KB
/
Copy pathcalculator.cairo
File metadata and controls
1119 lines (939 loc) · 45.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-License-Identifier: BUSL-1.1
//! Pure calculation functions for distribution share computation.
//! These functions are stateless and can be used without the DistributionComponent.
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
/// Returns the share (0-10000) for the specified payout index
///
/// # Arguments
/// * `distribution` - The distribution type to use (includes custom shares if Custom variant)
/// * `payout_index` - 1-indexed payout (1 = first payout, 2 = second payout, etc.)
/// * `total_payouts` - Total number of payouts to distribute across
/// * `available_share` - Total share to distribute in basis points (10000 = 100%)
///
/// # Returns
/// Share for the payout index in basis points
pub fn calculate_share(
distribution: Distribution, payout_index: u32, total_payouts: u32, available_share: u16,
) -> u16 {
if payout_index == 0 || payout_index > total_payouts || available_share == 0 {
return 0;
}
match distribution {
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),
}
}
/// Calculate the sum of all payout shares to verify they equal available_share
/// This is useful for validation and ensuring no rounding errors cause issues
/// Returns total in basis points
pub fn calculate_total(
distribution: Distribution, total_payouts: u32, available_share: u16,
) -> u16 {
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
},
}
}
/// Calculate the rounding dust (difference between available_share and sum of all shares)
/// This dust should be added to the first payout (winner) to ensure 100% distribution
/// Returns the dust amount in basis points
pub fn calculate_dust(distribution: Distribution, total_payouts: u32, available_share: u16) -> u16 {
let total = calculate_total(distribution, total_payouts, available_share);
if total > available_share {
// This shouldn't happen, but handle gracefully
0
} else {
available_share - total
}
}
/// Calculate share with dust allocation for payout_index 1 (winner)
/// This ensures that all available_share is distributed by giving the rounding remainder
/// to payout_index 1 (winner) (the winner). Use this for actual prize distribution to prevent stuck
/// funds.
///
/// # Arguments
/// * `distribution` - The distribution type to use (includes custom shares if Custom variant)
/// * `payout_index` - 1-indexed payout (1 = first payout, 2 = second payout, etc.)
/// * `total_payouts` - Total number of payouts to distribute across
/// * `available_share` - Total share to distribute in basis points (10000 = 100%)
///
/// # Returns
/// Share for the payout index in basis points, with dust added to payout_index 1 (winner)
pub fn calculate_share_with_dust(
distribution: Distribution, payout_index: u32, total_payouts: u32, available_share: u16,
) -> u16 {
// 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);
}
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)
},
}
}
/// 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>, Fixed) {
let mut weights: Array<Fixed> = 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(_) => {},
}
(weights, denominator)
}
/// 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<Fixed>, denominator: Fixed, payout_index: u32, available_share: u16,
) -> u16 {
if payout_index == 0 || payout_index > weights.len() || denominator == FixedTrait::ZERO() {
return 0;
}
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 share_fp = ratio_fp * available_fp;
let share_u64: u64 = share_fp.try_into().unwrap_or(0);
share_u64.try_into().unwrap_or(0)
}
/// 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<Fixed>, denominator: Fixed, available_share: u16) -> u16 {
let mut total: u16 = 0;
let mut p: u32 = 1;
let len = weights.len();
loop {
if p > len {
break;
}
total += share_at(weights, denominator, p, available_share);
p += 1;
}
total
}
/// Calculate uniform distribution - all payouts get equal share
/// Returns share in basis points
fn calculate_uniform_share(total_payouts: u32, available_share: u16) -> u16 {
if total_payouts == 0 {
return 0;
}
// Each payout gets available_share / total_payouts
let share: u32 = available_share.into() / total_payouts;
share.try_into().unwrap_or(0)
}
/// Calculate custom distribution share from provided shares array
/// Returns share in basis points
fn calculate_custom_share(payout_index: u32, shares: Span<u16>) -> u16 {
// payout_index is 1-indexed, array is 0-indexed
let index: u32 = payout_index - 1;
if index >= shares.len() {
return 0;
}
let share: u16 = *shares.at(index);
share
}
#[cfg(test)]
mod tests {
use crate::distribution::structs::{BASIS_POINTS, Distribution};
use super::{calculate_dust, calculate_share, calculate_share_with_dust, calculate_total};
#[test]
fn test_linear_distribution_3_payouts() {
// With 3 payouts and weight 10 (1.0): sum = 1+2+3 = 6
// Payout index 1 gets 3/6 = 50%, Position 2 gets 2/6 = 33%, Position 3 gets 1/6 = 17%
let dist = Distribution::Linear(10);
let share1 = calculate_share(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 3, BASIS_POINTS);
assert!(share1 >= 4950 && share1 <= 5050, "Payout index 1 should get ~50%");
assert!(share2 >= 3300 && share2 <= 3400, "Payout index 2 should get ~33%");
assert!(share3 >= 1600 && share3 <= 1700, "Payout index 3 should get ~17%");
}
#[test]
fn test_uniform_distribution() {
let dist = Distribution::Uniform;
let share1 = calculate_share(dist, 1, 4, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 4, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 4, BASIS_POINTS);
let share4 = calculate_share(dist, 4, 4, BASIS_POINTS);
// Each payout gets 10000 / 4 = 2500 bp (25%)
assert!(share1 == 2500, "All payouts should get 25%");
assert!(share2 == 2500, "All payouts should get 25%");
assert!(share3 == 2500, "All payouts should get 25%");
assert!(share4 == 2500, "All payouts should get 25%");
}
#[test]
fn test_custom_distribution() {
let dist = Distribution::Custom(
array![5000_u16, 3000_u16, 2000_u16].span(),
); // 50%, 30%, 20%
let share1 = calculate_share(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 3, BASIS_POINTS);
assert!(share1 == 5000, "Payout index 1 should get 50%");
assert!(share2 == 3000, "Payout index 2 should get 30%");
assert!(share3 == 2000, "Payout index 3 should get 20%");
}
#[test]
fn test_exponential_distribution_low_weight() {
// Test with low weight (2) - should be close to linear
let dist = Distribution::Exponential(2);
let share1 = calculate_share(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 3, BASIS_POINTS);
// Payout index 1 should get more than payout index 2, which gets more than payout index 3
assert!(share1 > share2, "Payout index 1 should get more than payout index 2");
assert!(share2 > share3, "Payout index 2 should get more than payout index 3");
// Total should sum to approximately 100%
let total = share1 + share2 + share3;
assert!(total >= 9900 && total <= BASIS_POINTS, "Total should be close to 100%");
}
#[test]
fn test_exponential_distribution_medium_weight() {
// Test with medium weight (50) - moderate steepness
let dist = Distribution::Exponential(50);
let share1 = calculate_share(dist, 1, 5, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 5, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 5, BASIS_POINTS);
let share4 = calculate_share(dist, 4, 5, BASIS_POINTS);
let share5 = calculate_share(dist, 5, 5, BASIS_POINTS);
// Verify decreasing shares - payout index 1 should get most
assert!(share1 > share2, "Payout index 1 > Payout index 2");
assert!(share1 > share3, "Payout index 1 > Payout index 3");
assert!(share1 > share4, "Payout index 1 > Payout index 4");
assert!(share1 > share5, "Payout index 1 > Payout index 5");
// Payout index 1 should get significantly more than last payout
assert!(
share1 > share5 * 10, "Payout index 1 should get >10x payout index 5 with weight 50",
);
// Total should sum to approximately 100%
let total = share1 + share2 + share3 + share4 + share5;
assert!(total >= 9900 && total <= BASIS_POINTS, "Total should be close to 100%");
}
#[test]
fn test_exponential_distribution_high_weight() {
// Test with high weight (90) - very steep distribution
let dist = Distribution::Exponential(90);
let share1 = calculate_share(dist, 1, 5, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 5, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 5, BASIS_POINTS);
let share4 = calculate_share(dist, 4, 5, BASIS_POINTS);
let share5 = calculate_share(dist, 5, 5, BASIS_POINTS);
// Verify payout index 1 gets most
assert!(share1 > share2, "Payout index 1 > Payout index 2");
assert!(share1 > share3, "Payout index 1 > Payout index 3");
assert!(share1 > share5, "Payout index 1 > Payout index 5");
// With high weight, payout index 1 should dominate
assert!(share1 > 7000, "High weight should give payout index 1 >70%");
// Lower payout indices should get very small shares
assert!(share5 < 100, "Payout index 5 should get <1% with high weight");
// Total should sum to approximately 100%
let total = share1 + share2 + share3 + share4 + share5;
assert!(total >= 9900 && total <= BASIS_POINTS, "Total should be close to 100%");
}
#[test]
fn test_exponential_weight_1() {
// Weight 1 should give linear-like distribution
let dist = Distribution::Exponential(1);
let share1 = calculate_share(dist, 1, 4, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 4, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 4, BASIS_POINTS);
let share4 = calculate_share(dist, 4, 4, BASIS_POINTS);
// Weight 1 means no exponentiation, so shares decrease linearly
assert!(share1 > share2, "Payout index 1 > Payout index 2");
assert!(share2 > share3, "Payout index 2 > Payout index 3");
assert!(share3 > share4, "Payout index 3 > Payout index 4");
// Total should sum to approximately 100%
let total = share1 + share2 + share3 + share4;
assert!(total >= 9900 && total <= BASIS_POINTS, "Total should be close to 100%");
}
#[test]
fn test_exponential_weight_100() {
// Weight 1000 (100.0) - extremely steep
let dist = Distribution::Exponential(1000);
let share1 = calculate_share(dist, 1, 10, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 10, BASIS_POINTS);
let share10 = calculate_share(dist, 10, 10, BASIS_POINTS);
// Payout index 1 should get almost everything
assert!(share1 > 9000, "Weight 100 should give payout index 1 >90%");
// Payout index 2 should still get something, but very little
assert!(share2 < 1000, "Payout index 2 should get <10%");
// Last payout should get negligible amount
assert!(share10 < 10, "Payout index 10 should get <0.1%");
}
#[test]
fn test_calculate_total_linear() {
let dist = Distribution::Linear(1);
let total = calculate_total(dist, 3, BASIS_POINTS);
// Due to rounding, total may be slightly less than BASIS_POINTS
assert!(total >= 9900 && total <= BASIS_POINTS, "Total should be close to 100%");
}
#[test]
fn test_calculate_total_uniform() {
let dist = Distribution::Uniform;
let total = calculate_total(dist, 4, BASIS_POINTS);
assert!(total == BASIS_POINTS, "Uniform total should be exactly 100%");
}
#[test]
fn test_calculate_total_custom() {
let dist = Distribution::Custom(array![5000_u16, 3000_u16, 2000_u16].span());
let total = calculate_total(dist, 3, BASIS_POINTS);
assert!(total == BASIS_POINTS, "Custom total should be exactly 100%");
}
#[test]
fn test_invalid_payout_index_returns_zero() {
let dist = Distribution::Linear(1);
// Payout index 0 is invalid
let share0 = calculate_share(dist, 0, 3, BASIS_POINTS);
assert!(share0 == 0, "Payout index 0 should return 0");
// Payout index beyond total_payouts is invalid
let share4 = calculate_share(dist, 4, 3, BASIS_POINTS);
assert!(share4 == 0, "Payout index beyond total should return 0");
}
#[test]
fn test_zero_available_share() {
let dist = Distribution::Linear(1);
let share = calculate_share(dist, 1, 3, 0);
assert!(share == 0, "Zero available share should return 0");
}
#[test]
fn test_linear_distribution_10_payouts() {
// Test linear with 10 payouts to verify the formula works with larger numbers
let dist = Distribution::Linear(10); // weight 1.0
let share1 = calculate_share(dist, 1, 10, BASIS_POINTS);
let share5 = calculate_share(dist, 5, 10, BASIS_POINTS);
let share10 = calculate_share(dist, 10, 10, BASIS_POINTS);
// Verify decreasing pattern
assert!(share1 > share5, "Payout index 1 should get more than payout index 5");
assert!(share5 > share10, "Payout index 5 should get more than payout index 10");
// Payout index 1 gets 10/55 ≈ 18.18%
assert!(share1 >= 1800 && share1 <= 1820, "Payout index 1 should get ~18.18%");
// Payout index 10 gets 1/55 ≈ 1.82%
assert!(share10 >= 180 && share10 <= 200, "Payout index 10 should get ~1.82%");
// Verify total sums correctly
let total = calculate_total(dist, 10, BASIS_POINTS);
assert!(total >= 9900 && total <= BASIS_POINTS, "Total should be close to 100%");
}
#[test]
fn test_linear_distribution_exact_values() {
// Test with 5 payouts: sum = 1+2+3+4+5 = 15
let dist = Distribution::Linear(10); // weight 1.0
let share1 = calculate_share(dist, 1, 5, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 5, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 5, BASIS_POINTS);
let share4 = calculate_share(dist, 4, 5, BASIS_POINTS);
let share5 = calculate_share(dist, 5, 5, BASIS_POINTS);
// Payout index 1: 5/15 = 33.33%
assert!(share1 >= 3330 && share1 <= 3340, "Payout index 1 should get ~33.33%");
// Payout index 2: 4/15 = 26.67%
assert!(share2 >= 2660 && share2 <= 2670, "Payout index 2 should get ~26.67%");
// Payout index 3: 3/15 = 20%
assert!(share3 >= 1995 && share3 <= 2005, "Payout index 3 should get ~20%");
// Payout index 4: 2/15 = 13.33%
assert!(share4 >= 1330 && share4 <= 1340, "Payout index 4 should get ~13.33%");
// Payout index 5: 1/15 = 6.67%
assert!(share5 >= 665 && share5 <= 670, "Payout index 5 should get ~6.67%");
}
#[test]
fn test_uniform_distribution_10_payouts() {
let dist = Distribution::Uniform;
// Each payout gets 10000 / 10 = 1000 bp (10%)
let share1 = calculate_share(dist, 1, 10, BASIS_POINTS);
let share5 = calculate_share(dist, 5, 10, BASIS_POINTS);
let share10 = calculate_share(dist, 10, 10, BASIS_POINTS);
assert!(share1 == 1000, "Each payout should get exactly 10%");
assert!(share5 == 1000, "Each payout should get exactly 10%");
assert!(share10 == 1000, "Each payout should get exactly 10%");
// Total should be exact
let total = calculate_total(dist, 10, BASIS_POINTS);
assert!(total == BASIS_POINTS, "Uniform total should be exactly 100%");
}
#[test]
fn test_uniform_distribution_odd_payouts() {
let dist = Distribution::Uniform;
// 10000 / 3 = 3333 with remainder, so each gets 3333
let share1 = calculate_share(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 3, BASIS_POINTS);
assert!(share1 == 3333, "Each payout should get 3333 bp");
assert!(share2 == 3333, "Each payout should get 3333 bp");
assert!(share3 == 3333, "Each payout should get 3333 bp");
// Total will be 9999 due to integer division
let total = calculate_total(dist, 3, BASIS_POINTS);
assert!(total == 9999, "Total should be 9999 due to rounding");
}
#[test]
fn test_custom_distribution_exact_100_percent() {
// Test custom shares that sum to exactly 10000 (100%)
let dist = Distribution::Custom(array![4000_u16, 3000_u16, 2000_u16, 1000_u16].span());
let share1 = calculate_share(dist, 1, 4, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 4, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 4, BASIS_POINTS);
let share4 = calculate_share(dist, 4, 4, BASIS_POINTS);
assert!(share1 == 4000, "Payout index 1 should get 40%");
assert!(share2 == 3000, "Payout index 2 should get 30%");
assert!(share3 == 2000, "Payout index 3 should get 20%");
assert!(share4 == 1000, "Payout index 4 should get 10%");
let total = calculate_total(dist, 4, BASIS_POINTS);
assert!(total == BASIS_POINTS, "Total should be exactly 100%");
}
#[test]
fn test_custom_distribution_unequal_shares() {
// Test custom shares with unequal distribution
let dist = Distribution::Custom(array![9000_u16, 500_u16, 300_u16, 200_u16].span());
let share1 = calculate_share(dist, 1, 4, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 4, BASIS_POINTS);
let share4 = calculate_share(dist, 4, 4, BASIS_POINTS);
assert!(share1 == 9000, "Payout index 1 gets 90%");
assert!(share2 == 500, "Position 2 gets 5%");
assert!(share4 == 200, "Position 4 gets 2%");
}
#[test]
fn test_partial_available_share() {
// Test when available_share is less than 10000 (partial distribution)
let dist = Distribution::Linear(10); // weight 1.0
let available = 5000_u16; // Only 50% to distribute
let share1 = calculate_share(dist, 1, 3, available);
let share2 = calculate_share(dist, 2, 3, available);
let _share3 = calculate_share(dist, 3, 3, available);
// Payout index 1: 3/6 * 5000 = 2500 (25% of total)
assert!(share1 == 2500, "Payout index 1 should get 25% when available is 50%");
// Payout index 2: 2/6 * 5000 = 1666 (16.66% of total)
assert!(share2 >= 1666 && share2 <= 1667, "Payout index 2 should get ~16.67%");
let total = calculate_total(dist, 3, available);
assert!(total >= 4900 && total <= available, "Total should be close to available");
}
#[test]
fn test_exponential_different_payout_counts() {
let dist = Distribution::Exponential(10);
// Test with 2 payouts
let share1_2pos = calculate_share(dist, 1, 2, BASIS_POINTS);
let share2_2pos = calculate_share(dist, 2, 2, BASIS_POINTS);
assert!(share1_2pos > share2_2pos, "Payout index 1 > Payout index 2 with 2 payouts");
let total_2pos = share1_2pos + share2_2pos;
assert!(total_2pos >= 9900 && total_2pos <= BASIS_POINTS, "Total should be close to 100%");
// Test with 20 payouts
let share1_20pos = calculate_share(dist, 1, 20, BASIS_POINTS);
let share10_20pos = calculate_share(dist, 10, 20, BASIS_POINTS);
let share20_20pos = calculate_share(dist, 20, 20, BASIS_POINTS);
assert!(share1_20pos > share10_20pos, "Position 1 > Position 10 with 20 payouts");
assert!(share10_20pos > share20_20pos, "Position 10 > Position 20 with 20 payouts");
}
#[test]
fn test_linear_weight_1_standard() {
// Weight 10 = 1.0 gives standard linear distribution
let dist = Distribution::Linear(10);
let share1 = calculate_share(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 3, BASIS_POINTS);
// With weight=1.0: 1st=3, 2nd=2, 3rd=1, total=6
assert!(share1 >= 4950 && share1 <= 5050, "Weight 1.0: Payout index 1 gets ~50%");
assert!(share2 >= 3300 && share2 <= 3400, "Weight 1.0: Position 2 gets ~33%");
assert!(share3 >= 1600 && share3 <= 1700, "Weight 1.0: Position 3 gets ~17%");
}
#[test]
fn test_linear_weight_2_steeper() {
// Weight 20 = 2.0 makes distribution steeper
let dist = Distribution::Linear(20);
let share1 = calculate_share(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 3, BASIS_POINTS);
// With weight=2.0 and new linear formula:
// Position 1: share = 1 + (3-1) * 2.0 = 5, total = 5+3+1 = 9
// 1st = 5/9 ≈ 55.56%, 2nd = 3/9 ≈ 33.33%, 3rd = 1/9 ≈ 11.11%
assert!(share1 >= 5540 && share1 <= 5580, "Weight 2.0: Payout index 1 gets ~55.56%");
assert!(share2 >= 3320 && share2 <= 3350, "Weight 2.0: Position 2 gets ~33.33%");
assert!(share3 >= 1100 && share3 <= 1130, "Weight 2.0: Position 3 gets ~11.11%");
// Verify payout index 1 gets much more with weight 2.0 than weight 1.0
let dist1 = Distribution::Linear(10);
let share1_w1 = calculate_share(dist1, 1, 3, BASIS_POINTS);
assert!(share1 > share1_w1, "Higher weight gives payout index 1 more");
}
#[test]
fn test_linear_weight_5_very_steep() {
// Weight 50 = 5.0 creates very steep distribution
let dist = Distribution::Linear(50);
let share1 = calculate_share(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 3, BASIS_POINTS);
// With weight=5.0 and new linear formula:
// Position 1: share = 1 + (3-1) * 5.0 = 11, total = 11+6+1 = 18
// 1st = 11/18 ≈ 61.11%, 2nd = 6/18 ≈ 33.33%, 3rd = 1/18 ≈ 5.56%
assert!(share1 >= 6100 && share1 <= 6130, "Weight 5.0: Position 1 gets ~61.11%");
assert!(share2 >= 3320 && share2 <= 3350, "Weight 5.0: Position 2 gets ~33.33%");
assert!(share3 >= 540 && share3 <= 570, "Weight 5.0: Position 3 gets ~5.56%");
// Position 1 should get more than payout index 2
assert!(share1 * 10 > share2 * 18, "Payout index 1 gets >1.8x more than payout index 2");
}
#[test]
fn test_compare_exponential_weights() {
// Compare how different weights affect payout index 1 with 5 total payouts
let weight_low = Distribution::Exponential(2);
let weight_medium = Distribution::Exponential(10);
let weight_high = Distribution::Exponential(30);
let share1_low = calculate_share(weight_low, 1, 5, BASIS_POINTS);
let share1_medium = calculate_share(weight_medium, 1, 5, BASIS_POINTS);
let share1_high = calculate_share(weight_high, 1, 5, BASIS_POINTS);
// Higher weight should give payout index 1 more
assert!(
share1_low < share1_medium,
"Medium weight should give more to payout_index 1 (winner) than low",
);
assert!(
share1_medium < share1_high,
"High weight should give more to payout_index 1 (winner) than medium",
);
// Verify the progression makes sense
assert!(share1_low < 5000, "Low weight (2) should give payout index 1 <50%");
assert!(
share1_medium > share1_low + 200, "Medium weight should give notably more than low",
);
assert!(
share1_high > share1_medium + 200, "High weight should give notably more than medium",
);
}
#[test]
fn test_distribution_with_single_payout() {
// When there's only 1 payout, it should get everything
let linear = Distribution::Linear(1);
let uniform = Distribution::Uniform;
let exponential = Distribution::Exponential(50);
let linear_share = calculate_share(linear, 1, 1, BASIS_POINTS);
let uniform_share = calculate_share(uniform, 1, 1, BASIS_POINTS);
let exp_share = calculate_share(exponential, 1, 1, BASIS_POINTS);
assert!(linear_share == BASIS_POINTS, "Linear: Single payout gets 100%");
assert!(uniform_share == BASIS_POINTS, "Uniform: Single payout gets 100%");
assert!(exp_share == BASIS_POINTS, "Exponential: Single payout gets 100%");
}
// ============================================================================
// DUST HANDLING TESTS - Critical for preventing stuck funds in contract
// ============================================================================
#[test]
fn test_dust_calculation_linear() {
// Linear with 3 payouts should have minimal dust
let dist = Distribution::Linear(1);
let dust = calculate_dust(dist, 3, BASIS_POINTS);
// Verify dust is small (less than number of payouts)
assert!(dust < 3, "Dust should be less than number of payouts");
// Verify total + dust = 100%
let total = calculate_total(dist, 3, BASIS_POINTS);
assert!(total + dust == BASIS_POINTS, "Total + dust must equal 100%");
}
#[test]
fn test_dust_calculation_uniform_with_rounding() {
// Uniform with 3 payouts: 10000 / 3 = 3333 each, dust = 1
let dist = Distribution::Uniform;
let dust = calculate_dust(dist, 3, BASIS_POINTS);
assert!(dust == 1, "Uniform with 3 payouts should have 1 bp dust");
let total = calculate_total(dist, 3, BASIS_POINTS);
assert!(total + dust == BASIS_POINTS, "Total + dust must equal 100%");
}
#[test]
fn test_dust_calculation_exponential() {
// Exponential distributions will likely have dust due to rounding
let dist = Distribution::Exponential(50);
let dust = calculate_dust(dist, 5, BASIS_POINTS);
// Verify dust exists and is reasonable
assert!(dust <= 100, "Dust should be small (<1%)");
let total = calculate_total(dist, 5, BASIS_POINTS);
assert!(total + dust == BASIS_POINTS, "Total + dust must equal 100%");
}
#[test]
fn test_share_with_dust_ensures_exact_100_percent() {
// Test that using calculate_share_with_dust gives exactly 100%
let distributions = array![
Distribution::Linear(1), Distribution::Uniform, Distribution::Exponential(10),
Distribution::Exponential(50), Distribution::Exponential(90),
];
let mut i = 0;
loop {
if i >= distributions.len() {
break;
}
let dist = *distributions.at(i);
// Test with 5 payouts
let mut total: u16 = 0;
let mut p: u32 = 1;
loop {
if p > 5 {
break;
}
total += calculate_share_with_dust(dist, p.try_into().unwrap(), 5, BASIS_POINTS);
p += 1;
}
assert!(total == BASIS_POINTS, "With dust, total must be exactly 100%");
i += 1;
};
}
#[test]
fn test_share_with_dust_last_position_gets_bonus() {
// Verify that payout index 1 gets the dust
let dist = Distribution::Uniform;
let total_payouts = 3_u32;
let share1 = calculate_share_with_dust(dist, 1, total_payouts, BASIS_POINTS);
let share2 = calculate_share_with_dust(dist, 2, total_payouts, BASIS_POINTS);
let share3 = calculate_share_with_dust(dist, 3, total_payouts, BASIS_POINTS);
// Payout index 2 and 3 get 3333, payout index 1 gets 3333 + 1 (dust) = 3334
assert!(share1 == 3334, "Payout index 1 gets base share + dust");
assert!(share2 == 3333, "Position 2 gets base share");
assert!(share3 == 3333, "Position 3 gets base share");
assert!(share1 + share2 + share3 == BASIS_POINTS, "Total must be exactly 100%");
}
#[test]
fn test_linear_with_dust_small_positions() {
// Test linear distribution with small payout counts (removed large counts due to gas
// limits)
let dist = Distribution::Linear(10); // weight 1.0
// Test with 3, 5, 7 positions (smaller set to avoid running out of gas)
let position_counts = array![3_u32, 5, 7];
let mut i = 0;
loop {
if i >= position_counts.len() {
break;
}
let positions = *position_counts.at(i);
let mut total: u16 = 0;
let mut p: u32 = 1;
loop {
if p > positions {
break;
}
total +=
calculate_share_with_dust(dist, p.try_into().unwrap(), positions, BASIS_POINTS);
p += 1;
}
assert!(total == BASIS_POINTS, "Linear with dust must sum to 100%");
i += 1;
};
}
// ============ Gas Comparison Tests ============
#[test]
fn test_fractional_weight_benefit() {
// Demonstrate the benefit: fractional weights provide finer control
let weight_15 = Distribution::Linear(15); // 1.5 - between 1.0 and 2.0
let weight_25 = Distribution::Linear(25); // 2.5 - between 2.0 and 3.0
let share_15 = calculate_share(weight_15, 1, 3, BASIS_POINTS);
let share_25 = calculate_share(weight_25, 1, 3, BASIS_POINTS);
// Verify these produce intermediate values
let weight_10 = Distribution::Linear(10); // 1.0
let weight_20 = Distribution::Linear(20); // 2.0
let weight_30 = Distribution::Linear(30); // 3.0
let share_10 = calculate_share(weight_10, 1, 3, BASIS_POINTS);
let share_20 = calculate_share(weight_20, 1, 3, BASIS_POINTS);
let share_30 = calculate_share(weight_30, 1, 3, BASIS_POINTS);
// Weight 1.5 should be between 1.0 and 2.0
assert!(share_15 > share_10 && share_15 < share_20, "1.5 is between 1.0 and 2.0");
// Weight 2.5 should be between 2.0 and 3.0
assert!(share_25 > share_20 && share_25 < share_30, "2.5 is between 2.0 and 3.0");
// This smooth gradient is the key benefit of the fixed-point approach!
}
#[test]
fn test_exponential_with_dust_various_weights() {
// Test exponential with different weights (scaled by 10) to ensure 100% distribution
let weights = array![
10_u16, 50, 100, 200, 500, 900, 1000,
]; // 1.0, 5.0, 10.0, 20.0, 50.0, 90.0, 100.0
let positions = 10_u32;
let mut i = 0;
loop {
if i >= weights.len() {
break;
}
let weight = *weights.at(i);
let dist = Distribution::Exponential(weight);
let mut total: u16 = 0;
let mut p: u32 = 1;
loop {
if p > positions {
break;
}
total +=
calculate_share_with_dust(dist, p.try_into().unwrap(), positions, BASIS_POINTS);
p += 1;
}
assert!(total == BASIS_POINTS, "Exponential with dust must sum to 100%");
i += 1;
};
}
#[test]
fn test_dust_with_partial_available_share() {
// Test that dust handling works with partial shares (not just BASIS_POINTS)
let dist = Distribution::Linear(1);
let available = 5000_u16; // Only 50% to distribute
let mut total: u16 = 0;
let mut p: u32 = 1;
loop {
if p > 3 {
break;
}
total += calculate_share_with_dust(dist, p.try_into().unwrap(), 3, available);
p += 1;
}
assert!(total == available, "Total must equal available share exactly");
}
#[test]
fn test_custom_distribution_with_dust() {
// Custom distributions that don't sum to exactly 100% should get dust added
let dist = Distribution::Custom(
array![4000_u16, 3000_u16, 2999_u16].span(),
); // Sums to 9999
let share1 = calculate_share_with_dust(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share_with_dust(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share_with_dust(dist, 3, 3, BASIS_POINTS);
// Position 1 should get 4000 + 1 (dust) = 4001
assert!(share1 == 4001, "Payout index 1 gets base share + dust");
assert!(share2 == 3000, "Position 2 gets base share");
assert!(share3 == 2999, "Position 3 gets base share");
assert!(share1 + share2 + share3 == BASIS_POINTS, "Total must be exactly 100%");
}
#[test]
fn test_no_dust_when_exact() {
// Uniform with 4 positions: 10000 / 4 = 2500 exactly, no dust
let dist = Distribution::Uniform;
let dust = calculate_dust(dist, 4, BASIS_POINTS);
assert!(dust == 0, "Should have no dust with perfect division");
}
#[test]
fn test_dust_never_exceeds_positions() {
// Dust should always be less than number of payouts (worst case for linear/uniform)
let distributions = array![
(Distribution::Linear(1), 10_u32), (Distribution::Uniform, 7),
(Distribution::Exponential(50), 15),
];
let mut i = 0;
loop {
if i >= distributions.len() {
break;
}
let (dist, positions) = *distributions.at(i);
let dust = calculate_dust(dist, positions, BASIS_POINTS);
assert!(
dust <= positions.try_into().unwrap(), "Dust should not exceed number of payouts",
);
i += 1;
};
}
// ============ Fractional Weight Tests ============
#[test]
fn test_debug_linear_weight_10() {
// Debug test to see actual values
let dist = Distribution::Linear(10);
let share1 = calculate_share(dist, 1, 3, BASIS_POINTS);
let share2 = calculate_share(dist, 2, 3, BASIS_POINTS);
let share3 = calculate_share(dist, 3, 3, BASIS_POINTS);
// Print values
println!("Weight 10 (1.0): share1={}, share2={}, share3={}", share1, share2, share3);
// Basic sanity checks
assert!(share1 > share2, "Payout index 1 > Payout index 2");