diff --git a/contracts/prize-pool/PrizePool.sol b/contracts/prize-pool/PrizePool.sol index ea4155e0..a585add0 100644 --- a/contracts/prize-pool/PrizePool.sol +++ b/contracts/prize-pool/PrizePool.sol @@ -24,6 +24,7 @@ abstract contract PrizePool is OwnableUpgradeSafe, BaseRelayRecipient, Reentranc } event CapturedAward(uint256 amount); + event TimelockDeposited(address indexed operator, address indexed to, address indexed token, uint256 amount); event Deposited(address indexed operator, address indexed to, address indexed token, uint256 amount); event Awarded(address indexed winner, address indexed token, uint256 amount); event AwardedExternal(address indexed winner, address indexed token, uint256 amount); @@ -123,6 +124,29 @@ abstract contract PrizePool is OwnableUpgradeSafe, BaseRelayRecipient, Reentranc return _canAwardExternal(_externalToken); } + function timelockDepositTo( + address to, + uint256 amount, + address controlledToken + ) + external + onlyControlledToken(controlledToken) + nonReentrant + { + require(_hasPrizeStrategy(), "PrizePool/prize-strategy-detached"); + _updateAwardBalance(); + + address operator = _msgSender(); + + ControlledToken(controlledToken).controllerMint(to, amount); + timelockBalances[operator] = timelockBalances[operator].sub(amount); + timelockTotalSupply = timelockTotalSupply.sub(amount); + + prizeStrategy.afterTimelockDepositTo(operator, to, amount, controlledToken); + + emit TimelockDeposited(operator, to, controlledToken, amount); + } + /// @notice Deposit assets into the Prize Pool to Purchase Tickets /// @param to The address receiving the Tickets /// @param amount The amount of assets to deposit to purchase tickets diff --git a/contracts/prize-pool/PrizeStrategyInterface.sol b/contracts/prize-pool/PrizeStrategyInterface.sol index 03b5b95c..e1fa5141 100644 --- a/contracts/prize-pool/PrizeStrategyInterface.sol +++ b/contracts/prize-pool/PrizeStrategyInterface.sol @@ -13,11 +13,19 @@ interface PrizeStrategyInterface { function beforeTokenTransfer(address from, address to, uint256 amount, address controlledToken) external; /// @dev Inheriting contract must handle deposits into the Prize Pool and account for balance changes - /// @param to The address of the account who performed the deposit + /// @param to The address of the account who is receiving the deposit /// @param amount The amount of the deposit to account for /// @param controlledToken The address of the token that was deposited function afterDepositTo(address to, uint256 amount, address controlledToken) external; + /// @notice Called by the Prize Pool after a user converts their timelocked tokens into a deposit + /// @dev Inheriting contract must handle deposits into the Prize Pool and account for balance changes + /// @param operator The user whose timelock was re-deposited + /// @param to The address of the account who is receiving the deposit + /// @param amount The amount of the deposit to account for + /// @param controlledToken The address of the token that was deposited + function afterTimelockDepositTo(address operator, address to, uint256 amount, address controlledToken) external; + /// @dev Inheriting contract must provide a view into the unlock timestamp for a timelocked withdrawal /// @param from The address of the account to withdraw from /// @param amount The amount of the withdrawal to account for diff --git a/contracts/prize-strategy/PrizeStrategy.sol b/contracts/prize-strategy/PrizeStrategy.sol index 19c44ddd..a829fd35 100644 --- a/contracts/prize-strategy/PrizeStrategy.sol +++ b/contracts/prize-strategy/PrizeStrategy.sol @@ -536,6 +536,32 @@ contract PrizeStrategy is PrizeStrategyStorage, /// @param amount The amount of collateral they deposited /// @param controlledToken The type of collateral they deposited function afterDepositTo(address to, uint256 amount, address controlledToken) external override onlyPrizePool requireNotLocked { + _afterDepositTo(to, amount, controlledToken); + } + + /// @notice Called by the prize pool after a deposit has been made. + /// @param to The user who deposited collateral + /// @param amount The amount of collateral they deposited + /// @param controlledToken The type of collateral they deposited + function afterTimelockDepositTo( + address, + address to, + uint256 amount, + address controlledToken + ) + external + override + onlyPrizePool + requireNotLocked + { + _afterDepositTo(to, amount, controlledToken); + } + + /// @notice Called by the prize pool after a deposit has been made. + /// @param to The user who deposited collateral + /// @param amount The amount of collateral they deposited + /// @param controlledToken The type of collateral they deposited + function _afterDepositTo(address to, uint256 amount, address controlledToken) internal { if (controlledToken == address(ticket)) { uint256 toBalance = ticket.balanceOf(to); _accrueCredit(to, toBalance.sub(amount)); diff --git a/test/features/support/PoolEnv.js b/test/features/support/PoolEnv.js index cde78d35..1bf52c95 100644 --- a/test/features/support/PoolEnv.js +++ b/test/features/support/PoolEnv.js @@ -66,6 +66,12 @@ function PoolEnv() { return await buidler.ethers.getContractAt('ControlledToken', ticketAddress, wallet) } + this.sponsorship = async function (wallet) { + let prizePool = await this.prizeStrategy(wallet) + let sponsorshipAddress = await prizePool.sponsorship() + return await buidler.ethers.getContractAt('ControlledToken', sponsorshipAddress, wallet) + } + this.wallet = async function (id) { let wallet = this.wallets[id] return wallet @@ -101,6 +107,38 @@ function PoolEnv() { debug(`Bought tickets`) } + this.timelockBuyTickets = async function ({ user, tickets }) { + debug(`Buying tickets with timelocked tokens...`) + let wallet = await this.wallet(user) + + debug('wallet is ', wallet._address) + + let ticket = await this.ticket(wallet) + let prizePool = await this.prizePool(wallet) + + let amount = toWei('' + tickets) + + await prizePool.timelockDepositTo(wallet._address, amount, ticket.address, this.overrides) + + debug(`Bought tickets with timelocked tokens`) + } + + this.timelockBuySponsorship = async function ({ user, sponsorship }) { + debug(`Buying sponsorship with timelocked tokens...`) + let wallet = await this.wallet(user) + + debug('wallet is ', wallet._address) + + let sponsorshipContract = await this.sponsorship(wallet) + let prizePool = await this.prizePool(wallet) + + let amount = toWei('' + sponsorship) + + await prizePool.timelockDepositTo(wallet._address, amount, sponsorshipContract.address, this.overrides) + + debug(`Bought sponsorship with timelocked tokens`) + } + this.buyTicketsAtTime = async function ({ user, tickets, elapsed }) { await this.atTime(elapsed, async () => { await this.buyTickets({ user, tickets }) @@ -135,6 +173,13 @@ function PoolEnv() { expect(await token.balanceOf(wallet._address)).to.equalish(amount, 300) } + this.expectUserToHaveSponsorship = async function ({ user, sponsorship }) { + let wallet = await this.wallet(user) + let sponsorshipContract = await this.sponsorship(wallet) + let amount = toWei(sponsorship) + expect(await sponsorshipContract.balanceOf(wallet._address)).to.equalish(amount, 300) + } + this.poolAccrues = async function ({ tickets }) { debug(`poolAccrues(${tickets.toString()})...`) await this.env.cToken.accrueCustom(toWei(tickets)) diff --git a/test/features/timelockDeposit.test.js b/test/features/timelockDeposit.test.js new file mode 100644 index 00000000..766acbe0 --- /dev/null +++ b/test/features/timelockDeposit.test.js @@ -0,0 +1,48 @@ +const { PoolEnv } = require('./support/PoolEnv') + +describe('Re-deposit Timelocked Tokens', () => { + + let env + + beforeEach(() => { + env = new PoolEnv() + }) + + describe('convert timelock to tickets', () => { + it('should allow the user to re-deposit timelock as tickets', async () => { + await env.createPool({ prizePeriodSeconds: 10, exitFee: '0.1', creditRate: '0.01' }) + // buy at time zero so that it is considered a 'full' ticket + await env.buyTicketsAtTime({ user: 1, tickets: 100, elapsed: 0 }) + await env.withdrawWithTimelockAtTime({ user: 1, tickets: 100, elapsed: 0 }) + + // tickets are converted to timelock + await env.expectUserToHaveTimelock({ user: 1, timelock: 100 }) + await env.expectUserTimelockAvailableAt({ user: 1, elapsed: 10 }) + + await env.timelockBuyTickets({ user: 1, tickets: 100 }) + + // expect balance + await env.expectUserToHaveTickets({ user: 1, tickets: 100 }) + await env.expectUserToHaveTimelock({ user: 1, timelock: 0 }) + }) + }) + + describe('convert timelock to sponsorship', () => { + it('should allow the user to re-deposit timelock as tickets', async () => { + await env.createPool({ prizePeriodSeconds: 10, exitFee: '0.1', creditRate: '0.01' }) + // buy at time zero so that it is considered a 'full' ticket + await env.buyTicketsAtTime({ user: 1, tickets: 100, elapsed: 0 }) + await env.withdrawWithTimelockAtTime({ user: 1, tickets: 100, elapsed: 0 }) + + // tickets are converted to timelock + await env.expectUserToHaveTimelock({ user: 1, timelock: 100 }) + await env.expectUserTimelockAvailableAt({ user: 1, elapsed: 10 }) + + await env.timelockBuySponsorship({ user: 1, sponsorship: 100 }) + + // expect balance + await env.expectUserToHaveSponsorship({ user: 1, sponsorship: 100 }) + await env.expectUserToHaveTimelock({ user: 1, timelock: 0 }) + }) + }) +})