From 4615b03bda094eff2358475f34780d23ff61194a Mon Sep 17 00:00:00 2001 From: Pierrick Turelier Date: Mon, 16 May 2022 11:54:33 -0500 Subject: [PATCH] feat(tests): add DrawCalculatorV3 unit tests --- contracts/DrawCalculatorV3.sol | 64 +- contracts/interfaces/IDrawCalculatorV3.sol | 10 +- contracts/test/DrawCalculatorV3Harness.sol | 46 + test/DrawCalculatorV3.test.ts | 1457 ++++++++++++++++++++ test/types.ts | 12 + 5 files changed, 1557 insertions(+), 32 deletions(-) create mode 100644 contracts/test/DrawCalculatorV3Harness.sol create mode 100644 test/DrawCalculatorV3.test.ts diff --git a/contracts/DrawCalculatorV3.sol b/contracts/DrawCalculatorV3.sol index daf108f7..980b4668 100644 --- a/contracts/DrawCalculatorV3.sol +++ b/contracts/DrawCalculatorV3.sol @@ -33,6 +33,9 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { /// @notice DrawBuffer address IDrawBuffer public immutable drawBuffer; + /// @notice PrizeConfigHistory address + IPrizeConfigHistory public immutable prizeConfigHistory; + /// @notice The tiers array length uint8 public constant TIERS_LENGTH = 16; @@ -42,10 +45,12 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { * @notice Emitted when the contract is initialized * @param gaugeController Address of the GaugeController * @param drawBuffer Address of the DrawBuffer + * @param prizeConfigHistory Address of the PrizeConfigHistory */ event Deployed( IGaugeController indexed gaugeController, - IDrawBuffer indexed drawBuffer + IDrawBuffer indexed drawBuffer, + IPrizeConfigHistory indexed prizeConfigHistory ); /* ============ Constructor ============ */ @@ -53,22 +58,26 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { /** * @notice DrawCalculator constructor * @param _gaugeController Address of the GaugeController - * @param _drawBuffer Address of the draw buffer to push draws to + * @param _drawBuffer Address of the DrawBuffer to push draws to + * @param _prizeConfigHistory Address of the PrizeConfigHistory * @param _owner Address of the owner */ constructor( IGaugeController _gaugeController, IDrawBuffer _drawBuffer, + IPrizeConfigHistory _prizeConfigHistory, address _owner ) Ownable(_owner) { require(address(_gaugeController) != address(0), "DrawCalc/GC-not-zero-address"); require(address(_drawBuffer) != address(0), "DrawCalc/DB-not-zero-address"); + require(address(_prizeConfigHistory) != address(0), "DrawCalc/PCH-not-zero-address"); require(_owner != address(0), "DrawCalc/owner-not-zero-address"); gaugeController = _gaugeController; drawBuffer = _drawBuffer; + prizeConfigHistory = _prizeConfigHistory; - emit Deployed(_gaugeController, _drawBuffer); + emit Deployed(_gaugeController, _drawBuffer, _prizeConfigHistory); } /* ============ External Functions ============ */ @@ -76,7 +85,6 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { /// @inheritdoc IDrawCalculatorV3 function calculate( ITicket _ticket, - IPrizeConfigHistory _prizeConfigHistory, address _user, uint32[] calldata _drawIds, bytes calldata _pickIndicesForDraws @@ -89,7 +97,6 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { return _calculatePrizesAwardable( _ticket, - _prizeConfigHistory, _user, _userRandomNumber, _drawIds, @@ -100,7 +107,6 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { /// @inheritdoc IDrawCalculatorV3 function calculateUserPicks( ITicket _ticket, - IPrizeConfigHistory _prizeConfigHistory, address _user, uint32[] calldata _drawIds ) external view override returns (uint64[] memory picks) { @@ -110,7 +116,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { for (uint32 _drawIndex = 0; _drawIndex < _drawsLength; _drawIndex++) { IDrawBeacon.Draw memory _draw = _draws[_drawIndex]; - IPrizeConfigHistory.PrizeConfig memory _prizeConfig = _prizeConfigHistory.getPrizeConfig(_draw.drawId); + IPrizeConfigHistory.PrizeConfig memory _prizeConfig = prizeConfigHistory.getPrizeConfig(_draw.drawId); _requireDrawUnexpired(_draw, _prizeConfig); @@ -138,6 +144,11 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { return gaugeController; } + /// @inheritdoc IDrawCalculatorV3 + function getPrizeConfigHistory() external override view returns (IPrizeConfigHistory) { + return prizeConfigHistory; + } + /// @inheritdoc IDrawCalculatorV3 function getTotalPicks( ITicket _ticket, @@ -155,7 +166,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { /** * @notice Ensure that the draw is not expired. * @param _draw Draw - * @param _prizeConfig Prize tier + * @param _prizeConfig PrizeConfig */ function _requireDrawUnexpired( IDrawBeacon.Draw memory _draw, @@ -167,7 +178,6 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { /** * @notice Calculates the prizes awardable for each DrawIds passed. * @param _ticket Address of the ticket to calculate awardable prizes for - * @param _prizeConfigHistory Address of the prizeConfigHistory associated with the ticket * @param _user Address of the user for which to calculate awardable prizes for * @param _userRandomNumber Random number of the user to consider over draws * @param _drawIds Array of DrawIds for which to calculate awardable prizes for @@ -175,7 +185,6 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { */ function _calculatePrizesAwardable( ITicket _ticket, - IPrizeConfigHistory _prizeConfigHistory, address _user, bytes32 _userRandomNumber, uint32[] memory _drawIds, @@ -190,7 +199,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { // Calculate prizes awardable for each Draw passed for (uint32 _drawIndex = 0; _drawIndex < _draws.length; _drawIndex++) { IDrawBeacon.Draw memory _draw = _draws[_drawIndex]; - IPrizeConfigHistory.PrizeConfig memory _prizeConfig = _prizeConfigHistory.getPrizeConfig(_draw.drawId); + IPrizeConfigHistory.PrizeConfig memory _prizeConfig = prizeConfigHistory.getPrizeConfig(_draw.drawId); _requireDrawUnexpired(_draw, _prizeConfig); @@ -218,7 +227,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { } /** - * @notice Calculates the number of picks a user gets for a Draw, considering the normalized user balance and the PrizeDistribution. + * @notice Calculates the number of picks a user gets for a Draw, considering the normalized user balance and the PrizeConfig. * @dev Divided by 1e18 since the normalized user balance is stored as a fixed point 18 number. * @param _ticket Address of the ticket to get total picks for * @param _startTimestamp Timestamp at which the prize starts @@ -239,7 +248,6 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { ) internal view returns (uint64) { uint256 _numberOfPicks = _getTotalPicks(_ticket, _startTimestamp, _endTimestamp, _poolStakeCeiling, _bitRange, _cardinality); uint256 _normalizedBalance = _getNormalizedBalanceAt(_ticket, _user, _startTimestamp, _endTimestamp); - return uint64((_normalizedBalance * _numberOfPicks) / 1 ether); } @@ -307,12 +315,12 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { } /** - * @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 _prizeConfig PrizeConfig for that draw + * @notice Calculates the prize amount for a PrizeConfig over given picks + * @param _winningRandomNumber Draw's winningRandomNumber + * @param _totalUserPicks Number of picks the user gets for the Draw + * @param _userRandomNumber User randomNumber for that draw + * @param _picks User picks for that draw + * @param _prizeConfig PrizeConfig for that draw * @return prize (if any), prizeCounts (if any) */ function _calculate( @@ -321,8 +329,8 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { bytes32 _userRandomNumber, uint64[] memory _picks, IPrizeConfigHistory.PrizeConfig memory _prizeConfig - ) internal pure returns (uint256 prize, uint256[] memory prizeCounts) { - // Create bitmasks for the PrizeDistribution + ) internal view returns (uint256 prize, uint256[] memory prizeCounts) { + // Create bitmasks for the PrizeConfig uint256[] memory masks = _createBitMasks(_prizeConfig.matchCardinality, _prizeConfig.bitRangeSize); uint32 picksLength = uint32(_picks.length); uint256[] memory _prizeCounts = new uint256[](_prizeConfig.tiers.length); @@ -334,7 +342,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { "DrawCalc/exceeds-max-user-picks" ); - // for each pick, find number of matching numbers and calculate prize distributions index + // for each pick, find number of matching numbers and calculate prize configs index for (uint32 index = 0; index < picksLength; index++) { require(_picks[index] < _totalUserPicks, "DrawCalc/insufficient-user-picks"); @@ -399,7 +407,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { * @notice Calculates the tier index given the random numbers and masks * @param _randomNumberThisPick User random number for this Pick * @param _winningRandomNumber The winning number for this draw - * @param _masks The pre-calculated bitmasks for the prizeDistributions + * @param _masks The pre-calculated bitmasks for the PrizeConfig * @return The position within the prize tier array (0 = top prize, 1 = runner-up prize, etc) */ function _calculateTierIndex( @@ -431,7 +439,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { } /** - * @notice Creates an array of bitmasks equal to the PrizeDistribution.matchCardinality length + * @notice Creates an array of bitmasks equal to the PrizeConfig.matchCardinality length * @param _matchCardinality Match cardinality for Draw * @param _bitRangeSize Bit range size for Draw * @return Array of bitmasks @@ -453,8 +461,8 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { } /** - * @notice Calculates the expected prize fraction per PrizeDistributions and distributionIndex - * @param _prizeFraction Prize fraction for this PrizeDistribution + * @notice Calculates the expected prize fraction per PrizeConfig and prize tiers index + * @param _prizeFraction Prize fraction for this PrizeConfig * @param _bitRangeSize Bit range size for Draw * @param _prizeConfigIndex Index of the prize tiers array to calculate * @return returns the fraction of the total prize (fixed point 9 number) @@ -474,9 +482,9 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { } /** - * @notice Calculates the number of prizes for a given prizeDistributionIndex + * @notice Calculates the number of prizes for a given PrizeConfigIndex * @param _bitRangeSize Bit range size for Draw - * @param _prizeConfigIndex Index of the prize config array to calculate + * @param _prizeConfigIndex Index of the PrizeConfig array to calculate * @return returns the fraction of the total prize (base 1e18) */ function _numberOfPrizesForIndex(uint8 _bitRangeSize, uint256 _prizeConfigIndex) diff --git a/contracts/interfaces/IDrawCalculatorV3.sol b/contracts/interfaces/IDrawCalculatorV3.sol index 20a94df3..88d3f060 100644 --- a/contracts/interfaces/IDrawCalculatorV3.sol +++ b/contracts/interfaces/IDrawCalculatorV3.sol @@ -16,7 +16,6 @@ interface IDrawCalculatorV3 { /** * @notice Calculates the awardable prizes for a user for Multiple Draws. Typically called by a PrizeDistributor. * @param ticket Address of the ticket to calculate awardable prizes for - * @param prizeConfigHistory Address of the prizeConfigHistory associated with the ticket * @param user Address of the user for which to calculate awardable prizes for * @param drawIds Array of DrawIds for which to calculate awardable prizes for * @param data ABI encoded pick indices for all Draws. Expected to be winning picks. Pick indices must be less than the totalUserPicks. @@ -25,7 +24,6 @@ interface IDrawCalculatorV3 { */ function calculate( ITicket ticket, - IPrizeConfigHistory prizeConfigHistory, address user, uint32[] calldata drawIds, bytes calldata data @@ -34,13 +32,11 @@ interface IDrawCalculatorV3 { /** * @notice Calculates picks for a user for Multiple Draws. * @param ticket Address of the ticket to calculate picks for - * @param prizeConfigHistory Address of the prizeConfigHistory associated with the ticket * @param user Address of the user for which to calculate picks for * @param drawIds Array of DrawIds for which to calculate picks for */ function calculateUserPicks( ITicket ticket, - IPrizeConfigHistory prizeConfigHistory, address user, uint32[] calldata drawIds ) external view returns (uint64[] memory); @@ -57,6 +53,12 @@ interface IDrawCalculatorV3 { */ function getGaugeController() external view returns (IGaugeController); + /** + * @notice Returns PrizeConfigHistory address. + * @return The PrizeConfigHistory address + */ + function getPrizeConfigHistory() external view returns (IPrizeConfigHistory); + /** * @notice Returns the total number of picks for a prize pool / ticket. * @param ticket Address of the ticket to get total picks for diff --git a/contracts/test/DrawCalculatorV3Harness.sol b/contracts/test/DrawCalculatorV3Harness.sol new file mode 100644 index 00000000..0d93e2e6 --- /dev/null +++ b/contracts/test/DrawCalculatorV3Harness.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.6; + +import "../DrawCalculatorV3.sol"; + +contract DrawCalculatorV3Harness is DrawCalculatorV3 { + constructor( + IGaugeController _gaugeController, + IDrawBuffer _drawBuffer, + IPrizeConfigHistory _prizeConfigHistory, + address _owner + ) DrawCalculatorV3(_gaugeController, _drawBuffer, _prizeConfigHistory, _owner) {} + + function calculateTierIndex( + uint256 _randomNumberThisPick, + uint256 _winningRandomNumber, + uint256[] memory _masks + ) public pure returns (uint256) { + return _calculateTierIndex(_randomNumberThisPick, _winningRandomNumber, _masks); + } + + function createBitMasks(uint8 _matchCardinality, uint8 _bitRangeSize) + public + pure + returns (uint256[] memory) + { + return _createBitMasks(_matchCardinality, _bitRangeSize); + } + + function calculatePrizeTierFraction( + uint256 _prizeFraction, + uint8 _bitRangeSize, + uint256 _prizeConfigIndex + ) external pure returns (uint256) { + return _calculatePrizeTierFraction(_prizeFraction, _bitRangeSize, _prizeConfigIndex); + } + + function numberOfPrizesForIndex(uint8 _bitRangeSize, uint256 _prizeConfigIndex) + external + pure + returns (uint256) + { + return _numberOfPrizesForIndex(_bitRangeSize, _prizeConfigIndex); + } +} diff --git a/test/DrawCalculatorV3.test.ts b/test/DrawCalculatorV3.test.ts new file mode 100644 index 00000000..9dfb55a0 --- /dev/null +++ b/test/DrawCalculatorV3.test.ts @@ -0,0 +1,1457 @@ +import { expect } from 'chai'; +import { deployMockContract, MockContract } from 'ethereum-waffle'; +import { BigNumber, Contract } from 'ethers'; +import { ethers, artifacts } from 'hardhat'; +import { Draw, PrizeConfig } from './types'; +import { fillPrizeTiersWithZeros } from './helpers/fillPrizeTiersWithZeros'; + +const { constants, getSigners, provider, utils } = ethers; +const { AddressZero } = constants; +const { parseEther: toWei, parseUnits } = utils; + +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( + gaugeControllerAddress: string, + drawBufferAddress: string, + prizeConfigHistoryAddress: string, + owner: string, +): Promise { + const drawCalculatorFactory = await ethers.getContractFactory('DrawCalculatorV3Harness'); + + return await drawCalculatorFactory.deploy( + gaugeControllerAddress, + drawBufferAddress, + prizeConfigHistoryAddress, + owner, + ); +} + +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('DrawCalculatorV3', () => { + const debug = newDebug('pt:DrawCalculator.test.ts:calculate()'); + + let drawCalculator: Contract; + let ticket: MockContract; + let gaugeController: MockContract; + let drawBuffer: MockContract; + let prizeConfigHistory: MockContract; + let owner: any; + let wallet2: any; + let wallet3: any; + + let constructorTest = false; + + const encoder = ethers.utils.defaultAbiCoder; + + beforeEach(async () => { + [owner, wallet2, wallet3] = await getSigners(); + + let ticketArtifact = await artifacts.readArtifact('Ticket'); + ticket = await deployMockContract(owner, ticketArtifact.abi); + + let gaugeControllerArtifact = await artifacts.readArtifact('GaugeController'); + gaugeController = await deployMockContract(owner, gaugeControllerArtifact.abi); + + let drawBufferArtifact = await artifacts.readArtifact('DrawBuffer'); + drawBuffer = await deployMockContract(owner, drawBufferArtifact.abi); + + let prizeConfigHistoryArtifact = await artifacts.readArtifact('PrizeConfigHistory'); + + prizeConfigHistory = await deployMockContract(owner, prizeConfigHistoryArtifact.abi); + + if (!constructorTest) { + drawCalculator = await deployDrawCalculator( + gaugeController.address, + drawBuffer.address, + prizeConfigHistory.address, + owner.address, + ); + } + }); + + describe('constructor()', () => { + beforeEach(() => { + constructorTest = true; + }); + + afterEach(() => { + constructorTest = false; + }); + + it('should deploy DrawCalculatorV3', async () => { + const drawCalculatorV3 = await deployDrawCalculator( + gaugeController.address, + drawBuffer.address, + prizeConfigHistory.address, + owner.address, + ); + + await expect(drawCalculatorV3.deployTransaction) + .to.emit(drawCalculatorV3, 'Deployed') + .withArgs(gaugeController.address, drawBuffer.address, prizeConfigHistory.address); + }); + + it('should fail if gaugeController is address zero', async () => { + await expect( + deployDrawCalculator( + AddressZero, + drawBuffer.address, + prizeConfigHistory.address, + owner.address, + ), + ).to.be.revertedWith('DrawCalc/GC-not-zero-address'); + }); + + it('should fail if drawBuffer is address zero', async () => { + await expect( + deployDrawCalculator( + gaugeController.address, + AddressZero, + prizeConfigHistory.address, + owner.address, + ), + ).to.be.revertedWith('DrawCalc/DB-not-zero-address'); + }); + + it('should fail if prizeConfigHistory is address zero', async () => { + await expect( + deployDrawCalculator( + gaugeController.address, + drawBuffer.address, + AddressZero, + owner.address, + ), + ).to.be.revertedWith('DrawCalc/PCH-not-zero-address'); + }); + + it('should fail if owner is address zero', async () => { + await expect( + deployDrawCalculator( + gaugeController.address, + drawBuffer.address, + prizeConfigHistory.address, + AddressZero, + ), + ).to.be.revertedWith('DrawCalc/owner-not-zero-address'); + }); + }); + + describe('getDrawBuffer()', () => { + it('should successfully get draw buffer address', async () => { + expect(await drawCalculator.getDrawBuffer()).to.equal(drawBuffer.address); + }); + }); + + describe('getPrizeConfigHistory()', () => { + it('should successfully get gauge controller address', async () => { + expect(await drawCalculator.getGaugeController()).to.equal(gaugeController.address); + }); + }); + + describe('getPrizeConfigHistory()', () => { + it('should successfully get prize config history address', async () => { + expect(await drawCalculator.getPrizeConfigHistory()).to.equal( + prizeConfigHistory.address, + ); + }); + }); + + describe('calculatePrizeTierFraction()', () => { + let prizeConfig: PrizeConfig; + + beforeEach(async () => { + prizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(10), + tiers: [ + parseUnits('0.6', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('1'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + }); + + it('grand prize gets the full fraction at index 0', async () => { + const amount = await drawCalculator.calculatePrizeTierFraction( + prizeConfig.tiers[0], + prizeConfig.bitRangeSize, + BigNumber.from(0), + ); + + expect(amount).to.equal(prizeConfig.tiers[0]); + }); + + it('runner up gets part of the fraction at index 1', async () => { + const amount = await drawCalculator.calculatePrizeTierFraction( + prizeConfig.tiers[1], + prizeConfig.bitRangeSize, + BigNumber.from(1), + ); + + const prizeCount = calculateNumberOfWinnersAtIndex( + prizeConfig.bitRangeSize.toNumber(), + 1, + ); + + const expectedPrizeFraction = prizeConfig.tiers[1].div(prizeCount); + + expect(amount).to.equal(expectedPrizeFraction); + }); + + it('all prize tier indexes', async () => { + for ( + let numberOfMatches = 0; + numberOfMatches < prizeConfig.tiers.length; + numberOfMatches++ + ) { + const tierIndex = BigNumber.from(prizeConfig.tiers.length - numberOfMatches - 1); // minus one because we start at 0 + + const fraction = await drawCalculator.calculatePrizeTierFraction( + prizeConfig.tiers[Number(tierIndex)], + prizeConfig.bitRangeSize, + tierIndex, + ); + + let prizeCount: BigNumber = calculateNumberOfWinnersAtIndex( + prizeConfig.bitRangeSize.toNumber(), + tierIndex.toNumber(), + ); + + const expectedPrizeFraction = + prizeConfig.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; + + 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 prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [ + parseUnits('0.5', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('1'), + endTimestampOffset: BigNumber.from(1), + }; + + for (let tierIndex = 0; tierIndex < prizeConfig.tiers.length; tierIndex++) { + const result = await drawCalculator.numberOfPrizesForIndex( + prizeConfig.bitRangeSize, + tierIndex, + ); + + const expectedNumberOfWinners = calculateNumberOfWinnersAtIndex( + prizeConfig.bitRangeSize.toNumber(), + tierIndex, + ); + + expect(result).to.equal(expectedNumberOfWinners); + } + }); + }); + + describe('calculateTierIndex()', () => { + it('calculates tiers index 0', async () => { + const prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [ + parseUnits('0.6', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('1'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + const bitMasks = await drawCalculator.createBitMasks( + prizeConfig.matchCardinality, + prizeConfig.bitRangeSize, + ); + + 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 prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(2), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [ + parseUnits('0.6', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('1'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + // 252: 1111 1100 + // 255 1111 1111 + + const bitMasks = await drawCalculator.createBitMasks( + prizeConfig.matchCardinality, + prizeConfig.bitRangeSize, + ); + + 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(prizeConfig.matchCardinality); + }); + + it('calculates tiers index 1', async () => { + const prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(3), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [ + parseUnits('0.6', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('1'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + // 527: 0010 0000 1111 + // 271 0001 0000 1111 + + const bitMasks = await drawCalculator.createBitMasks( + prizeConfig.matchCardinality, + prizeConfig.bitRangeSize, + ); + + 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 prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(6), + matchCardinality: BigNumber.from(2), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [ + parseUnits('0.6', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('1'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + const bitMasks = await drawCalculator.createBitMasks( + prizeConfig.matchCardinality, + prizeConfig.bitRangeSize, + ); + + 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 prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(2), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [ + parseUnits('0.6', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('1'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + const bitMasks = await drawCalculator.createBitMasks( + prizeConfig.matchCardinality, + prizeConfig.bitRangeSize, + ); + + expect(bitMasks[0]).to.eq(BigNumber.from(15)); // 1111 + expect(bitMasks[1]).to.eq(BigNumber.from(240)); // 11110000 + }); + }); + + describe('calculateUserPicks()', () => { + let offsetStartTimestamps: number[]; + let offsetEndTimestamps: number[]; + + beforeEach(async () => { + const prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [ + parseUnits('0.6', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('1'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(1).returns(prizeConfig); + + const winningNumber = utils.solidityKeccak256(['address'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + + const draw: Draw = newDraw({ + drawId: BigNumber.from(1), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(100)); + }); + + it('calculates the correct number of user picks', async () => { + const ticketBalance = toWei('5'); + const totalSupply = toWei('100000'); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const userPicks = await drawCalculator.calculateUserPicks( + ticket.address, + owner.address, + ['1'], + ); + + expect(userPicks[0]).to.equal(BigNumber.from(5)); + }); + + it('calculates the correct number of user picks', async () => { + const ticketBalance = toWei('10000'); + const totalSupply = toWei('100000'); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const userPicks = await drawCalculator.calculateUserPicks( + ticket.address, + owner.address, + ['1'], + ); + + expect(userPicks[0]).to.eq(BigNumber.from(10485)); + }); + }); + + describe('calculate()', () => { + context('with draw 1 set', () => { + let prizeConfig: PrizeConfig; + + beforeEach(async () => { + prizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [parseUnits('0.8', 9), parseUnits('0.2', 9)], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('100'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(1).returns(prizeConfig); + }); + + it('should calculate and win grand prize', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = toWei('10'); + const totalSupply = toWei('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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 gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(100)); + + const result = await drawCalculator.calculate( + ticket.address, + owner.address, + [draw.drawId], + pickIndices, + ); + + expect(result[0][0]).to.equal(toWei('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( + ticket.address, + owner.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'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = toWei('10'); + const totalSupply = toWei('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + prizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [parseUnits('0.8', 9), parseUnits('0.2', 9)], + expiryDuration: BigNumber.from(1), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('100'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(1).returns(prizeConfig); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): [balance] + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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 gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(100)); + + await expect( + drawCalculator.calculate( + ticket.address, + owner.address, + [draw.drawId], + pickIndices, + ), + ).to.revertedWith('DrawCalc/draw-expired'); + }); + + it('should revert with repeated pick indices', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1', '1']]]); // this isn't valid + const ticketBalance = toWei('10'); + const totalSupply = toWei('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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 gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(100)); + + await expect( + drawCalculator.calculate( + ticket.address, + owner.address, + [draw.drawId], + pickIndices, + ), + ).to.revertedWith('DrawCalc/picks-ascending'); + }); + + it('can calculate 1000 picks', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + + const pickIndices = encoder.encode( + ['uint256[][]'], + [[[...new Array(1000).keys()]]], + ); + + const totalSupply = toWei('10000'); + const ticketBalance = toWei('1000'); // 10 percent of total supply + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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 gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(100)); + + debug( + 'GasUsed for calculate 1000 picks(): ', + ( + await drawCalculator.estimateGas.calculate( + ticket.address, + owner.address, + [draw.drawId], + pickIndices, + ) + ).toString(), + ); + }); + + it('should match all numbers but prize tiers is 0 at index 0', async () => { + const winningNumber = utils.solidityKeccak256(['address'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + prizeConfig = { + ...prizeConfig, + tiers: [ + parseUnits('0', 9), // NOTE ZERO here + parseUnits('0.2', 9), + ], + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(1).returns(prizeConfig); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = toWei('10'); + const totalSupply = toWei('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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 gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(100)); + + const prizesAwardable = await drawCalculator.calculate( + ticket.address, + owner.address, + [draw.drawId], + pickIndices, + ); + + expect(prizesAwardable[0][0]).to.equal(toWei('0')); + }); + + it('should match all numbers but prize tiers is 0 at index 1', async () => { + prizeConfig = { + ...prizeConfig, + bitRangeSize: BigNumber.from(2), + matchCardinality: BigNumber.from(3), + tiers: [ + parseUnits('0.1', 9), // NOTE ZERO here + parseUnits('0', 9), + parseUnits('0.2', 9), + ], + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(1).returns(prizeConfig); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = toWei('10'); + const totalSupply = toWei('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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]); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(1000)); + + const prizesAwardable = await drawCalculator.calculate( + ticket.address, + owner.address, + [draw.drawId], + pickIndices, + ); + + expect(prizesAwardable[0][0]).to.equal(toWei('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 + prizeConfig = { + ...prizeConfig, + bitRangeSize: BigNumber.from(2), + matchCardinality: BigNumber.from(3), + tiers: [ + parseUnits('0.1', 9), + parseUnits('0', 9), // NOTE ZERO here + parseUnits('0.1', 9), + parseUnits('0.1', 9), + ], + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(1).returns(prizeConfig); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = toWei('10'); + const totalSupply = toWei('100'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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]); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(1000)); + + const prizesAwardable = await drawCalculator.calculate( + ticket.address, + owner.address, + [draw.drawId], + pickIndices, + ); + + expect(prizesAwardable[0][0]).to.equal(toWei('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 () => { + const winningNumber = utils.solidityKeccak256(['address'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [ + (await provider.getBlock('latest')).timestamp - 10, + (await provider.getBlock('latest')).timestamp - 5, + ]; + + const pickIndices = encoder.encode(['uint256[][]'], [[['1'], ['2']]]); + const ticketBalance = toWei('10'); + const ticketBalance2 = toWei('10'); + const totalSupply1 = toWei('100'); + const totalSupply2 = toWei('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, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + const prizeConfig2: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [parseUnits('0.8', 9), parseUnits('0.2', 9)], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('20'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig2.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + debug(`pushing settings for draw 2...`); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(1).returns(prizeConfig); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(2).returns(prizeConfig2); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw1.timestamp.sub(1), draw1.timestamp.sub(1)) + .returns(BigNumber.from(1000)); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, [offsetStartTimestamps[0]], [offsetEndTimestamps[0]]) + .returns([ticketBalance]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs([offsetStartTimestamps[0]], [offsetEndTimestamps[0]]) + .returns([totalSupply1]); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw2.timestamp.sub(1), draw2.timestamp.sub(1)) + .returns(BigNumber.from(1000)); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, [offsetStartTimestamps[1]], [offsetEndTimestamps[1]]) + .returns([ticketBalance2]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs([offsetStartTimestamps[1]], [offsetEndTimestamps[1]]) + .returns([totalSupply2]); + + const result = await drawCalculator.calculate( + ticket.address, + owner.address, + [draw1.drawId, draw2.drawId], + pickIndices, + ); + + expect(result[0][0]).to.equal(toWei('80')); + expect(result[0][1]).to.equal(toWei('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( + ticket.address, + owner.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'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [ + (await provider.getBlock('latest')).timestamp - 9, + (await provider.getBlock('latest')).timestamp - 5, + ]; + const totalSupply1 = toWei('100'); + const totalSupply2 = toWei('100'); + + const pickIndices = encoder.encode(['uint256[][]'], [[['1'], ['2']]]); + const ticketBalance = toWei('6'); // they had 6pc of all tickets + + const prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(1001), + tiers: [parseUnits('0.8', 9), parseUnits('0.2', 9)], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(10000000), // We increase poolStakeCeiling to reduce user picks + prize: toWei('100'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + const ticketBalance2 = toWei('0.3'); // they had 0.3pc of all tickets + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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 prizeConfigHistory.mock.getPrizeConfig.withArgs(1).returns(prizeConfig); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(2).returns(prizeConfig); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw1.timestamp.sub(1), draw1.timestamp.sub(1)) + .returns(BigNumber.from(1000)); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, [offsetStartTimestamps[0]], [offsetEndTimestamps[0]]) + .returns([ticketBalance]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs([offsetStartTimestamps[0]], [offsetEndTimestamps[0]]) + .returns([totalSupply1]); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw2.timestamp.sub(1), draw2.timestamp.sub(1)) + .returns(BigNumber.from(1000)); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, [offsetStartTimestamps[1]], [offsetEndTimestamps[1]]) + .returns([ticketBalance2]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs([offsetStartTimestamps[1]], [offsetEndTimestamps[1]]) + .returns([totalSupply2]); + + await expect( + drawCalculator.calculate( + ticket.address, + owner.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'], [owner.address]); + const winningRandomNumber = utils.solidityKeccak256( + ['bytes32', 'uint256'], + [winningNumber, 1], + ); + + const timestamps = [(await provider.getBlock('latest')).timestamp]; + const totalSupply = toWei('100'); + const pickIndices = encoder.encode(['uint256[][]'], [[['1', '2', '3']]]); + const ticketBalance = toWei('6'); + + const prizeConfig: PrizeConfig = { + bitRangeSize: BigNumber.from(4), + matchCardinality: BigNumber.from(5), + drawId: BigNumber.from(1), + maxPicksPerUser: BigNumber.from(2), + tiers: [parseUnits('0.8', 9), parseUnits('0.2', 9)], + expiryDuration: BigNumber.from(1000), + poolStakeCeiling: BigNumber.from(1000), + prize: toWei('100'), + endTimestampOffset: BigNumber.from(1), + }; + + prizeConfig.tiers = fillPrizeTiersWithZeros(prizeConfig.tiers); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.address, offsetStartTimestamps, offsetEndTimestamps) + .returns([ticketBalance]); // (user, timestamp): balance + + await ticket.mock.getAverageTotalSuppliesBetween + .withArgs(offsetStartTimestamps, offsetEndTimestamps) + .returns([totalSupply]); + + const draw: Draw = newDraw({ + drawId: BigNumber.from(2), + winningRandomNumber: BigNumber.from(winningRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + await prizeConfigHistory.mock.getPrizeConfig.withArgs(2).returns(prizeConfig); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(1000)); + + await expect( + drawCalculator.calculate( + ticket.address, + owner.address, + [draw.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 provider.getBlock('latest')).timestamp]; + const totalSupply = toWei('100'); + + const pickIndices = encoder.encode(['uint256[][]'], [[['1']]]); + const ticketBalance = toWei('10'); + + const offsetStartTimestamps = modifyTimestampsWithOffset( + timestamps, + BigNumber.from(1).toNumber(), + ); + + const offsetEndTimestamps = modifyTimestampsWithOffset( + timestamps, + prizeConfig.endTimestampOffset.toNumber(), + ); + + await ticket.mock.getAverageBalancesBetween + .withArgs(owner.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(userRandomNumber), + timestamp: BigNumber.from(timestamps[0]), + }); + + await drawBuffer.mock.getDraws.returns([draw]); + + await gaugeController.mock.getScaledAverageGaugeBetween + .withArgs(ticket.address, draw.timestamp.sub(1), draw.timestamp.sub(1)) + .returns(BigNumber.from(1000)); + + const prizesAwardable = await drawCalculator.calculate( + ticket.address, + owner.address, + [draw.drawId], + pickIndices, + ); + + expect(prizesAwardable[0][0]).to.equal(toWei('0')); + const prizeCounts = encoder.decode(['uint256[][]'], prizesAwardable[1]); + // there will always be a prizeCount at matchCardinality index + assertEmptyArrayOfBigNumbers( + prizeCounts[0][0].slice(prizeConfig.matchCardinality.toNumber() + 1), + ); + }); + }); + }); +}); diff --git a/test/types.ts b/test/types.ts index 3a66309a..1d69d249 100644 --- a/test/types.ts +++ b/test/types.ts @@ -12,4 +12,16 @@ export type PrizeDistribution = { expiryDuration: BigNumber; }; +export type PrizeConfig = { + bitRangeSize: BigNumber; + matchCardinality: BigNumber; + maxPicksPerUser: BigNumber; + drawId: BigNumber; + expiryDuration: BigNumber; + endTimestampOffset: BigNumber; + poolStakeCeiling: BigNumber; + prize: BigNumber; + tiers: BigNumber[]; +}; + export type Draw = { drawId: BigNumber, winningRandomNumber: BigNumber, timestamp: BigNumber }