From f003aeabbae8e882129f8b0c0884e5ae98ff0ae9 Mon Sep 17 00:00:00 2001 From: Pierrick Turelier Date: Thu, 10 Mar 2022 16:29:03 -0600 Subject: [PATCH] feat(DrawCalculatorV2): add contract --- contracts/DrawCalculator.sol | 4 +- contracts/DrawCalculatorV2.sol | 461 ++++++ contracts/interfaces/IDrawCalculator.sol | 6 +- .../interfaces/IPrizeDistributionSource.sol | 1 - contracts/test/DrawCalculatorHarness.sol | 2 +- contracts/test/DrawCalculatorV2Harness.sol | 55 + test/DrawCalculator.test.ts | 4 +- test/DrawCalculatorV2.test.ts | 1460 +++++++++++++++++ 8 files changed, 1983 insertions(+), 10 deletions(-) create mode 100644 contracts/DrawCalculatorV2.sol create mode 100644 contracts/test/DrawCalculatorV2Harness.sol create mode 100644 test/DrawCalculatorV2.test.ts diff --git a/contracts/DrawCalculator.sol b/contracts/DrawCalculator.sol index a1c63bbd..3b1ab193 100644 --- a/contracts/DrawCalculator.sol +++ b/contracts/DrawCalculator.sol @@ -139,11 +139,8 @@ contract DrawCalculator is IDrawCalculator { uint64 timeNow = uint64(block.timestamp); - - // calculate prizes awardable for each Draw passed for (uint32 drawIndex = 0; drawIndex < _draws.length; drawIndex++) { - require(timeNow < _draws[drawIndex].timestamp + _prizeDistributions[drawIndex].expiryDuration, "DrawCalc/draw-expired"); uint64 totalUserPicks = _calculateNumberOfUserPicks( @@ -159,6 +156,7 @@ contract DrawCalculator is IDrawCalculator { _prizeDistributions[drawIndex] ); } + prizeCounts = abi.encode(_prizeCounts); prizesAwardable = _prizesAwardable; } diff --git a/contracts/DrawCalculatorV2.sol b/contracts/DrawCalculatorV2.sol new file mode 100644 index 00000000..c3073a33 --- /dev/null +++ b/contracts/DrawCalculatorV2.sol @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.6; + +import "./interfaces/ITicket.sol"; +import "./interfaces/IDrawBuffer.sol"; +import "./interfaces/IPrizeDistributionSource.sol"; +import "./interfaces/IDrawBeacon.sol"; + +import "./PrizeDistributor.sol"; + +/** + * @title PoolTogether V4 DrawCalculatorV2 + * @author PoolTogether Inc Team + * @notice The DrawCalculator calculates a user's prize by matching a winning random number against + their picks. A users picks are generated deterministically based on their address and balance + of tickets held. Prize payouts are divided into multiple tiers: grand prize, second place, etc... + A user with a higher average weighted balance (during each draw period) will be given a large number of + picks to choose from, and thus a higher chance to match the winning numbers. +*/ +contract DrawCalculatorV2 { + /* ============ Variables ============ */ + + /// @notice DrawBuffer address + IDrawBuffer public immutable drawBuffer; + + /// @notice Ticket associated with DrawCalculator + ITicket public immutable ticket; + + /// @notice The source in which the history of draw settings are stored as ring buffer. + IPrizeDistributionSource public immutable prizeDistributionSource; + + /// @notice The tiers array length + uint8 public constant TIERS_LENGTH = 16; + + /* ============ Events ============ */ + + ///@notice Emitted when the contract is initialized + event Deployed( + ITicket indexed ticket, + IDrawBuffer indexed drawBuffer, + IPrizeDistributionSource indexed prizeDistributionSource + ); + + ///@notice Emitted when the prizeDistributor is set/updated + event PrizeDistributorSet(PrizeDistributor indexed prizeDistributor); + + /* ============ Constructor ============ */ + + /** + * @notice Constructor for DrawCalculator + * @param _ticket Ticket associated with this DrawCalculator + * @param _drawBuffer The address of the draw buffer to push draws to + * @param _prizeDistributionSource PrizeDistributionSource address + */ + constructor( + ITicket _ticket, + IDrawBuffer _drawBuffer, + IPrizeDistributionSource _prizeDistributionSource + ) { + require(address(_ticket) != address(0), "DrawCalc/ticket-not-zero"); + require(address(_prizeDistributionSource) != address(0), "DrawCalc/pdb-not-zero"); + require(address(_drawBuffer) != address(0), "DrawCalc/dh-not-zero"); + + ticket = _ticket; + drawBuffer = _drawBuffer; + prizeDistributionSource = _prizeDistributionSource; + + emit Deployed(_ticket, _drawBuffer, _prizeDistributionSource); + } + + /* ============ External Functions ============ */ + + /** + * @notice Calculates the prize amount for a user for Multiple Draws. Typically called by a PrizeDistributor. + * @param _user User for which to calculate prize amount. + * @param _drawIds drawId array for which to calculate prize amounts for. + * @param _pickIndicesForDraws The ABI encoded pick indices for all Draws. Expected to be winning picks. Pick indices must be less than the totalUserPicks. + * @return List of awardable prize amounts ordered by drawId. + */ + function calculate( + address _user, + uint32[] calldata _drawIds, + bytes calldata _pickIndicesForDraws + ) external view returns (uint256[] memory, bytes memory) { + uint64[][] memory pickIndices = abi.decode(_pickIndicesForDraws, (uint64 [][])); + require(pickIndices.length == _drawIds.length, "DrawCalc/invalid-pick-indices-length"); + + // READ list of IDrawBeacon.Draw using the drawIds from drawBuffer + IDrawBeacon.Draw[] memory draws = drawBuffer.getDraws(_drawIds); + + // READ list of IPrizeDistributionSource.PrizeDistribution using the drawIds + IPrizeDistributionSource.PrizeDistribution[] memory _prizeDistributions = prizeDistributionSource + .getPrizeDistributions(_drawIds); + + // The userBalances are fractions representing their portion of the liquidity for a draw. + uint256[] memory userBalances = _getNormalizedBalancesAt(_user, draws, _prizeDistributions); + + // The users address is hashed once. + bytes32 _userRandomNumber = keccak256(abi.encodePacked(_user)); + + return _calculatePrizesAwardable( + userBalances, + _userRandomNumber, + draws, + pickIndices, + _prizeDistributions + ); + } + + /** + * @notice Read global DrawBuffer variable. + * @return IDrawBuffer + */ + function getDrawBuffer() external view returns (IDrawBuffer) { + return drawBuffer; + } + + /** + * @notice Read global prizeDistributionSource variable. + * @return IPrizeDistributionSource + */ + function getPrizeDistributionSource() + external + view + returns (IPrizeDistributionSource) + { + return prizeDistributionSource; + } + + /** + * @notice Returns a users balances expressed as a fraction of the total supply over time. + * @param _user The users address + * @param _drawIds The drawIds to consider + * @return Array of balances + */ + function getNormalizedBalancesForDrawIds(address _user, uint32[] calldata _drawIds) + external + view + returns (uint256[] memory) + { + IDrawBeacon.Draw[] memory _draws = drawBuffer.getDraws(_drawIds); + IPrizeDistributionSource.PrizeDistribution[] memory _prizeDistributions = prizeDistributionSource + .getPrizeDistributions(_drawIds); + + return _getNormalizedBalancesAt(_user, _draws, _prizeDistributions); + } + + /* ============ Internal Functions ============ */ + + /** + * @notice Calculates the prizes awardable for each Draw passed. + * @param _normalizedUserBalances Fractions representing the user's portion of the liquidity for each draw. + * @param _userRandomNumber Random number of the user to consider over draws + * @param _draws List of Draws + * @param _pickIndicesForDraws Pick indices for each Draw + * @param _prizeDistributions PrizeDistribution for each Draw + + */ + function _calculatePrizesAwardable( + uint256[] memory _normalizedUserBalances, + bytes32 _userRandomNumber, + IDrawBeacon.Draw[] memory _draws, + uint64[][] memory _pickIndicesForDraws, + IPrizeDistributionSource.PrizeDistribution[] memory _prizeDistributions + ) internal view returns (uint256[] memory prizesAwardable, bytes memory prizeCounts) { + + uint256[] memory _prizesAwardable = new uint256[](_normalizedUserBalances.length); + uint256[][] memory _prizeCounts = new uint256[][](_normalizedUserBalances.length); + + uint64 timeNow = uint64(block.timestamp); + + // calculate prizes awardable for each Draw passed + for (uint32 drawIndex = 0; drawIndex < _draws.length; drawIndex++) { + require(timeNow < _draws[drawIndex].timestamp + _prizeDistributions[drawIndex].expiryDuration, "DrawCalc/draw-expired"); + + uint64 totalUserPicks = _calculateNumberOfUserPicks( + _prizeDistributions[drawIndex], + _normalizedUserBalances[drawIndex] + ); + + (_prizesAwardable[drawIndex], _prizeCounts[drawIndex]) = _calculate( + _draws[drawIndex].winningRandomNumber, + totalUserPicks, + _userRandomNumber, + _pickIndicesForDraws[drawIndex], + _prizeDistributions[drawIndex] + ); + } + + prizeCounts = abi.encode(_prizeCounts); + prizesAwardable = _prizesAwardable; + } + + /** + * @notice Calculates the number of picks a user gets for a Draw, considering the normalized user balance and the PrizeDistribution. + * @dev Divided by 1e18 since the normalized user balance is stored as a fixed point 18 number + * @param _prizeDistribution The PrizeDistribution to consider + * @param _normalizedUserBalance The normalized user balances to consider + * @return The number of picks a user gets for a Draw + */ + function _calculateNumberOfUserPicks( + IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution, + uint256 _normalizedUserBalance + ) internal pure returns (uint64) { + return uint64((_normalizedUserBalance * _prizeDistribution.numberOfPicks) / 1 ether); + } + + /** + * @notice Calculates the normalized balance of a user against the total supply for timestamps + * @param _user The user to consider + * @param _draws The draws we are looking at + * @param _prizeDistributions The prize tiers to consider (needed for draw timestamp offsets) + * @return An array of normalized balances + */ + function _getNormalizedBalancesAt( + address _user, + IDrawBeacon.Draw[] memory _draws, + IPrizeDistributionSource.PrizeDistribution[] memory _prizeDistributions + ) internal view returns (uint256[] memory) { + uint256 drawsLength = _draws.length; + uint64[] memory _timestampsWithStartCutoffTimes = new uint64[](drawsLength); + uint64[] memory _timestampsWithEndCutoffTimes = new uint64[](drawsLength); + + // generate timestamps with draw cutoff offsets included + for (uint32 i = 0; i < drawsLength; i++) { + unchecked { + _timestampsWithStartCutoffTimes[i] = + _draws[i].timestamp - _prizeDistributions[i].startTimestampOffset; + _timestampsWithEndCutoffTimes[i] = + _draws[i].timestamp - _prizeDistributions[i].endTimestampOffset; + } + } + + uint256[] memory balances = ticket.getAverageBalancesBetween( + _user, + _timestampsWithStartCutoffTimes, + _timestampsWithEndCutoffTimes + ); + + uint256[] memory totalSupplies = ticket.getAverageTotalSuppliesBetween( + _timestampsWithStartCutoffTimes, + _timestampsWithEndCutoffTimes + ); + + uint256[] memory normalizedBalances = new uint256[](drawsLength); + + // divide balances by total supplies (normalize) + for (uint256 i = 0; i < drawsLength; i++) { + if(totalSupplies[i] == 0){ + normalizedBalances[i] = 0; + } + else { + normalizedBalances[i] = (balances[i] * 1 ether) / totalSupplies[i]; + } + } + + return normalizedBalances; + } + + /** + * @notice Calculates the prize amount for a PrizeDistribution over given picks + * @param _winningRandomNumber Draw's winningRandomNumber + * @param _totalUserPicks number of picks the user gets for the Draw + * @param _userRandomNumber users randomNumber for that draw + * @param _picks users picks for that draw + * @param _prizeDistribution PrizeDistribution for that draw + * @return prize (if any), prizeCounts (if any) + */ + function _calculate( + uint256 _winningRandomNumber, + uint256 _totalUserPicks, + bytes32 _userRandomNumber, + uint64[] memory _picks, + IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution + ) internal pure returns (uint256 prize, uint256[] memory prizeCounts) { + + // create bitmasks for the PrizeDistribution + uint256[] memory masks = _createBitMasks(_prizeDistribution); + uint32 picksLength = uint32(_picks.length); + uint256[] memory _prizeCounts = new uint256[](_prizeDistribution.tiers.length); + + uint8 maxWinningTierIndex = 0; + + require( + picksLength <= _prizeDistribution.maxPicksPerUser, + "DrawCalc/exceeds-max-user-picks" + ); + + // for each pick, find number of matching numbers and calculate prize distributions index + for (uint32 index = 0; index < picksLength; index++) { + require(_picks[index] < _totalUserPicks, "DrawCalc/insufficient-user-picks"); + + if (index > 0) { + require(_picks[index] > _picks[index - 1], "DrawCalc/picks-ascending"); + } + + // hash the user random number with the pick value + uint256 randomNumberThisPick = uint256( + keccak256(abi.encode(_userRandomNumber, _picks[index])) + ); + + uint8 tiersIndex = _calculateTierIndex( + randomNumberThisPick, + _winningRandomNumber, + masks + ); + + // there is prize for this tier index + if (tiersIndex < TIERS_LENGTH) { + if (tiersIndex > maxWinningTierIndex) { + maxWinningTierIndex = tiersIndex; + } + _prizeCounts[tiersIndex]++; + } + } + + // now calculate prizeFraction given prizeCounts + uint256 prizeFraction = 0; + uint256[] memory prizeTiersFractions = _calculatePrizeTierFractions( + _prizeDistribution, + maxWinningTierIndex + ); + + // multiple the fractions by the prizeCounts and add them up + for ( + uint256 prizeCountIndex = 0; + prizeCountIndex <= maxWinningTierIndex; + prizeCountIndex++ + ) { + if (_prizeCounts[prizeCountIndex] > 0) { + prizeFraction += + prizeTiersFractions[prizeCountIndex] * + _prizeCounts[prizeCountIndex]; + } + } + + // return the absolute amount of prize awardable + // div by 1e9 as prize tiers are base 1e9 + prize = (prizeFraction * _prizeDistribution.prize) / 1e9; + prizeCounts = _prizeCounts; + } + + ///@notice Calculates the tier index given the random numbers and masks + ///@param _randomNumberThisPick users random number for this Pick + ///@param _winningRandomNumber The winning number for this draw + ///@param _masks The pre-calculate bitmasks for the prizeDistributions + ///@return The position within the prize tier array (0 = top prize, 1 = runner-up prize, etc) + function _calculateTierIndex( + uint256 _randomNumberThisPick, + uint256 _winningRandomNumber, + uint256[] memory _masks + ) internal pure returns (uint8) { + uint8 numberOfMatches = 0; + uint8 masksLength = uint8(_masks.length); + + // main number matching loop + for (uint8 matchIndex = 0; matchIndex < masksLength; matchIndex++) { + uint256 mask = _masks[matchIndex]; + + if ((_randomNumberThisPick & mask) != (_winningRandomNumber & mask)) { + // there are no more sequential matches since this comparison is not a match + if (masksLength == numberOfMatches) { + return 0; + } else { + return masksLength - numberOfMatches; + } + } + + // else there was a match + numberOfMatches++; + } + + return masksLength - numberOfMatches; + } + + /** + * @notice Create an array of bitmasks equal to the PrizeDistribution.matchCardinality length + * @param _prizeDistribution The PrizeDistribution to use to calculate the masks + * @return An array of bitmasks + */ + function _createBitMasks(IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution) + internal + pure + returns (uint256[] memory) + { + uint256[] memory masks = new uint256[](_prizeDistribution.matchCardinality); + masks[0] = (2**_prizeDistribution.bitRangeSize) - 1; + + for (uint8 maskIndex = 1; maskIndex < _prizeDistribution.matchCardinality; maskIndex++) { + // shift mask bits to correct position and insert in result mask array + masks[maskIndex] = masks[maskIndex - 1] << _prizeDistribution.bitRangeSize; + } + + return masks; + } + + /** + * @notice Calculates the expected prize fraction per PrizeDistributions and distributionIndex + * @param _prizeDistribution prizeDistribution struct for Draw + * @param _prizeTierIndex Index of the prize tiers array to calculate + * @return returns the fraction of the total prize (fixed point 9 number) + */ + function _calculatePrizeTierFraction( + IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution, + uint256 _prizeTierIndex + ) internal pure returns (uint256) { + // get the prize fraction at that index + uint256 prizeFraction = _prizeDistribution.tiers[_prizeTierIndex]; + + // calculate number of prizes for that index + uint256 numberOfPrizesForIndex = _numberOfPrizesForIndex( + _prizeDistribution.bitRangeSize, + _prizeTierIndex + ); + + return prizeFraction / numberOfPrizesForIndex; + } + + /** + * @notice Generates an array of prize tiers fractions + * @param _prizeDistribution prizeDistribution struct for Draw + * @param maxWinningTierIndex Max length of the prize tiers array + * @return returns an array of prize tiers fractions + */ + function _calculatePrizeTierFractions( + IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution, + uint8 maxWinningTierIndex + ) internal pure returns (uint256[] memory) { + uint256[] memory prizeDistributionFractions = new uint256[]( + maxWinningTierIndex + 1 + ); + + for (uint8 i = 0; i <= maxWinningTierIndex; i++) { + prizeDistributionFractions[i] = _calculatePrizeTierFraction( + _prizeDistribution, + i + ); + } + + return prizeDistributionFractions; + } + + /** + * @notice Calculates the number of prizes for a given prizeDistributionIndex + * @param _bitRangeSize Bit range size for Draw + * @param _prizeTierIndex Index of the prize tier array to calculate + * @return returns the fraction of the total prize (base 1e18) + */ + function _numberOfPrizesForIndex(uint8 _bitRangeSize, uint256 _prizeTierIndex) + internal + pure + returns (uint256) + { + if (_prizeTierIndex > 0) { + return ( 1 << _bitRangeSize * _prizeTierIndex ) - ( 1 << _bitRangeSize * (_prizeTierIndex - 1) ); + } else { + return 1; + } + } +} diff --git a/contracts/interfaces/IDrawCalculator.sol b/contracts/interfaces/IDrawCalculator.sol index 64e14257..ec85a69e 100644 --- a/contracts/interfaces/IDrawCalculator.sol +++ b/contracts/interfaces/IDrawCalculator.sol @@ -48,15 +48,15 @@ interface IDrawCalculator { function getDrawBuffer() external view returns (IDrawBuffer); /** - * @notice Read global DrawBuffer variable. - * @return IDrawBuffer + * @notice Read global prizeDistributionBuffer variable. + * @return IPrizeDistributionBuffer */ function getPrizeDistributionBuffer() external view returns (IPrizeDistributionBuffer); /** * @notice Returns a users balances expressed as a fraction of the total supply over time. * @param user The users address - * @param drawIds The drawsId to consider + * @param drawIds The drawIds to consider * @return Array of balances */ function getNormalizedBalancesForDrawIds(address user, uint32[] calldata drawIds) diff --git a/contracts/interfaces/IPrizeDistributionSource.sol b/contracts/interfaces/IPrizeDistributionSource.sol index 0d9d45b6..debeadae 100644 --- a/contracts/interfaces/IPrizeDistributionSource.sol +++ b/contracts/interfaces/IPrizeDistributionSource.sol @@ -7,7 +7,6 @@ pragma solidity 0.8.6; * @notice The PrizeDistributionSource interface. */ interface IPrizeDistributionSource { - ///@notice PrizeDistribution struct created every draw ///@param bitRangeSize Decimal representation of bitRangeSize ///@param matchCardinality The number of numbers to consider in the 256 bit random number. Must be > 1 and < 256/bitRangeSize. diff --git a/contracts/test/DrawCalculatorHarness.sol b/contracts/test/DrawCalculatorHarness.sol index 79ab82eb..aaa70c4d 100644 --- a/contracts/test/DrawCalculatorHarness.sol +++ b/contracts/test/DrawCalculatorHarness.sol @@ -8,7 +8,7 @@ contract DrawCalculatorHarness is DrawCalculator { constructor( ITicket _ticket, IDrawBuffer _drawBuffer, - PrizeDistributionBuffer _prizeDistributionBuffer + IPrizeDistributionBuffer _prizeDistributionBuffer ) DrawCalculator(_ticket, _drawBuffer, _prizeDistributionBuffer) {} function calculateTierIndex( diff --git a/contracts/test/DrawCalculatorV2Harness.sol b/contracts/test/DrawCalculatorV2Harness.sol new file mode 100644 index 00000000..c92d2dcd --- /dev/null +++ b/contracts/test/DrawCalculatorV2Harness.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.6; + +import "../DrawCalculatorV2.sol"; + +contract DrawCalculatorV2Harness is DrawCalculatorV2 { + constructor( + ITicket _ticket, + IDrawBuffer _drawBuffer, + IPrizeDistributionSource _prizeDistributionSource + ) DrawCalculatorV2(_ticket, _drawBuffer, _prizeDistributionSource) {} + + function calculateTierIndex( + uint256 _randomNumberThisPick, + uint256 _winningRandomNumber, + uint256[] memory _masks + ) public pure returns (uint256) { + return _calculateTierIndex(_randomNumberThisPick, _winningRandomNumber, _masks); + } + + function createBitMasks(IPrizeDistributionSource.PrizeDistribution calldata _prizeDistribution) + public + pure + returns (uint256[] memory) + { + return _createBitMasks(_prizeDistribution); + } + + ///@notice Calculates the expected prize fraction per prizeDistribution and prizeTierIndex + ///@param _prizeDistribution prizeDistribution struct for Draw + ///@param _prizeTierIndex Index of the prize tiers array to calculate + ///@return returns the fraction of the total prize + function calculatePrizeTierFraction( + IPrizeDistributionSource.PrizeDistribution calldata _prizeDistribution, + uint256 _prizeTierIndex + ) external pure returns (uint256) { + return _calculatePrizeTierFraction(_prizeDistribution, _prizeTierIndex); + } + + function numberOfPrizesForIndex(uint8 _bitRangeSize, uint256 _prizeTierIndex) + external + pure + returns (uint256) + { + return _numberOfPrizesForIndex(_bitRangeSize, _prizeTierIndex); + } + + function calculateNumberOfUserPicks( + IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution, + uint256 _normalizedUserBalance + ) external pure returns (uint64) { + return _calculateNumberOfUserPicks(_prizeDistribution, _normalizedUserBalance); + } +} diff --git a/test/DrawCalculator.test.ts b/test/DrawCalculator.test.ts index bd49159b..50dc87c4 100644 --- a/test/DrawCalculator.test.ts +++ b/test/DrawCalculator.test.ts @@ -131,13 +131,13 @@ describe('DrawCalculator', () => { }); describe('getDrawBuffer()', () => { - it('should succesfully read draw buffer', async () => { + it('should successfully read draw buffer', async () => { expect(await drawCalculator.getDrawBuffer()).to.equal(drawBuffer.address); }); }); describe('getPrizeDistributionBuffer()', () => { - it('should succesfully read draw buffer', async () => { + it('should successfully read prize distribution buffer', async () => { expect(await drawCalculator.getPrizeDistributionBuffer()).to.equal( prizeDistributionBuffer.address, ); diff --git a/test/DrawCalculatorV2.test.ts b/test/DrawCalculatorV2.test.ts new file mode 100644 index 00000000..9ebe5331 --- /dev/null +++ b/test/DrawCalculatorV2.test.ts @@ -0,0 +1,1460 @@ +import { expect } from 'chai'; +import { deployMockContract, MockContract } from 'ethereum-waffle'; +import { utils, Contract, BigNumber } from 'ethers'; +import { ethers, artifacts } from 'hardhat'; +import { Draw, PrizeDistribution } from './types'; +import { fillPrizeTiersWithZeros } from './helpers/fillPrizeTiersWithZeros'; + +const { getSigners } = ethers; + +const newDebug = require('debug'); + +function newDraw(overrides: any): Draw { + return { + drawId: 1, + timestamp: 0, + winningRandomNumber: 2, + beaconPeriodStartedAt: 0, + beaconPeriodSeconds: 1, + ...overrides, + }; +} + +function assertEmptyArrayOfBigNumbers(array: BigNumber[]) { + array.forEach((element: BigNumber) => { + expect(element).to.equal(BigNumber.from(0)); + }); +} + +export async function deployDrawCalculator( + signer: any, + ticketAddress: string, + drawBufferAddress: string, + prizeDistributionsHistoryAddress: string, +): Promise { + const drawCalculatorFactory = await ethers.getContractFactory('DrawCalculatorV2Harness', signer); + const drawCalculator: Contract = await drawCalculatorFactory.deploy( + ticketAddress, + drawBufferAddress, + prizeDistributionsHistoryAddress, + ); + + return drawCalculator; +} + +function calculateNumberOfWinnersAtIndex(bitRangeSize: number, tierIndex: number): BigNumber { + // Prize Count = (2**bitRange)**(cardinality-numberOfMatches) + // if not grand prize: - (2^bitRange)**(cardinality-numberOfMatches-1) - ... (2^bitRange)**(0) + if (tierIndex > 0) { + return BigNumber.from( + (1 << (bitRangeSize * tierIndex)) - (1 << (bitRangeSize * (tierIndex - 1))), + ); + } else { + return BigNumber.from(1); + } +} + +function modifyTimestampsWithOffset(timestamps: number[], offset: number): number[] { + return timestamps.map((timestamp: number) => timestamp - offset); +} + +describe('DrawCalculatorV2', () => { + let drawCalculator: Contract; + let ticket: MockContract; + let drawBuffer: MockContract; + let prizeDistributionSource: MockContract; + let wallet1: any; + let wallet2: any; + let wallet3: any; + + const encoder = ethers.utils.defaultAbiCoder; + + beforeEach(async () => { + [wallet1, wallet2, wallet3] = await getSigners(); + + let ticketArtifact = await artifacts.readArtifact('Ticket'); + ticket = await deployMockContract(wallet1, ticketArtifact.abi); + + let drawBufferArtifact = await artifacts.readArtifact('DrawBuffer'); + drawBuffer = await deployMockContract(wallet1, drawBufferArtifact.abi); + + let prizeDistributionSourceArtifact = await artifacts.readArtifact( + 'IPrizeDistributionSource', + ); + + prizeDistributionSource = await deployMockContract( + wallet1, + prizeDistributionSourceArtifact.abi, + ); + + drawCalculator = await deployDrawCalculator( + wallet1, + ticket.address, + drawBuffer.address, + prizeDistributionSource.address, + ); + }); + + describe('constructor()', () => { + it('should require non-zero ticket', async () => { + await expect( + deployDrawCalculator( + wallet1, + ethers.constants.AddressZero, + drawBuffer.address, + prizeDistributionSource.address, + ), + ).to.be.revertedWith('DrawCalc/ticket-not-zero'); + }); + + it('should require non-zero settings history', async () => { + await expect( + deployDrawCalculator( + wallet1, + ticket.address, + drawBuffer.address, + ethers.constants.AddressZero, + ), + ).to.be.revertedWith('DrawCalc/pdb-not-zero'); + }); + + it('should require a non-zero history', async () => { + await expect( + deployDrawCalculator( + wallet1, + ticket.address, + ethers.constants.AddressZero, + prizeDistributionSource.address, + ), + ).to.be.revertedWith('DrawCalc/dh-not-zero'); + }); + }); + + describe('getDrawBuffer()', () => { + it('should successfully read draw buffer', async () => { + expect(await drawCalculator.getDrawBuffer()).to.equal(drawBuffer.address); + }); + }); + + describe('getPrizeDistributionSource()', () => { + it('should successfully read prize distribution source', async () => { + expect(await drawCalculator.getPrizeDistributionSource()).to.equal( + prizeDistributionSource.address, + ); + }); + }); + + describe('calculateTierIndex()', () => { + let prizeDistribution: PrizeDistribution; + + beforeEach(async () => { + prizeDistribution = { + matchCardinality: BigNumber.from(5), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from(utils.parseEther('1')), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + await prizeDistributionSource.mock.getPrizeDistributions.returns([prizeDistribution]); + }); + + it('grand prize gets the full fraction at index 0', async () => { + const amount = await drawCalculator.calculatePrizeTierFraction( + prizeDistribution, + BigNumber.from(0), + ); + + expect(amount).to.equal(prizeDistribution.tiers[0]); + }); + + it('runner up gets part of the fraction at index 1', async () => { + const amount = await drawCalculator.calculatePrizeTierFraction( + prizeDistribution, + BigNumber.from(1), + ); + + const prizeCount = calculateNumberOfWinnersAtIndex( + prizeDistribution.bitRangeSize.toNumber(), + 1, + ); + + const expectedPrizeFraction = prizeDistribution.tiers[1].div(prizeCount); + + expect(amount).to.equal(expectedPrizeFraction); + }); + + it('all prize tier indexes', async () => { + for ( + let numberOfMatches = 0; + numberOfMatches < prizeDistribution.tiers.length; + numberOfMatches++ + ) { + const tierIndex = BigNumber.from( + prizeDistribution.tiers.length - numberOfMatches - 1, + ); // minus one because we start at 0 + + const fraction = await drawCalculator.calculatePrizeTierFraction( + prizeDistribution, + tierIndex, + ); + + let prizeCount: BigNumber = calculateNumberOfWinnersAtIndex( + prizeDistribution.bitRangeSize.toNumber(), + tierIndex.toNumber(), + ); + + const expectedPrizeFraction = + prizeDistribution.tiers[tierIndex.toNumber()].div(prizeCount); + + expect(fraction).to.equal(expectedPrizeFraction); + } + }); + }); + + describe('numberOfPrizesForIndex()', () => { + it('calculates the number of prizes at tiers index 0', async () => { + const bitRangeSize = 2; + + const result = await drawCalculator.numberOfPrizesForIndex( + bitRangeSize, + BigNumber.from(0), + ); + + expect(result).to.equal(1); // grand prize + }); + + it('calculates the number of prizes at tiers index 1', async () => { + const bitRangeSize = 3; + + const result = await drawCalculator.numberOfPrizesForIndex( + bitRangeSize, + BigNumber.from(1), + ); + + // Number that match exactly four: 8^1 - 8^0 = 7 + expect(result).to.equal(7); + }); + + it('calculates the number of prizes at tiers index 3', async () => { + const bitRangeSize = 3; + // numberOfPrizesForIndex(uint8 _bitRangeSize, uint256 _prizetierIndex) + // prizetierIndex = matchCardinality - numberOfMatches + // matchCardinality = 5, numberOfMatches = 2 + + const result = await drawCalculator.numberOfPrizesForIndex( + bitRangeSize, + BigNumber.from(3), + ); + + // Number that match exactly two: 8^3 - 8^2 + expect(result).to.equal(448); + }); + + it('calculates the number of prizes at all tiers indices', async () => { + let prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(5), + tiers: [ + ethers.utils.parseUnits('0.5', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from(utils.parseEther('1')), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + for (let tierIndex = 0; tierIndex < prizeDistribution.tiers.length; tierIndex++) { + const result = await drawCalculator.numberOfPrizesForIndex( + prizeDistribution.bitRangeSize, + tierIndex, + ); + + const expectedNumberOfWinners = calculateNumberOfWinnersAtIndex( + prizeDistribution.bitRangeSize.toNumber(), + tierIndex, + ); + + expect(result).to.equal(expectedNumberOfWinners); + } + }); + }); + + describe('calculatePrizeTiersFraction()', () => { + it('calculates tiers index 0', async () => { + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(5), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from(utils.parseEther('1')), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const bitMasks = await drawCalculator.createBitMasks(prizeDistribution); + + const winningRandomNumber = + '0x369ddb959b07c1d22a9bada1f3420961d0e0252f73c0f5b2173d7f7c6fe12b70'; + + const userRandomNumber = + '0x369ddb959b07c1d22a9bada1f3420961d0e0252f73c0f5b2173d7f7c6fe12b70'; // intentionally same as winning random number + + const prizetierIndex: BigNumber = await drawCalculator.calculateTierIndex( + userRandomNumber, + winningRandomNumber, + bitMasks, + ); + + // all numbers match so grand prize! + expect(prizetierIndex).to.eq(BigNumber.from(0)); + }); + + it('calculates tiers index 1', async () => { + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(2), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from(utils.parseEther('1')), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + // 252: 1111 1100 + // 255 1111 1111 + + const bitMasks = await drawCalculator.createBitMasks(prizeDistribution); + + expect(bitMasks.length).to.eq(2); // same as length of matchCardinality + expect(bitMasks[0]).to.eq(BigNumber.from(15)); + + const prizetierIndex: BigNumber = await drawCalculator.calculateTierIndex( + 252, + 255, + bitMasks, + ); + + // since the first 4 bits do not match the tiers index will be: (matchCardinality - numberOfMatches )= 2-0 = 2 + expect(prizetierIndex).to.eq(prizeDistribution.matchCardinality); + }); + + it('calculates tiers index 1', async () => { + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(3), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from(utils.parseEther('1')), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + // 527: 0010 0000 1111 + // 271 0001 0000 1111 + + const bitMasks = await drawCalculator.createBitMasks(prizeDistribution); + + expect(bitMasks.length).to.eq(3); // same as length of matchCardinality + expect(bitMasks[0]).to.eq(BigNumber.from(15)); + + const prizetierIndex: BigNumber = await drawCalculator.calculateTierIndex( + 527, + 271, + bitMasks, + ); + + // since the first 4 bits do not match the tiers index will be: (matchCardinality - numberOfMatches )= 3-2 = 1 + expect(prizetierIndex).to.eq(BigNumber.from(1)); + }); + }); + + describe('createBitMasks()', () => { + it('creates correct 6 bit masks', async () => { + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(2), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from(utils.parseEther('1')), + bitRangeSize: BigNumber.from(6), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const bitMasks = await drawCalculator.createBitMasks(prizeDistribution); + + expect(bitMasks[0]).to.eq(BigNumber.from(63)); // 111111 + expect(bitMasks[1]).to.eq(BigNumber.from(4032)); // 11111100000 + }); + + it('creates correct 4 bit masks', async () => { + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(2), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from(utils.parseEther('1')), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const bitMasks = await drawCalculator.createBitMasks(prizeDistribution); + + expect(bitMasks[0]).to.eq(BigNumber.from(15)); // 1111 + expect(bitMasks[1]).to.eq(BigNumber.from(240)); // 11110000 + }); + }); + + describe('calculateNumberOfUserPicks()', () => { + it('calculates the correct number of user picks', async () => { + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(5), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from('100'), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const normalizedUsersBalance = utils.parseEther('0.05'); // has 5% of the total supply + const userPicks = await drawCalculator.calculateNumberOfUserPicks( + prizeDistribution, + normalizedUsersBalance, + ); + + expect(userPicks).to.eq(BigNumber.from(5)); + }); + it('calculates the correct number of user picks', async () => { + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(5), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from('100000'), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + const normalizedUsersBalance = utils.parseEther('0.1'); // has 10% of the total supply + const userPicks = await drawCalculator.calculateNumberOfUserPicks( + prizeDistribution, + normalizedUsersBalance, + ); + + expect(userPicks).to.eq(BigNumber.from(10000)); // 10% of numberOfPicks + }); + }); + + describe('getNormalizedBalancesAt()', () => { + it('calculates the correct normalized balance', async () => { + const timestamps = [42, 77]; + + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(5), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from('100000'), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.endTimestampOffset.toNumber(), + ); + + const draw1: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from('1000'), + timestamp: BigNumber.from(timestamps[0]), + }); + + const draw2: Draw = newDraw({ + drawId: BigNumber.from(2), + winningRandomNumber: BigNumber.from('1000'), + timestamp: BigNumber.from(timestamps[1]), + }); + + await drawBuffer.mock.getDraws.returns([draw1, draw2]); + await prizeDistributionSource.mock.getPrizeDistributions.returns([ + prizeDistribution, + prizeDistribution, + ]); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([utils.parseEther('20'), utils.parseEther('30')]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([utils.parseEther('100'), utils.parseEther('600')]); + + const userNormalizedBalances = await drawCalculator.getNormalizedBalancesForDrawIds( + wallet1.address, + [1, 2], + ); + + expect(userNormalizedBalances[0]).to.eq(utils.parseEther('0.2')); + expect(userNormalizedBalances[1]).to.eq(utils.parseEther('0.05')); + }); + + it('returns 0 when totalSupply is zero', async () => { + const timestamps = [42, 77]; + + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(5), + tiers: [ + ethers.utils.parseUnits('0.6', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9), + ], + numberOfPicks: BigNumber.from('100000'), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.endTimestampOffset.toNumber(), + ); + + const draw1: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from('1000'), + timestamp: BigNumber.from(timestamps[0]), + }); + + const draw2: Draw = newDraw({ + drawId: BigNumber.from(2), + winningRandomNumber: BigNumber.from('1000'), + timestamp: BigNumber.from(timestamps[1]), + }); + + await drawBuffer.mock.getDraws.returns([draw1, draw2]); + await prizeDistributionSource.mock.getPrizeDistributions.returns([ + prizeDistribution, + prizeDistribution, + ]); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([utils.parseEther('10'), utils.parseEther('30')]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([utils.parseEther('0'), utils.parseEther('600')]); + + const balancesResult = await drawCalculator.getNormalizedBalancesForDrawIds( + wallet1.address, + [1, 2], + ); + expect(balancesResult[0]).to.equal(0); + }); + + it('returns zero when the balance is very small', async () => { + const timestamps = [42]; + + const prizeDistribution: PrizeDistribution = { + matchCardinality: BigNumber.from(5), + tiers: [ethers.utils.parseUnits('0.6', 9)], + numberOfPicks: BigNumber.from('100000'), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('1'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const draw1: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from('1000'), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw1]); + await prizeDistributionSource.mock.getPrizeDistributions.returns([prizeDistribution]); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([utils.parseEther('0.000000000000000001')]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([utils.parseEther('1000')]); + + const result = await drawCalculator.getNormalizedBalancesForDrawIds(wallet1.address, [ + 1, + ]); + + expect(result[0]).to.eq(BigNumber.from(0)); + }); + }); + + describe('calculate()', () => { + const debug = newDebug('pt:DrawCalculator.test.ts:calculate()'); + + context('with draw 1 set', () => { + let prizeDistribution: PrizeDistribution; + + beforeEach(async () => { + prizeDistribution = { + tiers: [ethers.utils.parseUnits('0.8', 9), ethers.utils.parseUnits('0.2', 9)], + numberOfPicks: BigNumber.from('10000'), + matchCardinality: BigNumber.from(5), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('100'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + await prizeDistributionSource.mock.getPrizeDistributions + .withArgs([1]) + .returns([prizeDistribution]); + }); + + it('should calculate and win grand prize', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [wallet1.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = utils.parseEther('10'); + const totalSupply = utils.parseEther('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + const result = await drawCalculator.calculate( + wallet1.address, + [draw.drawId], + pickIndices, + ); + + expect(result[0][0]).to.equal(utils.parseEther('80')); + const prizeCounts = encoder.decode(['uint256[][]'], result[1]); + expect(prizeCounts[0][0][0]).to.equal(BigNumber.from(1)); // has a prizeCount = 1 at grand winner index + assertEmptyArrayOfBigNumbers(prizeCounts[0][0].slice(1)); + + debug( + 'GasUsed for calculate(): ', + ( + await drawCalculator.estimateGas.calculate( + wallet1.address, + [draw.drawId], + pickIndices, + ) + ).toString(), + ); + }); + + it('should revert with expired draw', async () => { + // set draw timestamp as now + // set expiryDuration as 1 second + + const winningNumber = utils.solidityKeccak256(['address'], [wallet1.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = utils.parseEther('10'); + const totalSupply = utils.parseEther('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + prizeDistribution = { + tiers: [ethers.utils.parseUnits('0.8', 9), ethers.utils.parseUnits('0.2', 9)], + numberOfPicks: BigNumber.from('10000'), + matchCardinality: BigNumber.from(5), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('100'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + await prizeDistributionSource.mock.getPrizeDistributions + .withArgs([1]) + .returns([prizeDistribution]); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + await expect( + drawCalculator.calculate(wallet1.address, [draw.drawId], pickIndices), + ).to.revertedWith('DrawCalc/draw-expired'); + }); + + it('should revert with repeated pick indices', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [wallet1.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1', '1']]]); // this isn't valid + const ticketBalance = utils.parseEther('10'); + const totalSupply = utils.parseEther('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + await expect( + drawCalculator.calculate(wallet1.address, [draw.drawId], pickIndices), + ).to.revertedWith('DrawCalc/picks-ascending'); + }); + + it('can calculate 1000 picks', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [wallet1.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + + const pickIndices = encoder.encode( + ['uint256[][]'], + [[[...new Array(1000).keys()]]], + ); + + const totalSupply = utils.parseEther('10000'); + const ticketBalance = utils.parseEther('1000'); // 10 percent of total supply + // prizeDistributions.numberOfPicks = 10000 so user has 1000 picks + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + debug( + 'GasUsed for calculate 1000 picks(): ', + ( + await drawCalculator.estimateGas.calculate( + wallet1.address, + [draw.drawId], + pickIndices, + ) + ).toString(), + ); + }); + + it('should match all numbers but prize tiers is 0 at index 0', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [wallet1.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + prizeDistribution = { + ...prizeDistribution, + tiers: [ + ethers.utils.parseUnits('0', 9), // NOTE ZERO here + ethers.utils.parseUnits('0.2', 9), + ], + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + await prizeDistributionSource.mock.getPrizeDistributions + .withArgs([1]) + .returns([prizeDistribution]); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = utils.parseEther('10'); + const totalSupply = utils.parseEther('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + const prizesAwardable = await drawCalculator.calculate( + wallet1.address, + [draw.drawId], + pickIndices, + ); + + expect(prizesAwardable[0][0]).to.equal(utils.parseEther('0')); + }); + + it('should match all numbers but prize tiers is 0 at index 1', async () => { + prizeDistribution = { + ...prizeDistribution, + bitRangeSize: BigNumber.from(2), + matchCardinality: BigNumber.from(3), + tiers: [ + ethers.utils.parseUnits('0.1', 9), // NOTE ZERO here + ethers.utils.parseUnits('0', 9), + ethers.utils.parseUnits('0.2', 9), + ], + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + await prizeDistributionSource.mock.getPrizeDistributions + .withArgs([1]) + .returns([prizeDistribution]); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = utils.parseEther('10'); + const totalSupply = utils.parseEther('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from( + '25671298157762322557963155952891969742538148226988266342908289227085909174336', + ), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + const prizesAwardable = await drawCalculator.calculate( + wallet1.address, + [draw.drawId], + pickIndices, + ); + + expect(prizesAwardable[0][0]).to.equal(utils.parseEther('0')); + const prizeCounts = encoder.decode(['uint256[][]'], prizesAwardable[1]); + expect(prizeCounts[0][0][1]).to.equal(BigNumber.from(1)); // has a prizeCount = 1 at runner up index + assertEmptyArrayOfBigNumbers(prizeCounts[0][0].slice(2)); + }); + + it('runner up matches but tier is 0 at index 1', async () => { + // cardinality 3 + // matches = 2 + // non zero tiers = 4 + prizeDistribution = { + ...prizeDistribution, + bitRangeSize: BigNumber.from(2), + matchCardinality: BigNumber.from(3), + tiers: [ + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0', 9), // NOTE ZERO here + ethers.utils.parseUnits('0.1', 9), + ethers.utils.parseUnits('0.1', 9) + ], + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + await prizeDistributionSource.mock.getPrizeDistributions + .withArgs([1]) + .returns([prizeDistribution]); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = utils.parseEther('10'); + const totalSupply = utils.parseEther('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from( + '25671298157762322557963155952891969742538148226988266342908289227085909174336', + ), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + const prizesAwardable = await drawCalculator.calculate( + wallet1.address, + [draw.drawId], + pickIndices, + ); + + expect(prizesAwardable[0][0]).to.equal(utils.parseEther('0')); + const prizeCounts = encoder.decode(['uint256[][]'], prizesAwardable[1]); + expect(prizeCounts[0][0][1]).to.equal(BigNumber.from(1)); // has a prizeCount = 1 at runner up index + assertEmptyArrayOfBigNumbers(prizeCounts[0][0].slice(2)); + }); + + it('should calculate for multiple picks, first pick grand prize winner, second pick no winnings', async () => { + //function calculate(address user, uint256[] calldata randomNumbers, uint256[] calldata timestamps, uint256[] calldata prizes, bytes calldata data) external override view returns (uint256){ + + const winningNumber = utils.solidityKeccak256(['address'], [wallet1.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [ + (await ethers.provider.getBlock('latest')).timestamp - 10, + (await ethers.provider.getBlock('latest')).timestamp - 5, + ]; + + const pickIndices = encoder.encode(['uint256[][]'], [[['1'], ['2']]]); + const ticketBalance = utils.parseEther('10'); + const ticketBalance2 = utils.parseEther('10'); + const totalSupply1 = utils.parseEther('100'); + const totalSupply2 = utils.parseEther('100'); + + const draw1: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + const draw2: Draw = newDraw({ + drawId: BigNumber.from(2), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[1]), + }); + + await drawBuffer.mock.getDraws.returns([draw1, draw2]); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance, ticketBalance2]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply1, totalSupply2]); + + const prizeDistribution2: PrizeDistribution = { + tiers: [ethers.utils.parseUnits('0.8', 9), ethers.utils.parseUnits('0.2', 9)], + numberOfPicks: BigNumber.from(utils.parseEther('1')), + matchCardinality: BigNumber.from(5), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('20'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution2.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + debug(`pushing settings for draw 2...`); + + await prizeDistributionSource.mock.getPrizeDistributions + .withArgs([1, 2]) + .returns([prizeDistribution, prizeDistribution2]); + + const result = await drawCalculator.calculate( + wallet1.address, + [draw1.drawId, draw2.drawId], + pickIndices, + ); + + expect(result[0][0]).to.equal(utils.parseEther('80')); + expect(result[0][1]).to.equal(utils.parseEther('0')); + + const prizeCounts = encoder.decode(['uint256[][]'], result[1]); + expect(prizeCounts[0][0][0]).to.equal(BigNumber.from(1)); // has a prizeCount = 1 at grand winner index for first draw + expect(prizeCounts[0][1][0]).to.equal(BigNumber.from(0)); // has a prizeCount = 1 at grand winner index for second draw + + debug( + 'GasUsed for 2 calculate() calls: ', + ( + await drawCalculator.estimateGas.calculate( + wallet1.address, + [draw1.drawId, draw2.drawId], + pickIndices, + ) + ).toString(), + ); + }); + + it('should not have enough funds for a second pick and revert', async () => { + // the first draw the user has > 1 pick and the second draw has 0 picks (0.3/100 < 0.5 so rounds down to 0) + const winningNumber = utils.solidityKeccak256(['address'], [wallet1.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [ + (await ethers.provider.getBlock('latest')).timestamp - 9, + (await ethers.provider.getBlock('latest')).timestamp - 5, + ]; + const totalSupply1 = utils.parseEther('100'); + const totalSupply2 = utils.parseEther('100'); + + const pickIndices = encoder.encode(['uint256[][]'], [[['1'], ['2']]]); + const ticketBalance = ethers.utils.parseEther('6'); // they had 6pc of all tickets + + const prizeDistribution: PrizeDistribution = { + tiers: [ethers.utils.parseUnits('0.8', 9), ethers.utils.parseUnits('0.2', 9)], + numberOfPicks: BigNumber.from(1), + matchCardinality: BigNumber.from(5), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('100'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.endTimestampOffset.toNumber(), + ); + + const ticketBalance2 = ethers.utils.parseEther('0.3'); // they had 0.03pc of all tickets + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance, ticketBalance2]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply1, totalSupply2]); + + const draw1: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + const draw2: Draw = newDraw({ + drawId: BigNumber.from(2), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[1]), + }); + + await drawBuffer.mock.getDraws.returns([draw1, draw2]); + + await prizeDistributionSource.mock.getPrizeDistributions + .withArgs([1, 2]) + .returns([prizeDistribution, prizeDistribution]); + + await expect( + drawCalculator.calculate( + wallet1.address, + [draw1.drawId, draw2.drawId], + pickIndices, + ), + ).to.revertedWith('DrawCalc/insufficient-user-picks'); + }); + + it('should revert exceeding max user picks', async () => { + // maxPicksPerUser is set to 2, user tries to claim with 3 picks + const winningNumber = utils.solidityKeccak256(['address'], [wallet1.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + const totalSupply1 = utils.parseEther('100'); + const pickIndices = encoder.encode(['uint256[][]'], [[['1', '2', '3']]]); + const ticketBalance = ethers.utils.parseEther('6'); + + const prizeDistribution: PrizeDistribution = { + tiers: [ethers.utils.parseUnits('0.8', 9), ethers.utils.parseUnits('0.2', 9)], + numberOfPicks: BigNumber.from(1), + matchCardinality: BigNumber.from(5), + bitRangeSize: BigNumber.from(4), + prize: ethers.utils.parseEther('100'), + startTimestampOffset: BigNumber.from(1), + endTimestampOffset: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(2), + expiryDuration: BigNumber.from(1000), + }; + + prizeDistribution.tiers = fillPrizeTiersWithZeros(prizeDistribution.tiers); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply1]); + + const draw1: Draw = newDraw({ + drawId: BigNumber.from(2), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw1]); + + await prizeDistributionSource.mock.getPrizeDistributions + .withArgs([2]) + .returns([prizeDistribution]); + + await expect( + drawCalculator.calculate(wallet1.address, [draw1.drawId], pickIndices), + ).to.revertedWith('DrawCalc/exceeds-max-user-picks'); + }); + + it('should calculate and win nothing', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [wallet2.address]); + const userRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 112312312], + ); + + const timestamps = [(await ethers.provider.getBlock('latest')).timestamp]; + const totalSupply = utils.parseEther('100'); + + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = utils.parseEther('10'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.startTimestampOffset.toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeDistribution.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(wallet1.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw1: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(userRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw1]); + + const prizesAwardable = await drawCalculator.calculate( + wallet1.address, + [draw1.drawId], + pickIndices, + ); + + expect(prizesAwardable[0][0]).to.equal(utils.parseEther('0')); + const prizeCounts = encoder.decode(['uint256[][]'], prizesAwardable[1]); + // there will always be a prizeCount at matchCardinality index + assertEmptyArrayOfBigNumbers( + prizeCounts[0][0].slice(prizeDistribution.matchCardinality.toNumber() + 1), + ); + }); + }); + }); +});