From 143cd77965edee6e53f9b00979bb08465c83a7df Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 21 Mar 2024 09:25:11 -0400 Subject: [PATCH] feat(incentives): Add raffle support to ERC20Incentive --- src/incentives/AllowListIncentive.sol | 12 +-- src/incentives/ERC1155Incentive.sol | 20 ++--- src/incentives/ERC20Incentive.sol | 71 +++++++++++++----- src/incentives/PointsIncentive.sol | 12 +-- test/BoostCore.t.sol | 4 +- test/budgets/VestingBudget.t.sol | 34 ++++----- test/e2e/EndToEnd.t.sol | 2 +- test/incentives/AllowListIncentive.t.sol | 6 +- test/incentives/ERC1155Incentive.t.sol | 14 ++-- test/incentives/ERC20Incentive.t.sol | 96 +++++++++++++++++++++--- test/incentives/PointsIncentive.t.sol | 6 +- 11 files changed, 195 insertions(+), 82 deletions(-) diff --git a/src/incentives/AllowListIncentive.sol b/src/incentives/AllowListIncentive.sol index 7c83514c..300e0b8a 100644 --- a/src/incentives/AllowListIncentive.sol +++ b/src/incentives/AllowListIncentive.sol @@ -20,14 +20,14 @@ contract AllowListIncentive is Incentive { /// @notice The payload for initializing an AllowListIncentive struct InitPayload { SimpleAllowList allowList; - uint256 maxClaims; + uint256 limit; } /// @notice The SimpleAllowList contract SimpleAllowList public allowList; /// @notice The maximum number of claims that can be made (one per address) - uint256 public maxClaims; + uint256 public limit; /// @notice Construct a new AllowListIncentive /// @dev Because this contract is a base implementation, it should not be initialized through the constructor. Instead, it should be cloned and initialized using the {initialize} function. @@ -36,12 +36,12 @@ contract AllowListIncentive is Incentive { } /// @notice Initialize the contract with the incentive parameters - /// @param data_ The compressed initialization data `(SimpleAllowList allowList, uint256 maxClaims)` + /// @param data_ The compressed initialization data `(SimpleAllowList allowList, uint256 limit)` function initialize(bytes calldata data_) public override initializer { InitPayload memory init_ = abi.decode(data_.cdDecompress(), (InitPayload)); _initializeOwner(msg.sender); allowList = init_.allowList; - maxClaims = init_.maxClaims; + limit = init_.limit; } /// @inheritdoc Incentive @@ -49,7 +49,7 @@ contract AllowListIncentive is Incentive { /// @param data_ The claim data function claim(bytes calldata data_) external virtual override onlyOwner returns (bool) { ClaimPayload memory claim_ = abi.decode(data_.cdDecompress(), (ClaimPayload)); - if (claims++ >= maxClaims || claimed[claim_.target]) revert NotClaimable(); + if (claims++ >= limit || claimed[claim_.target]) revert NotClaimable(); claimed[claim_.target] = true; (address[] memory users, bool[] memory allowed) = _makeAllowListPayload(claim_.target); @@ -67,7 +67,7 @@ contract AllowListIncentive is Incentive { /// @inheritdoc Incentive function isClaimable(bytes calldata data_) external view virtual override returns (bool) { ClaimPayload memory claim_ = abi.decode(data_.cdDecompress(), (ClaimPayload)); - return claims < maxClaims && !claimed[claim_.target] && !allowList.isAllowed(claim_.target, ""); + return claims < limit && !claimed[claim_.target] && !allowList.isAllowed(claim_.target, ""); } /// @inheritdoc Incentive diff --git a/src/incentives/ERC1155Incentive.sol b/src/incentives/ERC1155Incentive.sol index b8d9de5b..c5e243a9 100644 --- a/src/incentives/ERC1155Incentive.sol +++ b/src/incentives/ERC1155Incentive.sol @@ -29,7 +29,7 @@ contract ERC1155Incentive is Incentive, IERC1155Receiver { IERC1155 asset; Strategy strategy; uint256 tokenId; - uint256 maxClaims; + uint256 limit; bytes extraData; } @@ -40,7 +40,7 @@ contract ERC1155Incentive is Incentive, IERC1155Receiver { Strategy public strategy; /// @notice The maximum number of claims that can be made (one per address) - uint256 public maxClaims; + uint256 public limit; /// @notice The ERC1155 token ID for the incentive uint256 public tokenId; @@ -61,18 +61,18 @@ contract ERC1155Incentive is Incentive, IERC1155Receiver { // Ensure the strategy is valid (MINT is not yet supported) if (init_.strategy == Strategy.MINT) revert BoostError.NotImplemented(); - if (init_.maxClaims == 0) revert BoostError.InvalidInitialization(); + if (init_.limit == 0) revert BoostError.InvalidInitialization(); // Ensure the maximum reward amount has been allocated uint256 available = init_.asset.balanceOf(address(this), init_.tokenId); - if (available < init_.maxClaims) { - revert BoostError.InsufficientFunds(address(init_.asset), available, init_.maxClaims); + if (available < init_.limit) { + revert BoostError.InsufficientFunds(address(init_.asset), available, init_.limit); } asset = init_.asset; strategy = init_.strategy; tokenId = init_.tokenId; - maxClaims = init_.maxClaims; + limit = init_.limit; extraData = init_.extraData; _initializeOwner(msg.sender); } @@ -105,8 +105,8 @@ contract ERC1155Incentive is Incentive, IERC1155Receiver { (uint256 amount) = abi.decode(claim_.data, (uint256)); // Ensure the amount is valid and reduce the max claims accordingly - if (amount > maxClaims) revert BoostError.ClaimFailed(msg.sender, abi.encode(claim_)); - maxClaims -= amount; + if (amount > limit) revert BoostError.ClaimFailed(msg.sender, abi.encode(claim_)); + limit -= amount; // Reclaim the incentive to the intended recipient // wake-disable-next-line reentrancy (not a risk here) @@ -129,7 +129,7 @@ contract ERC1155Incentive is Incentive, IERC1155Receiver { asset: address(init_.asset), target: address(this), data: abi.encode( - Budget.ERC1155Payload({tokenId: init_.tokenId, amount: init_.maxClaims, data: init_.extraData}) + Budget.ERC1155Payload({tokenId: init_.tokenId, amount: init_.limit, data: init_.extraData}) ) }) ) @@ -155,7 +155,7 @@ contract ERC1155Incentive is Incentive, IERC1155Receiver { /// @param recipient_ The address of the recipient /// @return True if the incentive is claimable for the recipient function _isClaimable(address recipient_) internal view returns (bool) { - return !claimed[recipient_] && claims < maxClaims; + return !claimed[recipient_] && claims < limit; } /// @inheritdoc IERC1155Receiver diff --git a/src/incentives/ERC20Incentive.sol b/src/incentives/ERC20Incentive.sol index 095a0108..83516349 100644 --- a/src/incentives/ERC20Incentive.sol +++ b/src/incentives/ERC20Incentive.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.24; +import {LibPRNG} from "lib/solady/src/utils/LibPRNG.sol"; import {LibZip} from "lib/solady/src/utils/LibZip.sol"; import {SafeTransferLib} from "lib/solady/src/utils/SafeTransferLib.sol"; @@ -11,24 +12,30 @@ import {Incentive} from "./Incentive.sol"; /// @title ERC20 Incentive /// @notice A simple ERC20 incentive implementation that allows claiming of tokens contract ERC20Incentive is Incentive { + using LibPRNG for LibPRNG.PRNG; using LibZip for bytes; using SafeTransferLib for address; + /// @notice Emitted when an entry is added to the raffle + event Entry(address indexed entry); + /// @notice The strategy for the incentive /// @dev The strategy determines how the incentive is disbursed: /// - POOL: Transfer tokens from the budget to the recipient /// - MINT: Mint tokens to the recipient directly (not yet implemented) + /// - RAFFLE: Add the recipient to a raffle for a chance to win the entire reward amount enum Strategy { POOL, - MINT + MINT, + RAFFLE } - /// @notice The payload for initializing an ERC20Incentive + /// @notice The payload for initializing the incentive struct InitPayload { address asset; Strategy strategy; uint256 reward; - uint256 maxClaims; + uint256 limit; } /// @notice The address of the ERC20-like token @@ -40,8 +47,11 @@ contract ERC20Incentive is Incentive { /// @notice The reward amount issued for each claim uint256 public reward; - /// @notice The maximum number of claims that can be made (one per address) - uint256 public maxClaims; + /// @notice The limit (max claims, or max entries for raffles) + uint256 public limit; + + /// @notice The set of addresses that have claimed a slot in the incentive raffle + address[] public entries; /// @notice Construct a new ERC20Incentive /// @dev Because this contract is a base implementation, it should not be initialized through the constructor. Instead, it should be cloned and initialized using the {initialize} function. @@ -50,16 +60,16 @@ contract ERC20Incentive is Incentive { } /// @notice Initialize the contract with the incentive parameters - /// @param data_ The compressed incentive parameters `(address asset, Strategy strategy, uint256 reward, uint256 maxClaims)` + /// @param data_ The compressed incentive parameters `(address asset, Strategy strategy, uint256 reward, uint256 limit)` function initialize(bytes calldata data_) public override initializer { InitPayload memory init_ = abi.decode(data_.cdDecompress(), (InitPayload)); // Ensure the strategy is valid (MINT is not yet supported) if (init_.strategy == Strategy.MINT) revert BoostError.NotImplemented(); - if (init_.reward == 0 || init_.maxClaims == 0) revert BoostError.InvalidInitialization(); + if (init_.reward == 0 || init_.limit == 0) revert BoostError.InvalidInitialization(); // Ensure the maximum reward amount has been allocated - uint256 maxTotalReward = init_.reward * init_.maxClaims; + uint256 maxTotalReward = init_.strategy != Strategy.RAFFLE ? init_.reward * init_.limit : init_.reward; uint256 available = init_.asset.balanceOf(address(this)); if (available < maxTotalReward) { revert BoostError.InsufficientFunds(init_.asset, available, maxTotalReward); @@ -68,7 +78,7 @@ contract ERC20Incentive is Incentive { asset = init_.asset; strategy = init_.strategy; reward = init_.reward; - maxClaims = init_.maxClaims; + limit = init_.limit; _initializeOwner(msg.sender); } @@ -76,17 +86,23 @@ contract ERC20Incentive is Incentive { /// @param data_ The data payload for the incentive claim `(address recipient, bytes data)` /// @return True if the incentive was successfully claimed function claim(bytes calldata data_) external override onlyOwner returns (bool) { - // Disburse the incentive based on the strategy (POOL only for now) - if (strategy == Strategy.POOL) { - ClaimPayload memory claim_ = abi.decode(data_.cdDecompress(), (ClaimPayload)); - if (!_isClaimable(claim_.target)) revert NotClaimable(); + ClaimPayload memory claim_ = abi.decode(data_.cdDecompress(), (ClaimPayload)); + if (!_isClaimable(claim_.target)) revert NotClaimable(); + if (strategy == Strategy.POOL) { claims++; claimed[claim_.target] = true; asset.safeTransfer(claim_.target, reward); + emit Claimed(claim_.target, abi.encodePacked(asset, claim_.target, reward)); + return true; + } else if (strategy == Strategy.RAFFLE) { + claims++; + claimed[claim_.target] = true; + entries.push(claim_.target); + emit Entry(claim_.target); return true; } @@ -98,9 +114,15 @@ contract ERC20Incentive is Incentive { ClaimPayload memory claim_ = abi.decode(data_.cdDecompress(), (ClaimPayload)); (uint256 amount) = abi.decode(claim_.data, (uint256)); - // Ensure the amount is a multiple of the reward and reduce the max claims accordingly - if (amount % reward != 0) revert BoostError.ClaimFailed(msg.sender, abi.encode(claim_)); - maxClaims -= amount / reward; + if (strategy == Strategy.RAFFLE) { + // Ensure the amount is the full reward and there are no raffle entries, then reset the limit + if (amount != reward || claims > 0) revert BoostError.ClaimFailed(msg.sender, abi.encode(claim_)); + limit = 0; + } else { + // Ensure the amount is a multiple of the reward and reduce the max claims accordingly + if (amount % reward != 0) revert BoostError.ClaimFailed(msg.sender, abi.encode(claim_)); + limit -= amount / reward; + } // Transfer the tokens back to the intended recipient asset.safeTransfer(claim_.target, amount); @@ -115,13 +137,15 @@ contract ERC20Incentive is Incentive { /// @return budgetData The {Transfer} payload to be passed to the {Budget} for interpretation function preflight(bytes calldata data_) external view override returns (bytes memory budgetData) { InitPayload memory init_ = abi.decode(data_.cdDecompress(), (InitPayload)); + uint256 amount = init_.strategy != Strategy.RAFFLE ? init_.reward * init_.limit : init_.reward; + return LibZip.cdCompress( abi.encode( Budget.Transfer({ assetType: Budget.AssetType.ERC20, asset: init_.asset, target: address(this), - data: abi.encode(Budget.FungiblePayload({amount: init_.reward * init_.maxClaims})) + data: abi.encode(Budget.FungiblePayload({amount: amount})) }) ) ); @@ -141,6 +165,17 @@ contract ERC20Incentive is Incentive { /// @param recipient_ The address of the recipient /// @return True if the incentive is claimable for the recipient function _isClaimable(address recipient_) internal view returns (bool) { - return !claimed[recipient_] && claims < maxClaims; + return !claimed[recipient_] && claims < limit; + } + + function drawRaffle() external onlyOwner { + if (strategy != Strategy.RAFFLE) revert BoostError.Unauthorized(); + + LibPRNG.PRNG memory _prng = LibPRNG.PRNG({state: block.prevrandao + block.timestamp}); + + address winnerAddress = entries[_prng.next() % entries.length]; + + asset.safeTransfer(winnerAddress, reward); + emit Claimed(winnerAddress, abi.encodePacked(asset, winnerAddress, reward)); } } diff --git a/src/incentives/PointsIncentive.sol b/src/incentives/PointsIncentive.sol index b86c2bec..d3b51549 100644 --- a/src/incentives/PointsIncentive.sol +++ b/src/incentives/PointsIncentive.sol @@ -23,7 +23,7 @@ contract PointsIncentive is Incentive { address venue; bytes4 selector; uint256 quantity; - uint256 maxClaims; + uint256 limit; } /// @notice The address of the points contract @@ -33,7 +33,7 @@ contract PointsIncentive is Incentive { uint256 public quantity; /// @notice The maximum number of claims that can be made (one per address) - uint256 public maxClaims; + uint256 public limit; /// @notice The selector for the issuance function on the points contract bytes4 public selector; @@ -45,15 +45,15 @@ contract PointsIncentive is Incentive { } /// @notice Initialize the contract with the incentive parameters - /// @param data_ The compressed incentive parameters `(address points, uint256 quantity, uint256 maxClaims)` + /// @param data_ The compressed incentive parameters `(address points, uint256 quantity, uint256 limit)` function initialize(bytes calldata data_) public override initializer { InitPayload memory init_ = abi.decode(data_.cdDecompress(), (InitPayload)); - if (init_.quantity == 0 || init_.maxClaims == 0) revert BoostError.InvalidInitialization(); + if (init_.quantity == 0 || init_.limit == 0) revert BoostError.InvalidInitialization(); venue = init_.venue; selector = init_.selector; quantity = init_.quantity; - maxClaims = init_.maxClaims; + limit = init_.limit; _initializeOwner(msg.sender); } @@ -101,6 +101,6 @@ contract PointsIncentive is Incentive { /// @param recipient_ The address of the recipient /// @return True if the incentive is claimable for the recipient function _isClaimable(address recipient_) internal view returns (bool) { - return !claimed[recipient_] && claims < maxClaims; + return !claimed[recipient_] && claims < limit; } } diff --git a/test/BoostCore.t.sol b/test/BoostCore.t.sol index 5f9c0584..f4841d2a 100644 --- a/test/BoostCore.t.sol +++ b/test/BoostCore.t.sol @@ -135,7 +135,7 @@ contract BoostCoreTest is Test { assertTrue(_incentive.strategy() == ERC20Incentive.Strategy.POOL); assertEq(_incentive.asset(), address(mockERC20)); assertEq(_incentive.reward(), 1 ether); - assertEq(_incentive.maxClaims(), 100); + assertEq(_incentive.limit(), 100); assertEq(_incentive.claims(), 0); // Check the Validator (which should be the Action) @@ -282,7 +282,7 @@ contract BoostCoreTest is Test { asset: address(mockERC20), strategy: ERC20Incentive.Strategy.POOL, reward: 1 ether, - maxClaims: 100 + limit: 100 }) ) ) diff --git a/test/budgets/VestingBudget.t.sol b/test/budgets/VestingBudget.t.sol index 44fc39c2..47173adb 100644 --- a/test/budgets/VestingBudget.t.sol +++ b/test/budgets/VestingBudget.t.sol @@ -496,9 +496,9 @@ contract VestingBudgetTest is Test { vestingBudget.disburseBatch(requests); } - //////////////////////// + ///////////////////////// // VestingBudget.total // - //////////////////////// + ///////////////////////// function testTotal() public { // Ensure the budget has 0 tokens @@ -542,9 +542,9 @@ contract VestingBudgetTest is Test { assertEq(vestingBudget.total(address(mockERC20)), 100 ether); } - //////////////////////////// + ///////////////////////////// // VestingBudget.available // - //////////////////////////// + ///////////////////////////// function testAvailable() public { // Allocate 100 tokens to the budget @@ -593,9 +593,9 @@ contract VestingBudgetTest is Test { assertEq(vestingBudget.available(address(otherMockERC20)), 0); } - ////////////////////////////// + /////////////////////////////// // VestingBudget.distributed // - ////////////////////////////// + /////////////////////////////// function testDistributed() public { // Ensure the budget has 0 tokens distributed @@ -622,18 +622,18 @@ contract VestingBudgetTest is Test { assertEq(vestingBudget.distributed(address(mockERC20)), 50 ether); } - //////////////////////////// + ///////////////////////////// // VestingBudget.reconcile // - //////////////////////////// + ///////////////////////////// function testReconcile() public { // VestingBudget does not implement reconcile assertEq(vestingBudget.reconcile(""), 0); } - //////////////////////////////// + ///////////////////////////////// // VestingBudget.setAuthorized // - //////////////////////////////// + ///////////////////////////////// function testSetAuthorized() public { // Ensure the budget authorizes an account @@ -667,9 +667,9 @@ contract VestingBudgetTest is Test { vestingBudget.setAuthorized(accounts, authorized); } - /////////////////////////////// + //////////////////////////////// // VestingBudget.isAuthorized // - /////////////////////////////// + //////////////////////////////// function testIsAuthorized() public { address[] memory accounts = new address[](1); @@ -686,9 +686,9 @@ contract VestingBudgetTest is Test { assertTrue(vestingBudget.isAuthorized(address(this))); } - //////////////////////////////////// + ///////////////////////////////////// // VestingBudget.supportsInterface // - //////////////////////////////////// + ///////////////////////////////////// function testSupportsInterface() public { // Ensure the contract supports the Budget interface @@ -700,9 +700,9 @@ contract VestingBudgetTest is Test { assertFalse(vestingBudget.supportsInterface(type(Test).interfaceId)); } - //////////////////////////// + ///////////////////////////// // VestingBudget.fallback // - //////////////////////////// + ///////////////////////////// function testFallback() public { // Ensure the fallback is payable @@ -747,7 +747,7 @@ contract VestingBudgetTest is Test { } /////////////////////////// - // VestingBudget.receive // + // VestingBudget.receive // /////////////////////////// function testReceive() public { diff --git a/test/e2e/EndToEnd.t.sol b/test/e2e/EndToEnd.t.sol index 0c364774..22090d98 100644 --- a/test/e2e/EndToEnd.t.sol +++ b/test/e2e/EndToEnd.t.sol @@ -131,7 +131,7 @@ contract EndToEnd is Test { // - Incentive[0] == ERC20Incentive assertEq(ERC20Incentive(address(boost.incentives[0])).asset(), address(erc20)); assertEq(ERC20Incentive(address(boost.incentives[0])).reward(), 100 ether); - assertEq(ERC20Incentive(address(boost.incentives[0])).maxClaims(), 5); + assertEq(ERC20Incentive(address(boost.incentives[0])).limit(), 5); // - Protocol Fee == 1,000 bps (custom fee) + 1,000 bps (base fee) = 2,000 bps = 20% assertEq(boost.protocolFee, 2_000); diff --git a/test/incentives/AllowListIncentive.t.sol b/test/incentives/AllowListIncentive.t.sol index 0b064af3..78081bc1 100644 --- a/test/incentives/AllowListIncentive.t.sol +++ b/test/incentives/AllowListIncentive.t.sol @@ -20,7 +20,7 @@ contract AllowListIncentiveTest is Test { incentive = AllowListIncentive(LibClone.clone(address(new AllowListIncentive()))); incentive.initialize( - LibZip.cdCompress(abi.encode(AllowListIncentive.InitPayload({allowList: allowList, maxClaims: 10}))) + LibZip.cdCompress(abi.encode(AllowListIncentive.InitPayload({allowList: allowList, limit: 10}))) ); allowList.grantRoles(address(incentive), 1 << 1); @@ -32,14 +32,14 @@ contract AllowListIncentiveTest is Test { function test_initialize() public { assertEq(address(incentive.allowList()), address(allowList)); - assertEq(incentive.maxClaims(), 10); + assertEq(incentive.limit(), 10); assertEq(incentive.owner(), address(this)); } function test_initialize_twice() public { vm.expectRevert(bytes4(keccak256("InvalidInitialization()"))); incentive.initialize( - LibZip.cdCompress(abi.encode(AllowListIncentive.InitPayload({allowList: allowList, maxClaims: 10}))) + LibZip.cdCompress(abi.encode(AllowListIncentive.InitPayload({allowList: allowList, limit: 10}))) ); } diff --git a/test/incentives/ERC1155Incentive.t.sol b/test/incentives/ERC1155Incentive.t.sol index fd4d37c4..313564c4 100644 --- a/test/incentives/ERC1155Incentive.t.sol +++ b/test/incentives/ERC1155Incentive.t.sol @@ -51,7 +51,7 @@ contract ERC1155IncentiveTest is Test, IERC1155Receiver { assertTrue(incentive.strategy() == ERC1155Incentive.Strategy.POOL); assertEq(address(incentive.asset()), address(mockAsset)); assertEq(incentive.tokenId(), 42); - assertEq(incentive.maxClaims(), 5); + assertEq(incentive.limit(), 5); } function testInitialize_InsufficientAllocation() public { @@ -119,7 +119,7 @@ contract ERC1155IncentiveTest is Test, IERC1155Receiver { function testReclaim() public { // Initialize the ERC1155Incentive _initialize(mockAsset, ERC1155Incentive.Strategy.POOL, 42, 100); - assertEq(incentive.maxClaims(), 100); + assertEq(incentive.limit(), 100); // Reclaim 50x the reward amount bytes memory reclaimPayload = @@ -129,7 +129,7 @@ contract ERC1155IncentiveTest is Test, IERC1155Receiver { // Check that enough assets remain to cover 50 more claims assertEq(mockAsset.balanceOf(address(incentive), 42), 50); - assertEq(incentive.maxClaims(), 50); + assertEq(incentive.limit(), 50); } function testReclaim_InvalidAmount() public { @@ -226,13 +226,13 @@ contract ERC1155IncentiveTest is Test, IERC1155Receiver { return ERC1155Incentive(LibClone.clone(address(new ERC1155Incentive()))); } - function _initialize(MockERC1155 asset, ERC1155Incentive.Strategy strategy, uint256 tokenId, uint256 maxClaims) + function _initialize(MockERC1155 asset, ERC1155Incentive.Strategy strategy, uint256 tokenId, uint256 limit) internal { - incentive.initialize(_initPayload(asset, strategy, tokenId, maxClaims)); + incentive.initialize(_initPayload(asset, strategy, tokenId, limit)); } - function _initPayload(MockERC1155 asset, ERC1155Incentive.Strategy strategy, uint256 tokenId, uint256 maxClaims) + function _initPayload(MockERC1155 asset, ERC1155Incentive.Strategy strategy, uint256 tokenId, uint256 limit) internal pure returns (bytes memory) @@ -243,7 +243,7 @@ contract ERC1155IncentiveTest is Test, IERC1155Receiver { asset: IERC1155(address(asset)), strategy: strategy, tokenId: tokenId, - maxClaims: maxClaims, + limit: limit, extraData: "" }) ) diff --git a/test/incentives/ERC20Incentive.t.sol b/test/incentives/ERC20Incentive.t.sol index 33da0115..de2dcf27 100644 --- a/test/incentives/ERC20Incentive.t.sol +++ b/test/incentives/ERC20Incentive.t.sol @@ -51,7 +51,7 @@ contract ERC20IncentiveTest is Test { assertTrue(incentive.strategy() == ERC20Incentive.Strategy.POOL); assertEq(incentive.asset(), address(mockAsset)); assertEq(incentive.reward(), 1 ether); - assertEq(incentive.maxClaims(), 5); + assertEq(incentive.limit(), 5); } function testInitialize_InsufficientAllocation() public { @@ -110,6 +110,21 @@ contract ERC20IncentiveTest is Test { incentive.claim(claimPayload); } + function testClaim_RaffleStrategy() public { + // Initialize the ERC20Incentive raffling 100 eth to 1 of 5 entrants + _initialize(address(mockAsset), ERC20Incentive.Strategy.RAFFLE, 100 ether, 5); + + // Claim the incentive, which means adding the address to the entries list + bytes memory claimPayload = + LibZip.cdCompress(abi.encode(Incentive.ClaimPayload({target: address(1), data: bytes("")}))); + incentive.claim(claimPayload); + + // Check that the entry was added and no tokens were transferred + assertTrue(incentive.claimed(address(1))); + assertEq(incentive.entries(0), address(1)); + assertEq(mockAsset.balanceOf(address(incentive)), 100 ether); + } + //////////////////////////// // ERC20Incentive.reclaim // //////////////////////////// @@ -117,7 +132,7 @@ contract ERC20IncentiveTest is Test { function testReclaim() public { // Initialize the ERC20Incentive _initialize(address(mockAsset), ERC20Incentive.Strategy.POOL, 1 ether, 100); - assertEq(incentive.maxClaims(), 100); + assertEq(incentive.limit(), 100); // Reclaim 50x the reward amount bytes memory reclaimPayload = @@ -127,7 +142,7 @@ contract ERC20IncentiveTest is Test { // Check that enough assets remain to cover 50 more claims assertEq(mockAsset.balanceOf(address(incentive)), 50 ether); - assertEq(incentive.maxClaims(), 50); + assertEq(incentive.limit(), 50); } function testReclaim_InvalidAmount() public { @@ -143,6 +158,29 @@ contract ERC20IncentiveTest is Test { incentive.reclaim(reclaimPayload); } + function testReclaim_RaffleStrategy() public { + // Initialize the ERC20Incentive raffling 100 eth to 1 of 5 entrants + _initialize(address(mockAsset), ERC20Incentive.Strategy.RAFFLE, 100 ether, 5); + + // Claim the incentive for 1 address, adding it to the raffle entries + // and locking in the reward since there's at least 1 potential winner + bytes memory claimPayload = + LibZip.cdCompress(abi.encode(Incentive.ClaimPayload({target: address(1), data: bytes("")}))); + incentive.claim(claimPayload); + assertEq(incentive.entries(0), address(1)); + assertEq(incentive.limit(), 5); + + // Attempt to reclaim the reward => revert (because the reward is now locked) + bytes memory reclaimPayload = + LibZip.cdCompress(abi.encode(Incentive.ClaimPayload({target: address(1), data: abi.encode(100 ether)}))); + + vm.expectRevert( + abi.encodeWithSelector(BoostError.ClaimFailed.selector, address(this), reclaimPayload.cdDecompress()) + ); + incentive.reclaim(reclaimPayload); + assertEq(incentive.limit(), 5); + } + //////////////////////////////// // ERC20Incentive.isClaimable // //////////////////////////////// @@ -223,6 +261,48 @@ contract ERC20IncentiveTest is Test { assertEq(payload2.amount, 0); } + function testPreflight_RaffleStrategy() public { + // Check the preflight data for a raffle + bytes memory data = + incentive.preflight(_initPayload(address(mockAsset), ERC20Incentive.Strategy.RAFFLE, 1 ether, 5)); + Budget.Transfer memory budgetRequest = abi.decode(data.cdDecompress(), (Budget.Transfer)); + + assertEq(budgetRequest.asset, address(mockAsset)); + + Budget.FungiblePayload memory payload = abi.decode(budgetRequest.data, (Budget.FungiblePayload)); + assertEq(payload.amount, 1 ether); + } + + ///////////////////////////////// + // ERC20Incentive.drawRaffle // + ///////////////////////////////// + + function testDrawRaffle() public { + // Initialize the ERC20Incentive raffling 100 eth to 1 of 5 entrants + _initialize(address(mockAsset), ERC20Incentive.Strategy.RAFFLE, 100 ether, 5); + + // Claim the incentive for 5 different addresses + address[] memory recipients = _randomAccounts(5); + for (uint256 i = 0; i < 5; i++) { + bytes memory claimPayload = + LibZip.cdCompress(abi.encode(Incentive.ClaimPayload({target: recipients[i], data: bytes("")}))); + incentive.claim(claimPayload); + } + + // Mock the environment so our PRNG is easily predictable + vm.prevrandao(bytes32(uint256(42))); + vm.warp(100); + + // Draw the raffle, the winner should be the address at index 3 because the + // PRNG is seeded with `142` which means the first random value will be: + // 64619794903595674682953496420467953339178421197965090540722661986171627552023 + // and we apply modulo 5 to get 3, which is the index of the winner. + incentive.drawRaffle(); + + // Check that the winner was selected and rewarded + assertEq(mockAsset.balanceOf(address(recipients[3])), 100 ether); + } + /////////////////////////// // Test Helper Functions // /////////////////////////// @@ -231,19 +311,17 @@ contract ERC20IncentiveTest is Test { return ERC20Incentive(LibClone.clone(address(new ERC20Incentive()))); } - function _initialize(address asset, ERC20Incentive.Strategy strategy, uint256 reward, uint256 maxClaims) internal { - incentive.initialize(_initPayload(asset, strategy, reward, maxClaims)); + function _initialize(address asset, ERC20Incentive.Strategy strategy, uint256 reward, uint256 limit) internal { + incentive.initialize(_initPayload(asset, strategy, reward, limit)); } - function _initPayload(address asset, ERC20Incentive.Strategy strategy, uint256 reward, uint256 maxClaims) + function _initPayload(address asset, ERC20Incentive.Strategy strategy, uint256 reward, uint256 limit) internal pure returns (bytes memory) { return LibZip.cdCompress( - abi.encode( - ERC20Incentive.InitPayload({asset: asset, strategy: strategy, reward: reward, maxClaims: maxClaims}) - ) + abi.encode(ERC20Incentive.InitPayload({asset: asset, strategy: strategy, reward: reward, limit: limit})) ); } diff --git a/test/incentives/PointsIncentive.t.sol b/test/incentives/PointsIncentive.t.sol index efd21ca3..344ef2c0 100644 --- a/test/incentives/PointsIncentive.t.sol +++ b/test/incentives/PointsIncentive.t.sol @@ -26,7 +26,7 @@ contract PointsIncentiveTest is Test { venue: address(points), selector: bytes4(keccak256("issue(address,uint256)")), quantity: 100, - maxClaims: 10 + limit: 10 }) ) ) @@ -41,7 +41,7 @@ contract PointsIncentiveTest is Test { assertEq(address(incentive.venue()), address(points)); assertEq(incentive.selector(), bytes4(keccak256("issue(address,uint256)"))); assertEq(incentive.quantity(), 100); - assertEq(incentive.maxClaims(), 10); + assertEq(incentive.limit(), 10); assertEq(incentive.owner(), address(this)); } @@ -54,7 +54,7 @@ contract PointsIncentiveTest is Test { venue: address(points), selector: bytes4(keccak256("mint(address,uint256)")), quantity: 100, - maxClaims: 10 + limit: 10 }) ) )