Skip to content

Commit

Permalink
feat(incentives): Add raffle support to ERC20Incentive
Browse files Browse the repository at this point in the history
  • Loading branch information
ccashwell committed Mar 21, 2024
1 parent 8e8eb95 commit 143cd77
Show file tree
Hide file tree
Showing 11 changed files with 195 additions and 82 deletions.
12 changes: 6 additions & 6 deletions src/incentives/AllowListIncentive.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -36,20 +36,20 @@ 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
/// @notice Claim a slot on the {SimpleAllowList}
/// @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);
Expand All @@ -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
Expand Down
20 changes: 10 additions & 10 deletions src/incentives/ERC1155Incentive.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ contract ERC1155Incentive is Incentive, IERC1155Receiver {
IERC1155 asset;
Strategy strategy;
uint256 tokenId;
uint256 maxClaims;
uint256 limit;
bytes extraData;
}

Expand All @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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)
Expand All @@ -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})
)
})
)
Expand All @@ -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
Expand Down
71 changes: 53 additions & 18 deletions src/incentives/ERC20Incentive.sol
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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);
Expand All @@ -68,25 +78,31 @@ contract ERC20Incentive is Incentive {
asset = init_.asset;
strategy = init_.strategy;
reward = init_.reward;
maxClaims = init_.maxClaims;
limit = init_.limit;
_initializeOwner(msg.sender);
}

/// @notice Claim the 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;
}

Expand All @@ -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);
Expand All @@ -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}))
})
)
);
Expand All @@ -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));
}
}
12 changes: 6 additions & 6 deletions src/incentives/PointsIncentive.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
}
}
4 changes: 2 additions & 2 deletions test/BoostCore.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -282,7 +282,7 @@ contract BoostCoreTest is Test {
asset: address(mockERC20),
strategy: ERC20Incentive.Strategy.POOL,
reward: 1 ether,
maxClaims: 100
limit: 100
})
)
)
Expand Down
Loading

0 comments on commit 143cd77

Please sign in to comment.