-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[BOOST-4506] feat(evm): signature based incentives (#37)
- Loading branch information
Showing
2 changed files
with
297 additions
and
0 deletions.
There are no files selected for viewing
138 changes: 138 additions & 0 deletions
138
packages/evm/contracts/incentives/ERC20VariableIncentive.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
// SPDX-License-Identifier: GPL-3.0 | ||
pragma solidity ^0.8.24; | ||
|
||
import {LibPRNG} from "@solady/utils/LibPRNG.sol"; | ||
import {SafeTransferLib} from "@solady/utils/SafeTransferLib.sol"; | ||
|
||
import {BoostError} from "contracts/shared/BoostError.sol"; | ||
import {Incentive} from "contracts/incentives/Incentive.sol"; | ||
import {Budget} from "contracts/budgets/Budget.sol"; | ||
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
/// @title ERC20 Incentive with Variable Rewards | ||
/// @notice A modified ERC20 incentive implementation that allows claiming of variable token amounts with a spending limit | ||
|
||
contract ERC20VariableIncentive is Incentive { | ||
using SafeTransferLib for address; | ||
|
||
/// @notice The reward multiplier; if 0, the signed amount from the claim payload is used directly | ||
/// @notice The payload for initializing the incentive | ||
struct InitPayload { | ||
address asset; | ||
uint256 reward; | ||
uint256 limit; | ||
} | ||
|
||
/// @notice The address of the ERC20-like token | ||
address public asset; | ||
|
||
/// @notice The spending limit (max total claimable amount) | ||
uint256 public limit; | ||
|
||
/// @notice The total amount claimed so far | ||
uint256 public totalClaimed; | ||
|
||
/// @notice Initialize the contract with the incentive parameters | ||
/// @param data_ The compressed incentive parameters `(address asset, uint256 reward, uint256 limit)` | ||
function initialize(bytes calldata data_) public override initializer { | ||
InitPayload memory init_ = abi.decode(data_, (InitPayload)); | ||
|
||
address asset_ = init_.asset; | ||
uint256 reward_ = init_.reward; | ||
uint256 limit_ = init_.limit; | ||
|
||
if (limit_ == 0) revert BoostError.InvalidInitialization(); | ||
|
||
uint256 available = asset_.balanceOf(address(this)); | ||
if (available < limit_) { | ||
revert BoostError.InsufficientFunds(init_.asset, available, limit_); | ||
} | ||
|
||
asset = asset_; | ||
reward = reward_; | ||
limit = limit_; | ||
totalClaimed = 0; | ||
|
||
_initializeOwner(msg.sender); | ||
} | ||
|
||
/// @notice Claim the incentive with variable rewards | ||
/// @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) { | ||
ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); | ||
uint256 signedAmount = abi.decode(claim_.data, (uint256)); | ||
uint256 claimAmount; | ||
if (!_isClaimable(claim_.target)) revert NotClaimable(); | ||
|
||
if (reward == 0) { | ||
claimAmount = signedAmount; | ||
} else { | ||
// NOTE: this is assuming that the signed scalar is in ETH decimal format | ||
claimAmount = reward * signedAmount / 1e18; | ||
} | ||
|
||
if (totalClaimed + claimAmount > limit) revert ClaimFailed(); | ||
|
||
totalClaimed += claimAmount; | ||
asset.safeTransfer(claim_.target, claimAmount); | ||
|
||
emit Claimed(claim_.target, abi.encodePacked(asset, claim_.target, claimAmount)); | ||
return true; | ||
} | ||
|
||
/// @notice Check if an incentive is claimable | ||
/// @param data_ The data payload for the claim check `(address recipient, bytes data)` | ||
/// @return True if the incentive is claimable based on the data payload | ||
function isClaimable(bytes calldata data_) public view override returns (bool) { | ||
ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); | ||
return _isClaimable(claim_.target); | ||
} | ||
|
||
/// @notice Check if an incentive is claimable for a specific recipient | ||
/// @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 totalClaimed < limit; | ||
} | ||
|
||
/// @inheritdoc Incentive | ||
function reclaim(bytes calldata data_) external override onlyOwner returns (bool) { | ||
ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); | ||
(uint256 amount) = abi.decode(claim_.data, (uint256)); | ||
|
||
limit -= amount; | ||
|
||
// Transfer the tokens back to the intended recipient | ||
asset.safeTransfer(claim_.target, amount); | ||
emit Claimed(claim_.target, abi.encodePacked(asset, claim_.target, amount)); | ||
|
||
return true; | ||
} | ||
|
||
/// @inheritdoc Incentive | ||
/// @notice Preflight the incentive to determine the required budget action | ||
/// @param data_ The data payload for the incentive `(address asset, uint256 reward, uint256 limit)` | ||
/// @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) { | ||
(address asset_, uint256 reward_, uint256 limit_) = abi.decode(data_, (address, uint256, uint256)); | ||
|
||
return abi.encode( | ||
Budget.Transfer({ | ||
assetType: Budget.AssetType.ERC20, | ||
asset: asset_, | ||
target: address(this), | ||
data: abi.encode(Budget.FungiblePayload({amount: limit_})) | ||
}) | ||
); | ||
} | ||
|
||
/// @inheritdoc Incentive | ||
function getComponentInterface() public pure virtual override returns (bytes4) { | ||
return type(Incentive).interfaceId; | ||
} | ||
|
||
/// @inheritdoc Incentive | ||
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { | ||
return interfaceId == type(Incentive).interfaceId || super.supportsInterface(interfaceId); | ||
} | ||
} |
159 changes: 159 additions & 0 deletions
159
packages/evm/test/incentives/ERC20VariableIncentive.t.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
// SPDX-License-Identifier: GPL-3.0 | ||
pragma solidity ^0.8.24; | ||
|
||
import {Test, console} from "lib/forge-std/src/Test.sol"; | ||
import {MockERC20} from "contracts/shared/Mocks.sol"; | ||
import {LibClone} from "@solady/utils/LibClone.sol"; | ||
import {SafeTransferLib} from "@solady/utils/SafeTransferLib.sol"; | ||
|
||
import {BoostError} from "contracts/shared/BoostError.sol"; | ||
import {Incentive} from "contracts/incentives/Incentive.sol"; | ||
import {ERC20VariableIncentive} from "contracts/incentives/ERC20VariableIncentive.sol"; | ||
|
||
import {Budget} from "contracts/budgets/Budget.sol"; | ||
import {SimpleBudget} from "contracts/budgets/SimpleBudget.sol"; | ||
|
||
contract ERC20VariableIncentiveTest is Test { | ||
using SafeTransferLib for address; | ||
|
||
// Declare test accounts as constants | ||
address CLAIM_RECIPIENT = makeAddr("CLAIM_RECIPIENT"); | ||
address EXCEEDS_LIMIT_CLAIM = makeAddr("EXCEEDS_LIMIT_CLAIM"); | ||
address VARIABLE_REWARD_CLAIM = makeAddr("VARIABLE_REWARD_CLAIM"); | ||
|
||
ERC20VariableIncentive public incentive; | ||
SimpleBudget public budget = new SimpleBudget(); | ||
MockERC20 public mockAsset = new MockERC20(); | ||
|
||
function setUp() public { | ||
incentive = _newIncentiveClone(); | ||
|
||
// Preload the budget with some mock tokens | ||
mockAsset.mint(address(this), 100 ether); | ||
mockAsset.approve(address(budget), 100 ether); | ||
budget.allocate(_makeFungibleTransfer(Budget.AssetType.ERC20, address(mockAsset), address(this), 100 ether)); | ||
|
||
// Manually handle the budget disbursement | ||
budget.disburse( | ||
_makeFungibleTransfer(Budget.AssetType.ERC20, address(mockAsset), address(incentive), 100 ether) | ||
); | ||
} | ||
|
||
/////////////////////////////// | ||
// ERC20VariableIncentive.initialize // | ||
/////////////////////////////// | ||
|
||
function testInitialize() public { | ||
// Initialize the ERC20VariableIncentive | ||
_initialize(address(mockAsset), 1 ether, 5 ether); | ||
|
||
// Check the incentive parameters | ||
assertEq(incentive.asset(), address(mockAsset)); | ||
assertEq(incentive.reward(), 1 ether); | ||
assertEq(incentive.limit(), 5 ether); | ||
} | ||
|
||
function testInitialize_InsufficientFunds() public { | ||
// Attempt to initialize with a limit greater than available balance => revert | ||
vm.expectRevert( | ||
abi.encodeWithSelector(BoostError.InsufficientFunds.selector, address(mockAsset), 100 ether, 101 ether) | ||
); | ||
_initialize(address(mockAsset), 1 ether, 101 ether); | ||
} | ||
|
||
function testInitialize_InvalidInitialization() public { | ||
// Attempt to initialize with invalid parameters => revert | ||
vm.expectRevert(BoostError.InvalidInitialization.selector); | ||
_initialize(address(mockAsset), 0, 0); | ||
} | ||
|
||
//////////////////////////////// | ||
// ERC20VariableIncentive.claim // | ||
//////////////////////////////// | ||
|
||
function testClaim() public { | ||
// Initialize the ERC20VariableIncentive | ||
_initialize(address(mockAsset), 1 ether, 5 ether); | ||
|
||
vm.expectEmit(true, false, false, true); | ||
emit Incentive.Claimed(CLAIM_RECIPIENT, abi.encodePacked(address(mockAsset), CLAIM_RECIPIENT, uint256(1 ether))); | ||
|
||
// Claim the incentive | ||
bytes memory claimPayload = | ||
abi.encode(Incentive.ClaimPayload({target: CLAIM_RECIPIENT, data: abi.encode(1 ether)})); | ||
incentive.claim(claimPayload); | ||
|
||
// Check the claim status and balance | ||
assertEq(mockAsset.balanceOf(CLAIM_RECIPIENT), 1 ether); | ||
assertTrue(incentive.isClaimable(claimPayload)); | ||
} | ||
|
||
function testClaim_ClaimFailed() public { | ||
// Initialize the ERC20VariableIncentive | ||
_initialize(address(mockAsset), 1 ether, 2 ether); | ||
|
||
// Attempt to claim more than the limit => revert | ||
bytes memory claimPayload = | ||
abi.encode(Incentive.ClaimPayload({target: EXCEEDS_LIMIT_CLAIM, data: abi.encode(3 ether)})); | ||
vm.expectRevert(Incentive.ClaimFailed.selector); | ||
incentive.claim(claimPayload); | ||
} | ||
|
||
function testClaim_VariableReward() public { | ||
// Initialize the ERC20VariableIncentive with zero reward, meaning signed amount will be used directly | ||
_initialize(address(mockAsset), 0, 5 ether); | ||
|
||
// Claim with variable reward | ||
bytes memory claimPayload = | ||
abi.encode(Incentive.ClaimPayload({target: VARIABLE_REWARD_CLAIM, data: abi.encode(2 ether)})); | ||
incentive.claim(claimPayload); | ||
|
||
// Check the claim status and balance | ||
assertEq(mockAsset.balanceOf(VARIABLE_REWARD_CLAIM), 2 ether); | ||
assertTrue(incentive.isClaimable(claimPayload)); | ||
} | ||
|
||
///////////////////////////////// | ||
// ERC20VariableIncentive.supportsInterface // | ||
///////////////////////////////// | ||
|
||
function testSupportsInterface() public { | ||
// Ensure the contract supports the Incentive interface | ||
assertTrue(incentive.supportsInterface(type(Incentive).interfaceId)); | ||
} | ||
|
||
function testSupportsInterface_NotSupported() public { | ||
// Ensure the contract does not support an unsupported interface | ||
assertFalse(incentive.supportsInterface(type(Test).interfaceId)); | ||
} | ||
|
||
/////////////////////////// | ||
// Test Helper Functions // | ||
/////////////////////////// | ||
|
||
function _newIncentiveClone() internal returns (ERC20VariableIncentive) { | ||
return ERC20VariableIncentive(LibClone.clone(address(new ERC20VariableIncentive()))); | ||
} | ||
|
||
function _initialize(address asset, uint256 reward, uint256 limit) internal { | ||
incentive.initialize(_initPayload(asset, reward, limit)); | ||
} | ||
|
||
function _initPayload(address asset, uint256 reward, uint256 limit) internal pure returns (bytes memory) { | ||
return abi.encode(ERC20VariableIncentive.InitPayload({asset: asset, reward: reward, limit: limit})); | ||
} | ||
|
||
function _makeFungibleTransfer(Budget.AssetType assetType, address asset, address target, uint256 value) | ||
internal | ||
pure | ||
returns (bytes memory) | ||
{ | ||
Budget.Transfer memory transfer; | ||
transfer.assetType = assetType; | ||
transfer.asset = asset; | ||
transfer.target = target; | ||
transfer.data = abi.encode(Budget.FungiblePayload({amount: value})); | ||
|
||
return abi.encode(transfer); | ||
} | ||
} |