diff --git a/contracts/Ticket.sol b/contracts/Ticket.sol index 4dc1f7d5..208bd844 100644 --- a/contracts/Ticket.sol +++ b/contracts/Ticket.sol @@ -24,6 +24,10 @@ contract Ticket is ControlledToken, ITicket { using SafeERC20 for IERC20; using SafeCast for uint256; + // solhint-disable-next-line var-name-mixedcase + bytes32 private immutable _DELEGATE_TYPEHASH = + keccak256("Delegate(address user,address delegate,uint256 nonce,uint256 deadline)"); + /// @notice Record of token holders TWABs for each account. mapping(address => TwabLib.Account) internal userTwabs; @@ -33,9 +37,6 @@ contract Ticket is ControlledToken, ITicket { /// @notice Mapping of delegates. Each address can delegate their ticket power to another. mapping(address => address) internal delegates; - /// @notice Each address's balance - mapping(address => uint256) internal balances; - /* ============ Constructor ============ */ /** @@ -187,50 +188,57 @@ contract Ticket is ControlledToken, ITicket { return delegates[_user]; } - /// @inheritdoc IERC20 - function balanceOf(address _user) public view override returns (uint256) { - return _balanceOf(_user); + /// @inheritdoc ITicket + function controllerDelegateFor(address _user, address _to) external override onlyController { + _delegate(_user, _to); } - /// @inheritdoc IERC20 - function totalSupply() public view virtual override returns (uint256) { - return totalSupplyTwab.details.balance; + /// @inheritdoc ITicket + function delegateWithSignature( + address _user, + address _newDelegate, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external virtual override { + require(block.timestamp <= _deadline, "Ticket/delegate-expired-deadline"); + + bytes32 structHash = keccak256(abi.encode(_DELEGATE_TYPEHASH, _user, _newDelegate, _useNonce(_user), _deadline)); + + bytes32 hash = _hashTypedDataV4(structHash); + + address signer = ECDSA.recover(hash, _v, _r, _s); + require(signer == _user, "Ticket/delegate-invalid-signature"); + + _delegate(_user, _newDelegate); } /// @inheritdoc ITicket - function delegate(address to) external virtual override { - uint256 balance = _balanceOf(msg.sender); - address currentDelegate = delegates[msg.sender]; + function delegate(address _to) external virtual override { + _delegate(msg.sender, _to); + } - require(currentDelegate != to, "Ticket/delegate-already-set"); + /// @notice Delegates a users chance to another + /// @param _user The user whose balance should be delegated + /// @param _to The delegate + function _delegate(address _user, address _to) internal { + uint256 balance = balanceOf(_user); + address currentDelegate = delegates[_user]; - if (currentDelegate != address(0)) { - _decreaseUserTwab(msg.sender, currentDelegate, balance); - } else { - _decreaseUserTwab(msg.sender, msg.sender, balance); + if (currentDelegate == _to) { + return; } - if (to != address(0)) { - _increaseUserTwab(msg.sender, to, balance); - } else { - _increaseUserTwab(msg.sender, msg.sender, balance); - } + delegates[_user] = _to; - delegates[msg.sender] = to; + _transferTwab(currentDelegate, _to, balance); - emit Delegated(msg.sender, to); + emit Delegated(_user, _to); } /* ============ Internal Functions ============ */ - /** - * @notice Returns the ERC20 ticket token balance of a ticket holder. - * @return uint256 `_user` ticket token balance. - */ - function _balanceOf(address _user) internal view returns (uint256) { - return balances[_user]; - } - /** * @notice Retrieves the average balances held by a user for a given time frame. * @param _account The user whose balance is checked. @@ -263,162 +271,61 @@ contract Ticket is ControlledToken, ITicket { return averageBalances; } - /** - * @notice Overridding of the `_transfer` function of the base ERC20 contract. - * @dev `_sender` cannot be the zero address. - * @dev `_recipient` cannot be the zero address. - * @dev `_sender` must have a balance of at least `_amount`. - * @param _sender Address of the `_sender`that will send `_amount` of tokens. - * @param _recipient Address of the `_recipient`that will receive `_amount` of tokens. - * @param _amount Amount of tokens to be transferred from `_sender` to `_recipient`. - */ - function _transfer( - address _sender, - address _recipient, - uint256 _amount - ) internal virtual override { - require(_sender != address(0), "ERC20: transfer from the zero address"); - require(_recipient != address(0), "ERC20: transfer to the zero address"); - - _beforeTokenTransfer(_sender, _recipient, _amount); - - if (_sender != _recipient) { - // standard balance update - uint256 senderBalance = balances[_sender]; - require(senderBalance >= _amount, "ERC20: transfer amount exceeds balance"); - - unchecked { - balances[_sender] = senderBalance - _amount; - } - - balances[_recipient] += _amount; - - // history update - address senderDelegate = delegates[_sender]; - - if (senderDelegate != address(0)) { - _decreaseUserTwab(_sender, senderDelegate, _amount); - } else { - _decreaseUserTwab(_sender, _sender, _amount); - } - - // history update - address recipientDelegate = delegates[_recipient]; - - if (recipientDelegate != address(0)) { - _increaseUserTwab(_recipient, recipientDelegate, _amount); - } else { - _increaseUserTwab(_recipient, _recipient, _amount); - } + // @inheritdoc ERC20 + function _beforeTokenTransfer(address _from, address _to, uint256 _amount) internal override { + if (_from == _to) { + return; } - emit Transfer(_sender, _recipient, _amount); - - _afterTokenTransfer(_sender, _recipient, _amount); - } - - /** - * @notice Overridding of the `_mint` function of the base ERC20 contract. - * @dev `_to` cannot be the zero address. - * @param _to Address that will be minted `_amount` of tokens. - * @param _amount Amount of tokens to be minted to `_to`. - */ - function _mint(address _to, uint256 _amount) internal virtual override { - require(_to != address(0), "ERC20: mint to the zero address"); - - _beforeTokenTransfer(address(0), _to, _amount); - - balances[_to] += _amount; - - ( - TwabLib.AccountDetails memory accountDetails, - ObservationLib.Observation memory _totalSupply, - bool tsIsNew - ) = TwabLib.increaseBalance(totalSupplyTwab, _amount, uint32(block.timestamp)); - - totalSupplyTwab.details = accountDetails; - - if (tsIsNew) { - emit NewTotalSupplyTwab(_totalSupply); + address _fromDelegate; + if (_from != address(0)) { + _fromDelegate = delegates[_from]; } - address toDelegate = delegates[_to]; - - if (toDelegate != address(0)) { - _increaseUserTwab(_to, toDelegate, _amount); - } else { - _increaseUserTwab(_to, _to, _amount); + address _toDelegate; + if (_to != address(0)) { + _toDelegate = delegates[_to]; } - emit Transfer(address(0), _to, _amount); - - _afterTokenTransfer(address(0), _to, _amount); + _transferTwab(_fromDelegate, _toDelegate, _amount); } - /** - * @notice Overridding of the `_burn` function of the base ERC20 contract. - * @dev `_from` cannot be the zero address. - * @dev `_from` must have at least `_amount` of tokens. - * @param _from Address that will be burned `_amount` of tokens. - * @param _amount Amount of tokens to be burnt from `_from`. - */ - function _burn(address _from, uint256 _amount) internal virtual override { - require(_from != address(0), "ERC20: burn from the zero address"); - - _beforeTokenTransfer(_from, address(0), _amount); - - ( - TwabLib.AccountDetails memory accountDetails, - ObservationLib.Observation memory tsTwab, - bool tsIsNew - ) = TwabLib.decreaseBalance( - totalSupplyTwab, - _amount, - "Ticket/burn-amount-exceeds-total-supply-twab", - uint32(block.timestamp) - ); - - totalSupplyTwab.details = accountDetails; - - if (tsIsNew) { - emit NewTotalSupplyTwab(tsTwab); + /// @notice Transfers the given TWAB balance from one user to another + /// @param _from The user to transfer the balance from. May be zero in the event of a mint. + /// @param _to The user to transfer the balance to. May be zero in the event of a burn. + /// @param _amount The balance that is being transferred. + function _transferTwab(address _from, address _to, uint256 _amount) internal { + // If we are transferring tokens from an undelegated account to a delegated account + if (_from == address(0) && _to != address(0)) { + _increaseTotalSupplyTwab(_amount); + } else // if we are transferring tokens from a delegated account to an undelegated account + if (_from != address(0) && _to == address(0)) { + _decreaseTotalSupplyTwab(_amount); + } // otherwise if the to delegate is set, then increase their twab + + if (_from != address(0)) { + _decreaseUserTwab(_from, _amount); } - - uint256 accountBalance = balances[_from]; - - require(accountBalance >= _amount, "ERC20: burn amount exceeds balance"); - - unchecked { - balances[_from] = accountBalance - _amount; + + if (_to != address(0)) { + _increaseUserTwab(_to, _amount); } - - address fromDelegate = delegates[_from]; - - if (fromDelegate != address(0)) { - _decreaseUserTwab(_from, fromDelegate, _amount); - } else { - _decreaseUserTwab(_from, _from, _amount); - } - - emit Transfer(_from, address(0), _amount); - - _afterTokenTransfer(_from, address(0), _amount); } /** - * @notice Increase `_user` TWAB balance. - * @dev If `_user` has not set a delegate address, `_user` TWAB balance will be increased. - * @dev Otherwise, `_delegate` TWAB balance will be increased. - * @param _user Address of the user. - * @param _delegate Address of the delegate. - * @param _amount Amount of tokens to be added to `_user` TWAB balance. + * @notice Increase `_to` TWAB balance. + * @param _to Address of the delegate. + * @param _amount Amount of tokens to be added to `_to` TWAB balance. */ function _increaseUserTwab( - address _user, - address _delegate, + address _to, uint256 _amount ) internal { - TwabLib.Account storage _account = userTwabs[_delegate]; + if (_amount == 0) { + return; + } + + TwabLib.Account storage _account = userTwabs[_to]; ( TwabLib.AccountDetails memory accountDetails, @@ -429,24 +336,24 @@ contract Ticket is ControlledToken, ITicket { _account.details = accountDetails; if (isNew) { - emit NewUserTwab(_user, _delegate, twab); + emit NewUserTwab(_to, twab); } } /** - * @notice Decrease `_user` TWAB balance. - * @dev If `_user` has not set a delegate address, `_user` TWAB balance will be decreased. - * @dev Otherwise, `_delegate` TWAB balance will be decreased. - * @param _user Address of the user. - * @param _delegate Address of the delegate. - * @param _amount Amount of tokens to be added to `_user` TWAB balance. + * @notice Decrease `_to` TWAB balance. + * @param _to Address of the delegate. + * @param _amount Amount of tokens to be added to `_to` TWAB balance. */ function _decreaseUserTwab( - address _user, - address _delegate, + address _to, uint256 _amount ) internal { - TwabLib.Account storage _account = userTwabs[_delegate]; + if (_amount == 0) { + return; + } + + TwabLib.Account storage _account = userTwabs[_to]; ( TwabLib.AccountDetails memory accountDetails, @@ -462,7 +369,52 @@ contract Ticket is ControlledToken, ITicket { _account.details = accountDetails; if (isNew) { - emit NewUserTwab(_user, _delegate, twab); + emit NewUserTwab(_to, twab); + } + } + + /// @notice Decreases the total supply twab. Should be called anytime a balance moves from delegated to undelegated + /// @param _amount The amount to decrease the total by + function _decreaseTotalSupplyTwab(uint256 _amount) internal { + if (_amount == 0) { + return; + } + + ( + TwabLib.AccountDetails memory accountDetails, + ObservationLib.Observation memory tsTwab, + bool tsIsNew + ) = TwabLib.decreaseBalance( + totalSupplyTwab, + _amount, + "Ticket/burn-amount-exceeds-total-supply-twab", + uint32(block.timestamp) + ); + + totalSupplyTwab.details = accountDetails; + + if (tsIsNew) { + emit NewTotalSupplyTwab(tsTwab); + } + } + + /// @notice Increases the total supply twab. Should be called anytime a balance moves from undelegated to delegated + /// @param _amount The amount to increase the total by + function _increaseTotalSupplyTwab(uint256 _amount) internal { + if (_amount == 0) { + return; + } + + ( + TwabLib.AccountDetails memory accountDetails, + ObservationLib.Observation memory _totalSupply, + bool tsIsNew + ) = TwabLib.increaseBalance(totalSupplyTwab, _amount, uint32(block.timestamp)); + + totalSupplyTwab.details = accountDetails; + + if (tsIsNew) { + emit NewTotalSupplyTwab(_totalSupply); } } } diff --git a/contracts/interfaces/IPrizePool.sol b/contracts/interfaces/IPrizePool.sol index 5cb6a305..a1c5f855 100644 --- a/contracts/interfaces/IPrizePool.sol +++ b/contracts/interfaces/IPrizePool.sol @@ -3,11 +3,11 @@ pragma solidity 0.8.6; import "../external/compound/ICompLike.sol"; -import "../interfaces/IControlledToken.sol"; +import "../interfaces/ITicket.sol"; interface IPrizePool { /// @dev Event emitted when controlled token is added - event ControlledTokenAdded(IControlledToken indexed token); + event ControlledTokenAdded(ITicket indexed token); event AwardCaptured(uint256 amount); @@ -15,12 +15,12 @@ interface IPrizePool { event Deposited( address indexed operator, address indexed to, - IControlledToken indexed token, + ITicket indexed token, uint256 amount ); /// @dev Event emitted when interest is awarded to a winner - event Awarded(address indexed winner, IControlledToken indexed token, uint256 amount); + event Awarded(address indexed winner, ITicket indexed token, uint256 amount); /// @dev Event emitted when external ERC20s are awarded to a winner event AwardedExternalERC20(address indexed winner, address indexed token, uint256 amount); @@ -35,7 +35,7 @@ interface IPrizePool { event Withdrawal( address indexed operator, address indexed from, - IControlledToken indexed token, + ITicket indexed token, uint256 amount, uint256 redeemed ); @@ -50,7 +50,7 @@ interface IPrizePool { event PrizeStrategySet(address indexed prizeStrategy); /// @dev Event emitted when the Ticket is set - event TicketSet(IControlledToken indexed ticket); + event TicketSet(ITicket indexed ticket); /// @dev Emitted when there was an error thrown awarding an External ERC721 event ErrorAwardingExternalERC721(bytes error); @@ -60,6 +60,13 @@ interface IPrizePool { /// @param amount The amount of assets to deposit function depositTo(address to, uint256 amount) external; + /// @notice Deposit assets into the Prize Pool in exchange for tokens, + /// then sets the delegate on behalf of the caller. + /// @param to The address receiving the newly minted tokens + /// @param amount The amount of assets to deposit + /// @param delegate The address to delegate to for the caller + function depositToAndDelegate(address to, uint256 amount, address delegate) external; + /// @notice Withdraw assets from the Prize Pool instantly. A fairness fee may be charged for an early exit. /// @param from The address to redeem tokens from. /// @param amount The amount of tokens to redeem for assets. @@ -110,7 +117,7 @@ interface IPrizePool { /** * @notice Read ticket variable */ - function getTicket() external view returns (IControlledToken); + function getTicket() external view returns (ITicket); /** * @notice Read token variable @@ -125,7 +132,7 @@ interface IPrizePool { /// @dev Checks if a specific token is controlled by the Prize Pool /// @param _controlledToken The address of the token to check /// @return True if the token is a controlled token, false otherwise - function isControlled(IControlledToken _controlledToken) external view returns (bool); + function isControlled(ITicket _controlledToken) external view returns (bool); /// @notice Called by the Prize-Strategy to transfer out external ERC20 tokens /// @dev Used to transfer out tokens held by the Prize Pool. Could be liquidated, or anything. @@ -178,7 +185,7 @@ interface IPrizePool { /// @notice Set prize pool ticket. /// @param _ticket Address of the ticket to set. /// @return True if ticket has been successfully set. - function setTicket(IControlledToken _ticket) external returns (bool); + function setTicket(ITicket _ticket) external returns (bool); /// @notice Delegate the votes for a Compound COMP-like token held by the prize pool /// @param _compLike The COMP-like token held by the prize pool that should be delegated diff --git a/contracts/interfaces/ITicket.sol b/contracts/interfaces/ITicket.sol index ef94acba..531f8896 100644 --- a/contracts/interfaces/ITicket.sol +++ b/contracts/interfaces/ITicket.sol @@ -3,8 +3,9 @@ pragma solidity 0.8.6; import "../libraries/TwabLib.sol"; +import "./IControlledToken.sol"; -interface ITicket { +interface ITicket is IControlledToken { /** * @notice A struct containing details for an Account. * @param balance The current balance for an Account. @@ -45,12 +46,10 @@ interface ITicket { /** * @notice Emitted when a new TWAB has been recorded. - * @param user The Ticket holder address. * @param delegate The recipient of the ticket power (may be the same as the user). * @param newTwab Updated TWAB of a ticket holder after a successful TWAB recording. */ event NewUserTwab( - address indexed user, address indexed delegate, ObservationLib.Observation newTwab ); @@ -76,9 +75,34 @@ interface ITicket { * @dev To reset the delegate, pass the zero address (0x000.000) as `to` parameter. * @dev Current delegate address should be different from the new delegate address `to`. * @param to Recipient of delegated TWAB. - */ + */ function delegate(address to) external; + /** + * @notice Allows the controller to delegate on a users behalf. + * @param user The user for whom to delegate + * @param delegate The new delegate + */ + function controllerDelegateFor(address user, address delegate) external; + + /** + * @notice Allows a user to delegate via signature + * @param user The user who is delegating + * @param delegate The new delegate + * @param deadline The timestamp by which this must be submitted + * @param v The v portion of the ECDSA sig + * @param r The r portion of the ECDSA sig + * @param s The s portion of the ECDSA sig + */ + function delegateWithSignature( + address user, + address delegate, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + /** * @notice Gets a users twab context. This is a struct with their balance, next twab index, and cardinality. * @param user The user for whom to fetch the TWAB context. diff --git a/contracts/prize-pool/PrizePool.sol b/contracts/prize-pool/PrizePool.sol index dd9464ad..78e1eefc 100644 --- a/contracts/prize-pool/PrizePool.sol +++ b/contracts/prize-pool/PrizePool.sol @@ -12,6 +12,7 @@ import "@pooltogether/owner-manager-contracts/contracts/Ownable.sol"; import "../external/compound/ICompLike.sol"; import "../interfaces/IPrizePool.sol"; +import "../interfaces/ITicket.sol"; /** * @title PoolTogether V4 PrizePool @@ -31,7 +32,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece string public constant VERSION = "4.0.0"; /// @notice Prize Pool ticket. Can only be set once by calling `setTicket()`. - IControlledToken internal ticket; + ITicket internal ticket; /// @notice The Prize Strategy that this Prize Pool is bound to. address internal prizeStrategy; @@ -85,7 +86,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece } /// @inheritdoc IPrizePool - function isControlled(IControlledToken _controlledToken) external view override returns (bool) { + function isControlled(ITicket _controlledToken) external view override returns (bool) { return _isControlled(_controlledToken); } @@ -105,7 +106,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece } /// @inheritdoc IPrizePool - function getTicket() external view override returns (IControlledToken) { + function getTicket() external view override returns (ITicket) { return ticket; } @@ -151,11 +152,29 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece nonReentrant canAddLiquidity(_amount) { - address _operator = msg.sender; + _depositTo(msg.sender, _to, _amount); + } + + /// @inheritdoc IPrizePool + function depositToAndDelegate(address _to, uint256 _amount, address _delegate) + external + override + nonReentrant + canAddLiquidity(_amount) + { + _depositTo(msg.sender, _to, _amount); + ticket.controllerDelegateFor(msg.sender, _delegate); + } + /// @notice Transfers tokens in from one user and mints tickets to another + /// @notice _operator The user to transfer tokens from + /// @notice _to The user to mint tickets to + /// @notice _amount The amount to transfer and mint + function _depositTo(address _operator, address _to, uint256 _amount) internal + { require(_canDeposit(_operator, _amount), "PrizePool/exceeds-balance-cap"); - IControlledToken _ticket = ticket; + ITicket _ticket = ticket; _mint(_to, _amount, _ticket); @@ -172,7 +191,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece nonReentrant returns (uint256) { - IControlledToken _ticket = ticket; + ITicket _ticket = ticket; // burn the tickets _ticket.controllerBurnFrom(msg.sender, _from, _amount); @@ -198,7 +217,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece require(_amount <= currentAwardBalance, "PrizePool/award-exceeds-avail"); _currentAwardBalance = currentAwardBalance - _amount; - IControlledToken _ticket = ticket; + ITicket _ticket = ticket; _mint(_to, _amount, _ticket); @@ -262,7 +281,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece } /// @inheritdoc IPrizePool - function setTicket(IControlledToken _ticket) external override onlyOwner returns (bool) { + function setTicket(ITicket _ticket) external override onlyOwner returns (bool) { address _ticketAddress = address(_ticket); require(_ticketAddress != address(0), "PrizePool/ticket-not-zero-address"); @@ -330,7 +349,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece function _mint( address _to, uint256 _amount, - IControlledToken _controlledToken + ITicket _controlledToken ) internal { _controlledToken.controllerMint(_to, _amount); } @@ -340,7 +359,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece /// @param _amount The amount of tokens to be deposited into the Prize Pool. /// @return True if the Prize Pool can receive the specified `amount` of tokens. function _canDeposit(address _user, uint256 _amount) internal view returns (bool) { - IControlledToken _ticket = ticket; + ITicket _ticket = ticket; uint256 _balanceCap = balanceCap; if (_balanceCap == type(uint256).max) return true; @@ -360,7 +379,7 @@ abstract contract PrizePool is IPrizePool, Ownable, ReentrancyGuard, IERC721Rece /// @dev Checks if a specific token is controlled by the Prize Pool /// @param _controlledToken The address of the token to check /// @return True if the token is a controlled token, false otherwise - function _isControlled(IControlledToken _controlledToken) internal view returns (bool) { + function _isControlled(ITicket _controlledToken) internal view returns (bool) { if (ticket == _controlledToken) { return true; } diff --git a/contracts/test/PrizePoolHarness.sol b/contracts/test/PrizePoolHarness.sol index 6191a6cf..68d62b4c 100644 --- a/contracts/test/PrizePoolHarness.sol +++ b/contracts/test/PrizePoolHarness.sol @@ -17,7 +17,7 @@ contract PrizePoolHarness is PrizePool { function mint( address _to, uint256 _amount, - IControlledToken _controlledToken + ITicket _controlledToken ) external { _mint(_to, _amount, _controlledToken); } diff --git a/test/Ticket.test.ts b/test/Ticket.test.ts index 448bdabf..b80d3918 100644 --- a/test/Ticket.test.ts +++ b/test/Ticket.test.ts @@ -4,7 +4,7 @@ import { expect } from 'chai'; import { deployMockContract, MockContract } from 'ethereum-waffle'; import { utils, Contract, ContractFactory, BigNumber } from 'ethers'; import hre, { ethers } from 'hardhat'; - +import { delegateSignature } from './helpers/delegateSignature'; import { increaseTime as increaseTimeHelper } from './helpers/increaseTime'; const newDebug = require('debug'); @@ -57,10 +57,11 @@ async function printTwabs( twabs.forEach((twab, index) => { debugLog(`Twab ${index} { amount: ${twab.amount}, timestamp: ${twab.timestamp}}`); }); + + return twabs } describe('Ticket', () => { - let prizePool: MockContract; let ticket: Contract; let wallet1: SignerWithAddress; @@ -75,19 +76,18 @@ describe('Ticket', () => { beforeEach(async () => { [wallet1, wallet2, wallet3, wallet4] = await getSigners(); - const PrizePool = await hre.artifacts.readArtifact( - 'contracts/prize-pool/PrizePool.sol:PrizePool', - ); - - prizePool = await deployMockContract(wallet1 as Signer, PrizePool.abi); ticket = await deployTicketContract( ticketName, ticketSymbol, ticketDecimals, - prizePool.address, + wallet1.address, ); - prizePool.mock.getBalanceCap.returns(MaxUint256); + // delegate for each of the users + await ticket.delegate(wallet1.address) + await ticket.connect(wallet2).delegate(wallet2.address) + await ticket.connect(wallet3).delegate(wallet3.address) + await ticket.connect(wallet4).delegate(wallet4.address) }); describe('constructor()', () => { @@ -96,17 +96,18 @@ describe('Ticket', () => { ticketName, ticketSymbol, ticketDecimals, - prizePool.address, + wallet1.address, ); expect(await ticket.name()).to.equal(ticketName); expect(await ticket.symbol()).to.equal(ticketSymbol); expect(await ticket.decimals()).to.equal(ticketDecimals); + expect(await ticket.controller()).to.equal(wallet1.address) }); it('should fail if token decimal is not greater than 0', async () => { await expect( - deployTicketContract(ticketName, ticketSymbol, 0, prizePool.address), + deployTicketContract(ticketName, ticketSymbol, 0, wallet1.address), ).to.be.revertedWith('ControlledToken/decimals-gt-zero'); }); @@ -287,7 +288,7 @@ describe('Ticket', () => { await expect( ticket.transferTo(wallet1.address, wallet2.address, insufficientMintAmount), - ).to.be.revertedWith('ERC20: transfer amount exceeds balance'); + ).to.be.revertedWith('ERC20: burn amount exceeds balance'); }); }); @@ -339,15 +340,20 @@ describe('Ticket', () => { expect(await ticket.mintTwice(wallet1.address, mintAmount)) .to.emit(ticket, 'Transfer') .withArgs(AddressZero, wallet1.address, mintAmount); + const timestamp = (await getBlock('latest')).timestamp; - await printTwabs(ticket, wallet1, debug); - - const context = await ticket.getAccountDetails(wallet1.address); + const twabs = await printTwabs(ticket, wallet1, debug); - debug(`Twab Context: `, context); + const matchingTwabs = twabs.reduce((all: any, twab: any) => { + debug(`TWAB timestamp ${twab.timestamp}, timestamp: ${timestamp}`) + debug(twab) + if (twab.timestamp.toString() == timestamp.toString()) { + all.push(twab) + } + return all + }, []) - expect(context.cardinality).to.equal(1); - expect(context.nextTwabIndex).to.equal(1); + expect(matchingTwabs.length).to.equal(1); expect(await ticket.totalSupply()).to.equal(mintAmount.mul(2)); }); }); @@ -855,21 +861,16 @@ describe('Ticket', () => { expect(await ticket.getBalanceAt(wallet2.address, timestamp)).to.equal(toWei('100')); }); - it('should revert if delegate address has already been set to passed address', async () => { + it('should be a no-op if delegate address has already been set to passed address', async () => { await ticket.mint(wallet1.address, toWei('100')); await ticket.delegate(wallet2.address); - await expect(ticket.delegate(wallet2.address)).to.be.revertedWith( - 'Ticket/delegate-already-set', - ); - - const timestamp = (await provider.getBlock('latest')).timestamp; - - expect(await ticket.delegateOf(wallet1.address)).to.equal(wallet2.address); - expect(await ticket.getBalanceAt(wallet2.address, timestamp)).to.equal(toWei('100')); + await expect( + ticket.delegate(wallet2.address) + ).to.not.emit(ticket, 'Delegated') }); - it('should delegate back to ticket holder is address zero is passed', async () => { + it('should allow the delegate to be reset by passing zero', async () => { await ticket.mint(wallet1.address, toWei('100')); await ticket.delegate(wallet2.address); @@ -886,9 +887,7 @@ describe('Ticket', () => { expect(await ticket.delegateOf(wallet1.address)).to.equal(AddressZero); expect(await ticket.getBalanceAt(wallet2.address, afterTimestamp)).to.equal(toWei('0')); - expect(await ticket.getBalanceAt(wallet1.address, afterTimestamp)).to.equal( - toWei('100'), - ); + expect(await ticket.getBalanceAt(wallet1.address, afterTimestamp)).to.equal(toWei('0')); }); it('should clear old delegates if any', async () => { @@ -938,4 +937,32 @@ describe('Ticket', () => { ); }); }); + + describe('delegateWithSignature()', () => { + it('should allow somone to delegate with a signature', async () => { + // @ts-ignore + const { user, delegate, nonce, deadline, v, r, s } = await delegateSignature({ + ticket, userWallet: wallet1, delegate: wallet2.address + }) + + await ticket.connect(wallet3).delegateWithSignature(user, delegate, deadline, v, r, s) + + expect(await ticket.delegateOf(wallet1.address)).to.equal(wallet2.address) + }) + }) + + describe('controllerDelegateFor', () => { + it('should allow the controller to delegate for a user', async () => { + await ticket.controllerDelegateFor(wallet2.address, wallet3.address) + + expect(await ticket.delegateOf(wallet2.address)).to.equal(wallet3.address) + }) + + it('should not allow anyone else to delegate', async () => { + await expect( + ticket.connect(wallet2).controllerDelegateFor(wallet1.address, wallet3.address) + ).to.be.revertedWith('ControlledToken/only-controller') + + }) + }) }); diff --git a/test/helpers/delegateSignature.ts b/test/helpers/delegateSignature.ts new file mode 100644 index 00000000..b6ea6f2b --- /dev/null +++ b/test/helpers/delegateSignature.ts @@ -0,0 +1,102 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { ethers, Contract } from 'ethers'; + +const domainSchema = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +]; + +const permitSchema = [ + { name: 'user', type: 'address' }, + { name: 'delegate', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, +]; + +export const signDelegateMessage = async (signer: any, domain: any, message: any) => { + let myAddr = signer.address; + + if (myAddr.toLowerCase() !== message.user.toLowerCase()) { + throw `signDelegate: address of signer does not match user address in message`; + } + + if (message.nonce === undefined) { + let tokenAbi = ['function nonces(address user) view returns (uint)']; + + let tokenContract = new Contract(domain.verifyingContract, tokenAbi, signer); + + let nonce = await tokenContract.nonces(myAddr); + + message = { ...message, nonce: nonce.toString() }; + } + + let typedData = { + types: { + EIP712Domain: domainSchema, + Delegate: permitSchema, + }, + primaryType: 'Delegate', + domain, + message, + }; + + let sig; + + if (signer && signer.provider) { + try { + sig = await signer.provider.send('eth_signTypedData', [myAddr, typedData]); + } catch (e: any) { + if (/is not supported/.test(e.message)) { + sig = await signer.provider.send('eth_signTypedData_v4', [myAddr, typedData]); + } + } + } + + return { domain, message, sig }; +} + + + +type Delegate = { + ticket: Contract, + userWallet: SignerWithAddress; + delegate: string; +}; + +export async function delegateSignature({ + ticket, + userWallet, + delegate +}: Delegate): Promise { + const nonce = (await ticket.nonces(userWallet.address)).toNumber() + const chainId = (await ticket.provider.getNetwork()).chainId + const deadline = (await ticket.provider.getBlock('latest')).timestamp + 100 + + let delegateSig = await signDelegateMessage( + userWallet, + { + name: 'PoolTogether ControlledToken', + version: '1', + chainId, + verifyingContract: ticket.address, + }, + { + user: userWallet.address, + delegate, + nonce, + deadline, + }, + ); + + const sig = ethers.utils.splitSignature(delegateSig.sig); + + return { + user: userWallet.address, + delegate, + nonce, + deadline, + ...sig + } +} diff --git a/test/prize-pool/PrizePool.test.ts b/test/prize-pool/PrizePool.test.ts index 8a81b446..4de8b671 100644 --- a/test/prize-pool/PrizePool.test.ts +++ b/test/prize-pool/PrizePool.test.ts @@ -161,6 +161,20 @@ describe('PrizePool', function () { }); }); + describe('depositToAndDelegate()', () => { + it('should delegate after depositing', async () => { + const amount = toWei('100') + await depositToken.approve(prizePool.address, amount); + await depositToken.mint(wallet1.address, amount); + + await yieldSourceStub.mock.supplyTokenTo.withArgs(amount, prizePool.address).returns() + + await prizePool.depositToAndDelegate(wallet1.address, amount, wallet2.address) + + expect(await ticket.delegateOf(wallet1.address)).to.equal(wallet2.address) + }) + }) + describe('depositTo()', () => { it('should revert when deposit exceeds liquidity cap', async () => { const amount = toWei('1');