Skip to content

Commit

Permalink
[BOOST-4506] feat(evm): signature based incentives (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
sammccord authored Aug 30, 2024
2 parents c0e0640 + b40c8b0 commit 6d85f96
Show file tree
Hide file tree
Showing 2 changed files with 297 additions and 0 deletions.
138 changes: 138 additions & 0 deletions packages/evm/contracts/incentives/ERC20VariableIncentive.sol
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 packages/evm/test/incentives/ERC20VariableIncentive.t.sol
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);
}
}

0 comments on commit 6d85f96

Please sign in to comment.