From 954c9c17bcdd10ccf7590224b91ce8f46dadd69a Mon Sep 17 00:00:00 2001 From: Kames Geraghty <3408362+kamescg@users.noreply.github.com> Date: Mon, 16 May 2022 19:40:22 -0600 Subject: [PATCH] update(PrizeDistributorV2): add contracts and commented unit tests (#264) --- contracts/DrawCalculatorV3.sol | 7 +- contracts/PrizeDistributorV2.sol | 197 +++++++++++++++++++++ contracts/interfaces/IDrawCalculatorV3.sol | 2 - test/PrizeDistributorV2.test.ts | 183 +++++++++++++++++++ 4 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 contracts/PrizeDistributorV2.sol create mode 100644 test/PrizeDistributorV2.test.ts diff --git a/contracts/DrawCalculatorV3.sol b/contracts/DrawCalculatorV3.sol index daf108f7..e9842d57 100644 --- a/contracts/DrawCalculatorV3.sol +++ b/contracts/DrawCalculatorV3.sol @@ -32,6 +32,8 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { /// @notice DrawBuffer address IDrawBuffer public immutable drawBuffer; + + IPrizeConfigHistory public immutable prizeConfigHistory; /// @notice The tiers array length uint8 public constant TIERS_LENGTH = 16; @@ -59,6 +61,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { constructor( IGaugeController _gaugeController, IDrawBuffer _drawBuffer, + IPrizeConfigHistory _prizeConfigHistory, address _owner ) Ownable(_owner) { require(address(_gaugeController) != address(0), "DrawCalc/GC-not-zero-address"); @@ -67,6 +70,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { gaugeController = _gaugeController; drawBuffer = _drawBuffer; + prizeConfigHistory = _prizeConfigHistory; emit Deployed(_gaugeController, _drawBuffer); } @@ -76,7 +80,6 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { /// @inheritdoc IDrawCalculatorV3 function calculate( ITicket _ticket, - IPrizeConfigHistory _prizeConfigHistory, address _user, uint32[] calldata _drawIds, bytes calldata _pickIndicesForDraws @@ -89,7 +92,7 @@ contract DrawCalculatorV3 is IDrawCalculatorV3, Manageable { return _calculatePrizesAwardable( _ticket, - _prizeConfigHistory, + prizeConfigHistory, _user, _userRandomNumber, _drawIds, diff --git a/contracts/PrizeDistributorV2.sol b/contracts/PrizeDistributorV2.sol new file mode 100644 index 00000000..fd800343 --- /dev/null +++ b/contracts/PrizeDistributorV2.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.6; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@pooltogether/owner-manager-contracts/contracts/Manageable.sol"; +import "./interfaces/IDrawCalculatorV3.sol"; + +/** + * @title PoolTogether V4 PrizeDistributorV2 + * @author PoolTogether Inc Team + * @notice The PrizeDistributorV2 contract holds Tickets (captured interest) and distributes tickets to users with winning draw claims. + PrizeDistributorV2 uses an external IDrawCalculatorV3 to validate a users draw claim, before awarding payouts. To prevent users + from reclaiming prizes, a payout history for each draw claim is mapped to user accounts. Reclaiming a draw can occur + if an "optimal" prize was not included in previous claim pick indices and the new claims updated payout is greater then + the previous prize distributor claim payout. +*/ +contract PrizeDistributorV2 is Manageable { + using SafeERC20 for IERC20; + + /** + * @notice Emit when user has claimed token from the PrizeDistributorV2. + * @param user User address receiving draw claim payouts + * @param drawId Draw id that was paid out + * @param payout Payout for draw + */ + event ClaimedDraw(address indexed user, uint32 indexed drawId, uint256 payout); + + /** + * @notice Emit when IDrawCalculatorV3 is set. + * @param calculator IDrawCalculatorV3 address + */ + event DrawCalculatorSet(IDrawCalculatorV3 indexed calculator); + + /** + * @notice Emit when Token is set. + * @param token Token address + */ + event TokenSet(IERC20 indexed token); + + /** + * @notice Emit when ERC20 tokens are withdrawn. + * @param token ERC20 token transferred. + * @param to Address that received funds. + * @param amount Amount of tokens transferred. + */ + event ERC20Withdrawn(IERC20 indexed token, address indexed to, uint256 amount); + + /* ============ Global Variables ============ */ + + /// @notice IDrawCalculatorV3 address + IDrawCalculatorV3 internal drawCalculator; + + /// @notice Token address + IERC20 internal immutable token; + + /// @notice Maps users => drawId => paid out balance + mapping(address => mapping(uint256 => uint256)) internal userDrawPayouts; + + /* ============ Initialize ============ */ + + /** + * @notice Initialize PrizeDistributorV2 smart contract. + * @param _owner Owner address + * @param _token Token address + * @param _drawCalculator IDrawCalculatorV3 address + */ + constructor( + address _owner, + IERC20 _token, + IDrawCalculatorV3 _drawCalculator + ) Ownable(_owner) { + _setDrawCalculator(_drawCalculator); + require(address(_token) != address(0), "PrizeDistributorV2/token-not-zero-address"); + token = _token; + emit TokenSet(_token); + } + + /* ============ External Functions ============ */ + + function claim( + ITicket _ticket, + address _user, + uint32[] calldata _drawIds, + bytes calldata _data + ) external returns (uint256) { + + uint256 totalPayout; + + (uint256[] memory drawPayouts, ) = drawCalculator.calculate(_ticket, _user, _drawIds, _data); // neglect the prizeCounts since we are not interested in them here + + uint256 drawPayoutsLength = drawPayouts.length; + for (uint256 payoutIndex = 0; payoutIndex < drawPayoutsLength; payoutIndex++) { + uint32 drawId = _drawIds[payoutIndex]; + uint256 payout = drawPayouts[payoutIndex]; + uint256 oldPayout = _getDrawPayoutBalanceOf(_user, drawId); + uint256 payoutDiff = 0; + + // helpfully short-circuit, in case the user screwed something up. + require(payout > oldPayout, "PrizeDistributorV2/zero-payout"); + + unchecked { + payoutDiff = payout - oldPayout; + } + + _setDrawPayoutBalanceOf(_user, drawId, payout); + + totalPayout += payoutDiff; + + emit ClaimedDraw(_user, drawId, payoutDiff); + } + + _awardPayout(_user, totalPayout); + + return totalPayout; + } + + function withdrawERC20( + IERC20 _erc20Token, + address _to, + uint256 _amount + ) external onlyManagerOrOwner returns (bool) { + require(_to != address(0), "PrizeDistributorV2/recipient-not-zero-address"); + require(address(_erc20Token) != address(0), "PrizeDistributorV2/ERC20-not-zero-address"); + + _erc20Token.safeTransfer(_to, _amount); + + emit ERC20Withdrawn(_erc20Token, _to, _amount); + + return true; + } + + function getDrawCalculator() external view returns (IDrawCalculatorV3) { + return drawCalculator; + } + + function getDrawPayoutBalanceOf(address _user, uint32 _drawId) + external + view + returns (uint256) + { + return _getDrawPayoutBalanceOf(_user, _drawId); + } + + function getToken() external view returns (IERC20) { + return token; + } + + function setDrawCalculator(IDrawCalculatorV3 _newCalculator) + external + onlyManagerOrOwner + returns (IDrawCalculatorV3) + { + _setDrawCalculator(_newCalculator); + return _newCalculator; + } + + /* ============ Internal Functions ============ */ + + function _getDrawPayoutBalanceOf(address _user, uint32 _drawId) + internal + view + returns (uint256) + { + return userDrawPayouts[_user][_drawId]; + } + + function _setDrawPayoutBalanceOf( + address _user, + uint32 _drawId, + uint256 _payout + ) internal { + userDrawPayouts[_user][_drawId] = _payout; + } + + /** + * @notice Sets IDrawCalculatorV3 reference for individual draw id. + * @param _newCalculator IDrawCalculatorV3 address + */ + function _setDrawCalculator(IDrawCalculatorV3 _newCalculator) internal { + require(address(_newCalculator) != address(0), "PrizeDistributorV2/calc-not-zero"); + drawCalculator = _newCalculator; + + emit DrawCalculatorSet(_newCalculator); + } + + /** + * @notice Transfer claimed draw(s) total payout to user. + * @param _to User address + * @param _amount Transfer amount + */ + function _awardPayout(address _to, uint256 _amount) internal { + token.safeTransfer(_to, _amount); + } + +} diff --git a/contracts/interfaces/IDrawCalculatorV3.sol b/contracts/interfaces/IDrawCalculatorV3.sol index 20a94df3..721586a9 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 diff --git a/test/PrizeDistributorV2.test.ts b/test/PrizeDistributorV2.test.ts new file mode 100644 index 00000000..28cecad6 --- /dev/null +++ b/test/PrizeDistributorV2.test.ts @@ -0,0 +1,183 @@ +import { expect } from 'chai'; +import { deployMockContract, MockContract } from 'ethereum-waffle'; +import { utils, constants, Contract, ContractFactory, BigNumber } from 'ethers'; +import { ethers, artifacts } from 'hardhat'; + +const { getSigners } = ethers; +const { parseEther: toWei } = utils; +const { AddressZero } = constants; + +describe('PrizeDistributorV2', () => { + let wallet1: any; + let wallet2: any; + let token: Contract; + let ticket: Contract; + let PrizeDistributorV2: Contract; + let drawCalculator: MockContract; + + before(async () => { + [wallet1, wallet2] = await getSigners(); + }); + + beforeEach(async () => { + const erc20MintableFactory: ContractFactory = await ethers.getContractFactory( + 'ERC20Mintable', + ); + + token = await erc20MintableFactory.deploy('Token', 'TOK'); + ticket = await erc20MintableFactory.deploy('Ticket', 'TIC'); + + let IDrawCalculator = await artifacts.readArtifact('IDrawCalculatorV3'); + drawCalculator = await deployMockContract(wallet1, IDrawCalculator.abi); + + const PrizeDistributorV2Factory: ContractFactory = await ethers.getContractFactory('PrizeDistributorV2'); + + PrizeDistributorV2 = await PrizeDistributorV2Factory.deploy( + wallet1.address, + ticket.address, + drawCalculator.address, + ); + + await ticket.mint(PrizeDistributorV2.address, toWei('1000')); + }); + + /** + * @description Test claim(ITicket _ticket,address _user,uint32[] calldata _drawIds,bytes calldata _data) function + * -= Expected Behavior =- + * 1. calculate drawPayouts for user, ticket, drawIds and data(picks) + * FOR + * 2. update Draw payout amount for each drawId + * 3. emit ClaimedDraw event + * END FOR + * 4. transfer total drawPayouts to user + * 5. return totalPayout + */ + describe('claim(ITicket _ticket,address _user,uint32[] calldata _drawIds,bytes calldata _data))', () => { + it('should SUCCEED to claim and emit ClaimedDraw event', async () => { + await drawCalculator.mock.calculate + .withArgs(ticket.address, wallet1.address, [1], '0x') + .returns([toWei('10')], "0x"); + await expect(PrizeDistributorV2.claim(ticket.address, wallet1.address, [1], '0x')) + .to.emit(PrizeDistributorV2, 'ClaimedDraw') + .withArgs(wallet1.address, 1, toWei('10')); + }); + + it('should SUCCEED to payout the difference if user claims more', async () => { + await drawCalculator.mock.calculate + .withArgs(ticket.address, wallet1.address, [1], '0x') + .returns([toWei('10')], "0x"); + await PrizeDistributorV2.claim(ticket.address, wallet1.address, [1], '0x'); + await drawCalculator.mock.calculate + .withArgs(ticket.address, wallet1.address, [1], '0x') + .returns([toWei('20')], "0x"); + await PrizeDistributorV2.claim(ticket.address, wallet1.address, [1], '0x') + expect(await PrizeDistributorV2.getDrawPayoutBalanceOf(wallet1.address, 1)).to.equal( + toWei('20'), + ); + }); + + it('should REVERT on 2.update because the prize was previously claimed', async () => { + await drawCalculator.mock.calculate + .withArgs(ticket.address, wallet1.address, [0], '0x') + .returns([toWei('10')], "0x"); + await PrizeDistributorV2.claim(ticket.address,wallet1.address, [0], '0x'); + await expect(PrizeDistributorV2.claim(ticket.address, wallet1.address, [0], '0x')).to.be.revertedWith( + 'PrizeDistributorV2/zero-payout', + ); + }); + }); + + /** + * @description Test setDrawCalculator(DrawCalculatorInterface _newCalculator) function + * -= Expected Behavior =- + * 1. authorize the `msg.sender` has OWNER or MANAGER role + * 2. update global drawCalculator variable + * 3. emit DrawCalculatorSet event + */ + describe('setDrawCalculator(DrawCalculatorInterface _newCalculator)', () => { + + it('should SUCCEED updating the drawCalculator global variable', async () => { + expect(await PrizeDistributorV2.setDrawCalculator(wallet2.address)) + .to.emit(PrizeDistributorV2, 'DrawCalculatorSet') + .withArgs(wallet2.address); + }); + + it('should REVERT on 1.authorized because wallet is NOT an OWNER or MANAGER', async () => { + const PrizeDistributorV2Unauthorized = PrizeDistributorV2.connect(wallet2); + await expect( + PrizeDistributorV2Unauthorized.setDrawCalculator(AddressZero), + ).to.be.revertedWith('Manageable/caller-not-manager-or-owner'); + }); + + it('should REVERT on 2.update because the drawCalculator address is NULL', async () => { + await expect(PrizeDistributorV2.setDrawCalculator(AddressZero)).to.be.revertedWith( + 'PrizeDistributorV2/calc-not-zero', + ); + }); + }); + + + /** + * @description Test withdrawERC20(IERC20 _erc20Token, address _to, uint256 _amount) function + * -= Expected Behavior =- + * 1. authorize the `msg.sender` has OWNER or MANAGER role + * 2. require _to address is not NULL + * 3. require _erc20Token address is not NULL + * 4. transfer ERC20 amount to _to address + * 5. emit ERC20Withdrawn event + * 6. return true + */ + + describe('withdrawERC20(IERC20 _erc20Token, address _to, uint256 _amount)', () => { + let withdrawAmount: BigNumber = toWei('100'); + + beforeEach(async () => { + await token.mint(PrizeDistributorV2.address, toWei('1000')); + }); + + it('should SUCCEED to withdraw ERC20 tokens as owner', async () => { + await expect(PrizeDistributorV2.withdrawERC20(token.address, wallet1.address, withdrawAmount)) + .to.emit(PrizeDistributorV2, 'ERC20Withdrawn') + .withArgs(token.address, wallet1.address, withdrawAmount); + }); + + it('should REVERT on 1.authorize because from address is not an OWNER or MANAGER', async () => { + expect( + PrizeDistributorV2 + .connect(wallet2) + .withdrawERC20(token.address, wallet1.address, withdrawAmount), + ).to.be.revertedWith('Manageable/caller-not-manager-or-owner'); + }); + + it('should REVERT on 2.require because the recipient address is NULL', async () => { + await expect( + PrizeDistributorV2.withdrawERC20(token.address, AddressZero, withdrawAmount), + ).to.be.revertedWith('PrizeDistributorV2/recipient-not-zero-address'); + }); + + it('should REVERT on 3.require because the ERC20 address is NULL', async () => { + await expect( + PrizeDistributorV2.withdrawERC20(AddressZero, wallet1.address, withdrawAmount), + ).to.be.revertedWith('PrizeDistributorV2/ERC20-not-zero-address'); + }); + + }); + + describe('getDrawCalculator()', () => { + it('should SUCCEED to read an empty Draw ID => DrawCalculator mapping', async () => { + expect(await PrizeDistributorV2.getDrawCalculator()).to.equal(drawCalculator.address); + }); + }); + + describe('getDrawPayoutBalanceOf()', () => { + it('should return the user payout for draw before claiming a payout', async () => { + expect(await PrizeDistributorV2.getDrawPayoutBalanceOf(wallet1.address, 0)).to.equal('0'); + }); + }); + + describe('getToken()', () => { + it('should succesfully read global token variable', async () => { + expect(await PrizeDistributorV2.getToken()).to.equal(ticket.address); + }); + }); +});