diff --git a/contracts/modules/sponsorship/Sponsorship.sol b/contracts/modules/sponsorship/Sponsorship.sol index f577985c..d9254a07 100644 --- a/contracts/modules/sponsorship/Sponsorship.sol +++ b/contracts/modules/sponsorship/Sponsorship.sol @@ -18,6 +18,9 @@ contract Sponsorship is TokenModule, ReentrancyGuardUpgradeSafe { event SponsorshipSupplied(address indexed operator, address indexed to, uint256 amount); event SponsorshipRedeemed(address indexed operator, address indexed from, uint256 amount); + event SponsorshipMinted(address indexed operator, address indexed to, uint256 amount); + event SponsorshipBurned(address indexed operator, address indexed from, uint256 amount); + event SponsorshipSwept(address indexed operator, address[] users); mapping(address => uint256) interestShares; YieldServiceInterface public yieldService; @@ -39,37 +42,38 @@ contract Sponsorship is TokenModule, ReentrancyGuardUpgradeSafe { } function supply(address receiver, uint256 amount) external nonReentrant { - address _sender = _msgSender(); + address sender = _msgSender(); - yieldService.token().transferFrom(_sender, address(this), amount); + yieldService.token().transferFrom(sender, address(this), amount); ensureYieldServiceApproved(amount); yieldService.supply(amount); _mintSponsorship(receiver, amount); - emit SponsorshipSupplied(_sender, receiver, amount); + emit SponsorshipSupplied(sender, receiver, amount); } function redeem(uint256 amount) external nonReentrant { - address _sender = _msgSender(); + address sender = _msgSender(); + require(interestShares[sender] >= amount, "Sponsorship/insuff"); - _burnSponsorship(_sender, amount); + _burnSponsorship(sender, amount); yieldService.redeem(amount); - IERC20(yieldService.token()).transfer(_sender, amount); + IERC20(yieldService.token()).transfer(sender, amount); - emit SponsorshipRedeemed(_sender, _sender, amount); + emit SponsorshipRedeemed(sender, sender, amount); } function operatorRedeem(address from, uint256 amount) external nonReentrant onlyOperator(from) { - address _sender = _msgSender(); + address sender = _msgSender(); _burnSponsorship(from, amount); yieldService.redeem(amount); IERC20(yieldService.token()).transfer(from, amount); - emit SponsorshipRedeemed(_sender, from, amount); + emit SponsorshipRedeemed(sender, from, amount); } function mint( @@ -77,61 +81,90 @@ contract Sponsorship is TokenModule, ReentrancyGuardUpgradeSafe { uint256 amount ) external virtual onlyManagerOrModule { _mintSponsorship(account, amount); + emit SponsorshipMinted(_msgSender(), account, amount); } function burn( - address from, + address account, uint256 amount ) external virtual onlyManagerOrModule { - _burnSponsorship(from, amount); + _burnSponsorship(account, amount); + emit SponsorshipBurned(_msgSender(), account, amount); } function sweep(address[] calldata users) external { _sweep(users); + emit SponsorshipSwept(_msgSender(), users); } - - function _mintSponsorship( address account, uint256 amount ) internal { + // Mint sponsorship tokens _mint(account, amount, "", ""); + + // Supply collateral for interest tracking uint256 shares = PrizePoolModuleManager(address(manager)).interestTracker().supplyCollateral(amount); interestShares[account] = interestShares[account].add(shares); - } + // Burn & accredit any accrued interest on collateral + _sweepInterest(account); + } function _burnSponsorship( - address from, + address account, uint256 amount ) internal { - _burn(from, amount, "", ""); - // uint256 shares = PrizePoolModuleManager(address(manager)).interestTracker().redeemCollateral(amount); - // interestShares[from] = interestShares[from].sub(shares); - _sweep(from); + // Burn & accredit accrued interest on collateral + _burnCollateralSweepInterest(account, amount); + + // Burn sponsorship tokens + _burn(account, amount, "", ""); + } + + function _burnCollateralSweepInterest( + address account, + uint256 collateralAmount + ) internal { + // Burn collateral + interest from interest tracker + _burnFromInterestTracker(account, collateralAmount.add(_mintCredit(account))); } + function _sweepInterest(address account) internal { + // Burn interest from interest tracker + _burnFromInterestTracker(account, _mintCredit(account)); + } - function _sweep(address user) internal { - address[] memory users = new address[](1); - users[0] = user; - _sweep(users); + function _sweep(address[] memory accounts) internal { + for (uint256 i = 0; i < accounts.length; i++) { + address account = accounts[i]; + _sweepInterest(account); + } } - function _sweep(address[] memory users) internal { + function _calculateCollateralInterest(address account) internal returns (uint256 interest) { InterestTrackerInterface interestTracker = PrizePoolModuleManager(address(manager)).interestTracker(); - Credit sponsorshipCredit = PrizePoolModuleManager(address(manager)).sponsorshipCredit(); uint256 exchangeRateMantissa = interestTracker.exchangeRateMantissa(); - for (uint256 i = 0; i < users.length; i++) { - address user = users[i]; - // uint256 collateral = interestTracker.collateralValueOfShares(interestShares[user]); - uint256 collateral = FixedPoint.divideUintByMantissa(interestShares[user], exchangeRateMantissa); - uint256 interest = collateral.sub(balanceOf(user)); - sponsorshipCredit.mint(user, interest); - uint256 shares = interestTracker.redeemCollateral(interest); - interestShares[user] = interestShares[user].sub(shares); - } + + // Calculate interest on collateral to be accreditted to account + uint256 collateral = FixedPoint.divideUintByMantissa(interestShares[account], exchangeRateMantissa); + interest = collateral.sub(balanceOf(account)); + } + + function _burnFromInterestTracker(address account, uint256 amount) internal { + // Burn collateral/interest from interest tracker + uint256 shares = PrizePoolModuleManager(address(manager)).interestTracker().redeemCollateral(amount); + interestShares[account] = interestShares[account].sub(shares); + } + + function _mintCredit(address account) internal returns (uint256 interest) { + // Calculate any accrued interest on existing collateral + interest = _calculateCollateralInterest(account); + + // Mint sponsorship credit for interest accrued + Credit sponsorshipCredit = PrizePoolModuleManager(address(manager)).sponsorshipCredit(); + sponsorshipCredit.mint(account, interest); } function ensureYieldServiceApproved(uint256 amount) internal { diff --git a/test/Sponsorship.test.js b/test/Sponsorship.test.js index 92666829..cb16660f 100644 --- a/test/Sponsorship.test.js +++ b/test/Sponsorship.test.js @@ -1,6 +1,6 @@ const { deployContract, deployMockContract } = require('ethereum-waffle') const { deploy1820 } = require('deploy-eip-1820') -const Sponsorship = require('../build/SponsorshipHarness.json') +const SponsorshipHarness = require('../build/SponsorshipHarness.json') const PeriodicPrizePool = require('../build/PeriodicPrizePool.json') const Credit = require('../build/Credit.json') const Timelock = require('../build/Timelock.json') @@ -17,6 +17,7 @@ const { call } = require('./helpers/call') const { ethers } = require('./helpers/ethers') const { expect } = require('chai') const buidler = require('./helpers/buidler') +const getIterable = require('./helpers/iterable') const { CALL_EXCEPTION } = require('ethers/errors') const toWei = ethers.utils.parseEther @@ -39,6 +40,33 @@ describe.only('Sponsorship contract', function() { let lastTxTimestamp + const _mocksForSupply = async ({supply, mintedTickets, collateral, account = wallet}) => { + await token.mock.transferFrom.withArgs(account._address, sponsorship.address, supply).returns(true) + + // ensure yield service approved + await token.mock.allowance.returns(0) + await token.mock.approve.returns(true) + + // supply to yield service + await yieldService.mock.supply.withArgs(supply).returns() + await prizePool.mock.mintedTickets.withArgs(mintedTickets).returns() + await interestTracker.mock.supplyCollateral.withArgs(collateral).returns(collateral) + } + + const _mocksForRedeem = async ({redeem, redeemedTickets, transfer, account = wallet}) => { + await prizePool.mock.redeemedTickets.withArgs(redeemedTickets).returns() + await yieldService.mock.redeem.withArgs(redeem).returns() + await token.mock.transfer.withArgs(account._address, transfer).returns(true) + } + + const _mocksForSweep = async ({totalSupply, redeemCollateral, credit, account = wallet, exchangeRate = toWei('1')}) => { + await interestTracker.mock.totalSupply.returns(totalSupply) + await interestTracker.mock.redeemCollateral.withArgs(redeemCollateral).returns(redeemCollateral) + await interestTracker.mock.exchangeRateMantissa.returns(exchangeRate); + await sponsorshipCredit.mock.mint.withArgs(account._address, credit).returns() + } + + beforeEach(async () => { [wallet, wallet2] = await buidler.ethers.getSigners() @@ -66,7 +94,7 @@ describe.only('Sponsorship contract', function() { await manager.mock.timelock.returns(timelock.address) await manager.mock.sponsorshipCredit.returns(sponsorshipCredit.address) - sponsorship = await deployContract(wallet, Sponsorship, [], overrides) + sponsorship = await deployContract(wallet, SponsorshipHarness, [], overrides) let tx = await sponsorship['initialize(address,address,string,string)']( manager.address, @@ -88,103 +116,106 @@ describe.only('Sponsorship contract', function() { describe('supply()', () => { it('should mint sponsorship tokens', async () => { - let amount = toWei('10') - - await token.mock.transferFrom.withArgs(wallet._address, sponsorship.address, amount).returns(true) - - // ensure yield service approved - await token.mock.allowance.returns(0) - await token.mock.approve.returns(true) - - // supply to yield service - await yieldService.mock.supply.withArgs(amount).returns() - await prizePool.mock.mintedTickets.withArgs(toWei('10')).returns() - await interestTracker.mock.supplyCollateral.withArgs(amount).returns(amount) - - await sponsorship.supply(wallet._address, toWei('10'), []) - - expect(await sponsorship.balanceOfInterestShares(wallet._address)).to.equal(amount) - expect(await sponsorship.balanceOf(wallet._address)).to.equal(amount) + const sweepAmount = toWei('0') + const supplyAmount = toWei('10') + + await _mocksForSupply({ + supply: supplyAmount, + mintedTickets: supplyAmount, + collateral: supplyAmount, + }) + + await _mocksForSweep({ + totalSupply: sweepAmount, + redeemCollateral: sweepAmount, + credit: sweepAmount, + }) + + // Supply sponsorship + await sponsorship.supply(wallet._address, supplyAmount) + + // Test supply + expect(await sponsorship.balanceOfInterestShares(wallet._address)).to.equal(supplyAmount) + expect(await sponsorship.balanceOf(wallet._address)).to.equal(supplyAmount) }) }) describe('redeem()', () => { it('should allow a sponsor to redeem their sponsorship tokens', async () => { - await sponsorship.setInterestSharesForTest(wallet._address, toWei('10')) - await sponsorship.mintForTest(wallet._address, toWei('10')) - - // burn tickets - await prizePool.mock.redeemedTickets.withArgs(toWei('10')).returns() - - // credit user - await interestTracker.mock.totalSupply.returns(toWei('10')) - await interestTracker.mock.redeemCollateral.withArgs(toWei('10')).returns(toWei('10')) - await interestTracker.mock.exchangeRateMantissa.returns(toWei('1')); - await sponsorshipCredit.mock.mint.withArgs(wallet._address, toWei('10')).returns() - - await yieldService.mock.redeem.withArgs(toWei('10')).returns() - - await token.mock.transfer.withArgs(wallet._address, toWei('10')).returns(true) - - await expect(sponsorship.redeem(toWei('10'), [])) + const amount = toWei('10') + + // Pre-fund sponsorship tokens + await sponsorship.setInterestSharesForTest(wallet._address, amount) + await sponsorship.mintForTest(wallet._address, amount) + + await _mocksForSweep({ + totalSupply: amount, + redeemCollateral: amount, + credit: toWei('0'), + }) + + await _mocksForRedeem({ + redeem: amount, + redeemedTickets: amount, + transfer: amount, + }) + + // Test redeem + await expect(sponsorship.redeem(amount)) .to.emit(sponsorship, 'SponsorshipRedeemed') - .withArgs(wallet._address, wallet._address, toWei('10')) + .withArgs(wallet._address, wallet._address, amount) }) it('should not allow a sponsor to redeem more sponsorship tokens than they hold', async () => { - await sponsorship.setInterestSharesForTest(wallet._address, toWei('10')) - await sponsorship.mintForTest(wallet._address, toWei('10')) - - // burn tickets - await prizePool.mock.redeemedTickets.withArgs(toWei('10')).returns() - - // credit user - await interestTracker.mock.totalSupply.returns(toWei('10')) - await interestTracker.mock.redeemCollateral.withArgs(toWei('10')).returns(toWei('10')) - await interestTracker.mock.exchangeRateMantissa.returns(toWei('1')); - await sponsorshipCredit.mock.mint.withArgs(wallet._address, toWei('10')).returns() + const amount = toWei('10') - await yieldService.mock.redeem.withArgs(toWei('10')).returns() + // Pre-fund sponsorship tokens + await sponsorship.setInterestSharesForTest(wallet._address, amount) - await token.mock.transfer.withArgs(wallet._address, toWei('10')).returns(true) - - await expect(sponsorship.redeem(toWei('10'), [])) - .to.emit(sponsorship, 'SponsorshipRedeemed') - .withArgs(wallet._address, wallet._address, toWei('10')) + // Test balance revert + await expect(sponsorship.redeem(amount.mul(2))) + .to.be.revertedWith('Sponsorship/insuff') }) }) describe('operatorRedeem()', () => { it('should allow an operator to redeem on behalf of a sponsor their sponsorship tokens', async () => { - await sponsorship.setInterestSharesForTest(wallet._address, toWei('10')) - await sponsorship.mintForTest(wallet._address, toWei('10')) - - // approve operator - await sponsorship.authorizeOperator(wallet2._address) + const amount = toWei('10') - // burn tickets - await prizePool.mock.redeemedTickets.withArgs(toWei('10')).returns() + // Pre-fund sponsorship tokens + await sponsorship.setInterestSharesForTest(wallet._address, amount) + await sponsorship.mintForTest(wallet._address, amount) - // credit user - await interestTracker.mock.totalSupply.returns(toWei('10')) - await interestTracker.mock.redeemCollateral.withArgs(toWei('10')).returns(toWei('10')) - await interestTracker.mock.exchangeRateMantissa.returns(toWei('1')); - await sponsorshipCredit.mock.mint.withArgs(wallet._address, toWei('10')).returns() + await _mocksForSweep({ + totalSupply: amount, + redeemCollateral: amount, + credit: toWei('0'), + }) - await yieldService.mock.redeem.withArgs(toWei('10')).returns() + await _mocksForRedeem({ + redeem: amount, + redeemedTickets: amount, + transfer: amount, + }) - await token.mock.transfer.withArgs(wallet._address, toWei('10')).returns(true) + // approve operator + await sponsorship.authorizeOperator(wallet2._address) - await expect(sponsorship.connect(wallet2).operatorRedeem(wallet._address, toWei('10'), [])) + // Test operator redeem + await expect(sponsorship.connect(wallet2).operatorRedeem(wallet._address, amount)) .to.emit(sponsorship, 'SponsorshipRedeemed') - .withArgs(wallet2._address, wallet._address, toWei('10')) + .withArgs(wallet2._address, wallet._address, amount) }) it('should not allow an unapproved operator to redeem on behalf of a sponsor', async () => { - await sponsorship.setInterestSharesForTest(wallet._address, toWei('10')) - await sponsorship.mintForTest(wallet._address, toWei('10')) + const amount = toWei('10') + + // Pre-fund sponsorship tokens + await sponsorship.setInterestSharesForTest(wallet._address, amount) + await sponsorship.mintForTest(wallet._address, amount) - await expect(sponsorship.connect(wallet2).operatorRedeem(wallet._address, toWei('10'), [])) + // Test redeem revert + await expect(sponsorship.connect(wallet2).operatorRedeem(wallet._address, amount)) .to.be.revertedWith('TokenModule/Invalid operator'); }) }) @@ -200,7 +231,53 @@ describe.only('Sponsorship contract', function() { }) describe('sweep()', () => { - it('should allow anyone to sweep for a list of users') + it('should allow anyone to sweep for a list of users', async () => { + const numAccounts = 5 + const iterableAccounts = getIterable(await buidler.ethers.getSigners(), numAccounts) + const amounts = [toWei('10'), toWei('98765'), toWei('100'), toWei('100000000'), toWei('10101101')] + const accountAddresses = [] + const interestAmount = toWei('1') + let totalSupply = toWei('0') + + // Pre-fund sponsorship tokens *with interest* + for await (let user of iterableAccounts()) { + await sponsorship.mintForTest(user.data._address, amounts[user.index]) + await sponsorship.setInterestSharesForTest(user.data._address, amounts[user.index].add(interestAmount)) + + accountAddresses.push(user.data._address) + totalSupply = totalSupply.add(amounts[user.index]) + } + debug({accountAddresses}) + + // Mocks for multiple accounts + for await (let user of iterableAccounts()) { + await _mocksForSweep({ + account: user.data, + totalSupply: totalSupply, + redeemCollateral: interestAmount, + credit: interestAmount, + }) + + await _mocksForRedeem({ + account: user.data, + redeem: amounts[user.index], + redeemedTickets: amounts[user.index], + transfer: amounts[user.index], + }) + totalSupply = totalSupply.sub(amounts[user.index]) + } + + // Sweep for multiple accounts + await expect(sponsorship.sweep(accountAddresses)) + .to.emit(sponsorship, 'SponsorshipSwept') + .withArgs(wallet._address, accountAddresses) + + // Test balances; all interest swept + for await (let user of iterableAccounts()) { + expect(await sponsorship.balanceOfInterestShares(user.data._address)).to.equal(amounts[user.index]) // "interestAmount" swept + expect(await sponsorship.balanceOf(user.data._address)).to.equal(amounts[user.index]) + } + }) }) }); diff --git a/test/helpers/iterable.js b/test/helpers/iterable.js new file mode 100644 index 00000000..922f871f --- /dev/null +++ b/test/helpers/iterable.js @@ -0,0 +1,9 @@ + +module.exports = (dataArray, count, startIndex = 0) => { + return async function* asyncGenerator() { + let i = 0 + while (i < count) { + yield {index: i, data: dataArray[startIndex + i++]} + } + } +} \ No newline at end of file