Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BOOST-5224] feat(evm): add topup functionality to core #367

Merged
merged 25 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
08985f6
feat(evm): topup functionality on incentives contracts
Quazia Jan 13, 2025
38b78f9
test(evm): add topup tests to incentive testing
Quazia Jan 13, 2025
b96a0aa
feat(evm): add topups from budgets to boost core
Quazia Jan 15, 2025
ea04f4c
fix(evm): modify topupIncentiveFromBudget to pipe accounting correctly
Quazia Jan 15, 2025
572ddb0
feat(evm): add approves into topupIncentiveFromBudget
Quazia Jan 15, 2025
3129342
feat(evm): incentives can be toped up without a budget
Quazia Jan 15, 2025
aafd0be
fix(evm): revert changes to _getFeeDisbursal
Quazia Jan 15, 2025
50695db
feat(evm): add topup function to ManagedBudgetWithFees
Quazia Jan 15, 2025
3b8b8e0
feat(sdk): account for fee as an additional amount instead of a disco…
Quazia Jan 15, 2025
eba78d8
wip: add integration tests for budget topups
topocount Jan 15, 2025
8a5ddbf
fix(evm): correct total amount handling and encoing
Quazia Jan 15, 2025
bc9b693
test(evm): enable topup fuzz testing
Quazia Jan 15, 2025
903b370
test(evm): uncomment the assertion in the topup fuzz test
Quazia Jan 17, 2025
9e33fad
fix(evm): utilize the same compiler configs as foundry
topocount Jan 22, 2025
761912b
feat(evm): implement Topup events in core
topocount Jan 23, 2025
7d6fbac
fix(evm): increment management fee on disbursement rather than set it
topocount Jan 20, 2025
64174f7
feat(evm): tweak topup accounting in topupIncentiveFromSender
Quazia Jan 22, 2025
2fca309
feat(sdk): add topup payload helpers to incentives
Quazia Jan 22, 2025
0777286
feat(sdk): add topup helpers to core
Quazia Jan 22, 2025
39c5743
fix(sdk): typo in getTopupPayload call
Quazia Jan 23, 2025
7103cea
fix(evm): tweak topup approval flow for sender topup
Quazia Jan 23, 2025
626ee6e
feat(sdk): throw if topup is zero in SDK
Quazia Jan 23, 2025
6073558
fix(sdk): correct asset retrieval in getTopupPayload
Quazia Jan 23, 2025
bf1a8da
test(sdk): add running incentive topup test
Quazia Jan 23, 2025
fcd9d88
fix(evm): _getFeeDisbursal merge regression
Quazia Jan 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions packages/evm/contracts/BoostCore.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {ABudget} from "contracts/budgets/ABudget.sol";
import {AIncentive} from "contracts/incentives/AIncentive.sol";
import {IAuth} from "contracts/auth/IAuth.sol";
import {AValidator} from "contracts/validators/AValidator.sol";
import {IToppable} from "contracts/shared/IToppable.sol";

/// @title Boost Core
/// @notice The core contract for the Boost protocol
Expand Down Expand Up @@ -63,6 +64,10 @@ contract BoostCore is Ownable, ReentrancyGuard {
uint256 indexed boostId, uint256 indexed incentiveId, address indexed claimant, address referrer, bytes data
);

event BoostToppedUp(
uint256 indexed boostId, uint256 indexed incentiveId, address indexed sender, uint256 amount, uint256 fee
);

/// @notice The list of boosts
BoostLib.Boost[] private _boosts;

Expand Down Expand Up @@ -204,6 +209,151 @@ contract BoostCore is Ownable, ReentrancyGuard {
emit IncentiveAdded(boostId, newIncentiveId, address(newIncentive), false);
}

/// @notice Top up an existing incentive using tokens from a budget.
/// All tokens are first transferred into BoostCore, then a protocol fee
/// is accounted for and reserved in this function. The remainder is approved
/// and the incentive contract pulls the tokens during the topup call.
/// @param boostId The ID of the Boost
/// @param incentiveId The ID of the incentive within that Boost
/// @param data_ The raw data to pass to the incentive’s `preflight` and eventually `topup`
/// @param budget The budget contract from which to disburse; if zero, we use the boost's default budget
function topupIncentiveFromBudget(uint256 boostId, uint256 incentiveId, bytes calldata data_, address budget)
external
nonReentrant
{
BoostLib.Boost storage boost = _boosts[boostId];
AIncentive incentiveContract = boost.incentives[incentiveId];

bytes memory preflightData = incentiveContract.preflight(data_);
if (preflightData.length == 0) {
revert BoostError.InvalidInitialization();
}

ABudget.Transfer memory request = abi.decode(preflightData, (ABudget.Transfer));

ABudget budget_ = (budget == address(0)) ? boost.budget : ABudget(payable(budget));
_checkBudget(budget_);

uint256 topupAmount;
uint256 fee;
uint256 totalAmount;
{
if (request.assetType == ABudget.AssetType.ERC20 || request.assetType == ABudget.AssetType.ETH) {
ABudget.FungiblePayload memory payload = abi.decode(request.data, (ABudget.FungiblePayload));
topupAmount = payload.amount;
fee = (topupAmount * boost.protocolFee) / FEE_DENOMINATOR;
totalAmount = topupAmount + fee;
payload.amount = totalAmount;
request.data = abi.encode(payload);
} else if (request.assetType == ABudget.AssetType.ERC1155) {
ABudget.ERC1155Payload memory payload = abi.decode(request.data, (ABudget.ERC1155Payload));
topupAmount = payload.amount;
fee = (topupAmount * boost.protocolFee) / FEE_DENOMINATOR;
totalAmount = topupAmount + fee;
payload.amount = totalAmount;
request.data = abi.encode(payload);
} else {
revert BoostError.NotImplemented();
}

// Redirect the disbursement to this contract so we have the funds to topup and reserve the protocol fee
request.target = address(this);
bytes memory revisedRequest = abi.encode(request);

if (!budget_.disburse(revisedRequest)) {
revert BoostError.InvalidInitialization();
}

bytes32 key = _generateKey(boostId, incentiveId);
IncentiveDisbursalInfo storage info = incentivesFeeInfo[key];
info.protocolFeesRemaining += fee;
}

if (topupAmount > 0) {
{
if (request.assetType == ABudget.AssetType.ERC20 || request.assetType == ABudget.AssetType.ETH) {
// Approve exactly `topupAmount` tokens
IERC20 token = IERC20(request.asset);
token.approve(address(incentiveContract), topupAmount);
IToppable(address(incentiveContract)).topup(topupAmount);
token.approve(address(incentiveContract), 0);
} else {
// ERC1155 => setApprovalForAll
IERC1155 erc1155 = IERC1155(request.asset);
erc1155.setApprovalForAll(address(incentiveContract), true);
IToppable(address(incentiveContract)).topup(topupAmount);
erc1155.setApprovalForAll(address(incentiveContract), false);
}
}
emit BoostToppedUp(boostId, incentiveId, msg.sender, topupAmount, fee);
}
}

/// @notice Top up an existing incentive using tokens pulled directly from the caller (msg.sender).
/// All tokens are first transferred into BoostCore, then a protocol fee is accounted for.
/// The remainder is approved so the incentive contract can pull them during the topup call.
/// @param boostId The ID of the Boost
/// @param incentiveId The ID of the incentive within that Boost
/// @param data_ The raw data to pass to the incentive’s `preflight` and eventually `topup`
function topupIncentiveFromSender(uint256 boostId, uint256 incentiveId, bytes calldata data_)
external
nonReentrant
{
BoostLib.Boost storage boost = _boosts[boostId];
AIncentive incentiveContract = boost.incentives[incentiveId];
bytes memory preflightData = incentiveContract.preflight(data_);
if (preflightData.length == 0) {
revert BoostError.InvalidInitialization();
}
ABudget.Transfer memory request = abi.decode(preflightData, (ABudget.Transfer));

uint256 topupAmount;
uint256 fee;
uint256 totalAmount;
uint256 tokenId;
if (request.assetType == ABudget.AssetType.ERC20 || request.assetType == ABudget.AssetType.ETH) {
ABudget.FungiblePayload memory payload = abi.decode(request.data, (ABudget.FungiblePayload));
topupAmount = payload.amount;
fee = (topupAmount * boost.protocolFee) / FEE_DENOMINATOR;
totalAmount = topupAmount + fee;
payload.amount = totalAmount;
} else if (request.assetType == ABudget.AssetType.ERC1155) {
ABudget.ERC1155Payload memory payload = abi.decode(request.data, (ABudget.ERC1155Payload));
topupAmount = payload.amount;
fee = (topupAmount * boost.protocolFee) / FEE_DENOMINATOR;
totalAmount = topupAmount + fee;
payload.amount = totalAmount;
tokenId = payload.tokenId;
} else {
revert BoostError.NotImplemented();
}

{
bytes32 key = _generateKey(boostId, incentiveId);
IncentiveDisbursalInfo storage info = incentivesFeeInfo[key];
info.protocolFeesRemaining += fee;
}

if (topupAmount > 0) {
if (request.assetType == ABudget.AssetType.ERC20 || request.assetType == ABudget.AssetType.ETH) {
// Approve exactly `topupAmount` tokens
IERC20 token = IERC20(request.asset);
token.transferFrom(msg.sender, address(this), totalAmount);
token.approve(address(incentiveContract), topupAmount);
IToppable(address(incentiveContract)).topup(topupAmount);
token.approve(address(incentiveContract), 0);
} else {
// ERC1155 => setApprovalForAll
IERC1155 erc1155 = IERC1155(request.asset);
erc1155.safeTransferFrom(msg.sender, address(this), tokenId, totalAmount, "");
erc1155.setApprovalForAll(address(incentiveContract), true);
IToppable(address(incentiveContract)).topup(topupAmount);
erc1155.setApprovalForAll(address(incentiveContract), false);
}
emit BoostToppedUp(boostId, incentiveId, msg.sender, topupAmount, fee);
}
}

/// @notice Claim an incentive for a Boost
/// @param boostId_ The ID of the Boost
/// @param incentiveId_ The ID of the AIncentive
Expand Down
16 changes: 15 additions & 1 deletion packages/evm/contracts/budgets/ManagedBudgetWithFees.sol
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ contract ManagedBudgetWithFees is AManagedBudgetWithFees, ManagedBudget {
return (amount, asset);
}

/// @notice A function that triggers a top-up on a specified incentive,
/// using *this* budget contract as the source of funds.
/// @param boostId The ID of the Boost to top up
/// @param incentiveId The ID of the incentive within that Boost
/// @param data_ Arbitrary data forwarded to the incentive’s `preflight` and `topup`
function topupIncentive(address target, uint256 boostId, uint256 incentiveId, bytes calldata data_)
external
onlyAuthorized
returns (bool)
{
BoostCore(target).topupIncentiveFromBudget(boostId, incentiveId, data_, address(this));
return true;
}

/// @inheritdoc ABudget
/// @notice Disburses assets from the budget to a single recipient if sender is owner, admin, or manager
/// @param data_ The packed {Transfer} request
Expand All @@ -136,7 +150,7 @@ contract ManagedBudgetWithFees is AManagedBudgetWithFees, ManagedBudget {
revert InsufficientFunds(request.asset, avail, payload.amount + maxManagementFee);
}

incentiveFeesMax[request.target] = maxManagementFee;
incentiveFeesMax[request.target] += maxManagementFee;
reservedFunds[request.asset] += maxManagementFee;
_distributedFungible[request.asset] += payload.amount;
_transferFungible(request.asset, request.target, payload.amount);
Expand Down
32 changes: 31 additions & 1 deletion packages/evm/contracts/incentives/ERC20Incentive.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import {AERC20Incentive} from "contracts/incentives/AERC20Incentive.sol";
import {AIncentive} from "contracts/incentives/AIncentive.sol";
import {ABudget} from "contracts/budgets/ABudget.sol";
import {RBAC} from "contracts/shared/RBAC.sol";
import {IToppable} from "contracts/shared/IToppable.sol";

/// @title ERC20 AIncentive
/// @notice A simple ERC20 incentive implementation that allows claiming of tokens
contract ERC20Incentive is RBAC, AERC20Incentive {
contract ERC20Incentive is RBAC, AERC20Incentive, IToppable {
using LibPRNG for LibPRNG.PRNG;
using SafeTransferLib for address;

Expand Down Expand Up @@ -137,6 +138,35 @@ contract ERC20Incentive is RBAC, AERC20Incentive {
return (amount, asset);
}

/// @notice Top up the incentive with more ERC20 tokens
/// @dev Uses `msg.sender` as the token source, and uses `asset` to identify which token.
/// Caller must approve this contract to spend at least `amount` prior to calling.
/// @param amount The number of tokens to top up
function topup(uint256 amount) external virtual override onlyOwnerOrRoles(MANAGER_ROLE) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bit of a bikeshedding question: should this interface be added to AIncentive?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I specifically avoided that cause it'd change all the component interfaces and we're not calling into it from the helpers but I could add it. Would just require more thought around the component interface change for existing incentive modules.

if (amount == 0) {
revert BoostError.InvalidInitialization();
}
// Transfer tokens from the caller into this contract
asset.safeTransferFrom(msg.sender, address(this), amount);

// For RAFFLE strategy, decide whether or not to allow multiple prizes
// For simplicity, revert here:
if (strategy == Strategy.RAFFLE) {
revert BoostError.Unauthorized();
}

// For POOL strategy, each claim uses `reward` tokens, so require multiples
if (amount % reward != 0) {
revert BoostError.InvalidInitialization();
}

// Increase how many times this incentive can be claimed
uint256 additionalClaims = amount / reward;
limit += additionalClaims;

emit ToppedUp(msg.sender, amount);
}

/// @notice Check if an incentive is claimable
/// @param claimTarget the address that could receive the claim
/// @return True if the incentive is claimable based on the data payload
Expand Down
20 changes: 19 additions & 1 deletion packages/evm/contracts/incentives/ERC20PeggedIncentive.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import {AERC20PeggedIncentive} from "contracts/incentives/AERC20PeggedIncentive.
import {AIncentive} from "contracts/incentives/AIncentive.sol";
import {ABudget} from "contracts/budgets/ABudget.sol";
import {RBAC} from "contracts/shared/RBAC.sol";
import {IToppable} from "contracts/shared/IToppable.sol";

contract ERC20PeggedIncentive is RBAC, AERC20PeggedIncentive {
contract ERC20PeggedIncentive is RBAC, AERC20PeggedIncentive, IToppable {
using SafeTransferLib for address;

event ERC20PeggedIncentiveInitialized(
Expand Down Expand Up @@ -126,6 +127,23 @@ contract ERC20PeggedIncentive is RBAC, AERC20PeggedIncentive {
return (amount, asset);
}

/// @notice Top up the incentive with more ERC20 tokens
/// @dev Uses `msg.sender` as the token source, and uses `asset` to identify which token.
/// Caller must approve this contract to spend at least `amount` prior to calling.
/// @param amount The number of tokens to top up
function topup(uint256 amount) external virtual override onlyOwnerOrRoles(MANAGER_ROLE) {
if (amount == 0) {
revert BoostError.InvalidInitialization();
}
Comment on lines +135 to +137
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why have this check? spam avoidance?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably remove it - the check exists in the SDK and I'm generally in favor of less in-contract checks for 'enough rope' type problems

// Transfer tokens from the caller into this contract
asset.safeTransferFrom(msg.sender, address(this), amount);

// Increase the total incentive limit
limit += amount;

emit ToppedUp(msg.sender, amount);
}

/// @notice Check if an incentive is claimable
/// @param claimTarget the address that could receive the claim
/// @return True if the incentive is claimable based on the data payload
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {AERC20PeggedIncentive} from "contracts/incentives/AERC20PeggedIncentive.
import {AIncentive} from "contracts/incentives/AIncentive.sol";
import {ABudget} from "contracts/budgets/ABudget.sol";
import {RBAC} from "contracts/shared/RBAC.sol";
import {IToppable} from "contracts/shared/IToppable.sol";

contract ERC20PeggedVariableCriteriaIncentive is RBAC, AERC20PeggedVariableCriteriaIncentive {
contract ERC20PeggedVariableCriteriaIncentive is RBAC, AERC20PeggedVariableCriteriaIncentive, IToppable {
using SafeTransferLib for address;

event ERC20PeggedIncentiveInitialized(
Expand Down Expand Up @@ -155,6 +156,23 @@ contract ERC20PeggedVariableCriteriaIncentive is RBAC, AERC20PeggedVariableCrite
return (amount, asset);
}

/// @notice Top up the incentive with more ERC20 tokens
/// @dev Uses `msg.sender` as the token source, and uses `asset` to identify which token.
/// Caller must approve this contract to spend at least `amount` prior to calling.
/// @param amount The number of tokens to top up
function topup(uint256 amount) external virtual override onlyOwnerOrRoles(MANAGER_ROLE) {
if (amount == 0) {
revert BoostError.InvalidInitialization();
}
// Transfer tokens from the caller into this contract
asset.safeTransferFrom(msg.sender, address(this), amount);

// Increase the total incentive limit
limit += amount;

emit ToppedUp(msg.sender, amount);
}

/// @notice Check if an incentive is claimable
/// @param claimTarget the address that could receive the claim
/// @return True if the incentive is claimable based on the data payload
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {AERC20VariableCriteriaIncentive} from "contracts/incentives/AERC20Variab
import {ABudget} from "contracts/budgets/ABudget.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {AIncentive} from "contracts/incentives/AIncentive.sol";
import {IToppable} from "contracts/shared/IToppable.sol";

enum SignatureType {
FUNC,
Expand Down Expand Up @@ -88,4 +89,21 @@ contract ERC20VariableCriteriaIncentive is AERC20VariableCriteriaIncentive {
emit Claimed(claimTarget, abi.encodePacked(asset, claimTarget, claimAmount));
return true;
}

/// @notice Top up the incentive with more ERC20 tokens
/// @dev Uses `msg.sender` as the token source, and uses `asset` to identify which token.
/// Caller must approve this contract to spend at least `amount` prior to calling.
/// @param amount The number of tokens to top up
function topup(uint256 amount) external virtual override onlyOwnerOrRoles(MANAGER_ROLE) {
if (amount == 0) {
revert BoostError.InvalidInitialization();
}
// Transfer tokens from the caller into this contract
asset.safeTransferFrom(msg.sender, address(this), amount);

// Increase the total incentive limit
limit += amount;

emit ToppedUp(msg.sender, amount);
}
}
20 changes: 19 additions & 1 deletion packages/evm/contracts/incentives/ERC20VariableIncentive.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import {ABudget} from "contracts/budgets/ABudget.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {AIncentive} from "contracts/incentives/AIncentive.sol";
import {RBAC} from "contracts/shared/RBAC.sol";
import {IToppable} from "contracts/shared/IToppable.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 AERC20VariableIncentive, RBAC {
contract ERC20VariableIncentive is AERC20VariableIncentive, RBAC, IToppable {
using SafeTransferLib for address;

event ERC20VariableIncentiveInitialized(address indexed asset, uint256 reward, uint256 limit);
Expand Down Expand Up @@ -128,6 +129,23 @@ contract ERC20VariableIncentive is AERC20VariableIncentive, RBAC {
return (amount, asset);
}

/// @notice Top up the incentive with more ERC20 tokens
/// @dev Uses `msg.sender` as the token source, and uses `asset` to identify which token.
/// Caller must approve this contract to spend at least `amount` prior to calling.
/// @param amount The number of tokens to top up
function topup(uint256 amount) external virtual override onlyOwnerOrRoles(MANAGER_ROLE) {
if (amount == 0) {
revert BoostError.InvalidInitialization();
}
// Transfer tokens from the caller into this contract
asset.safeTransferFrom(msg.sender, address(this), amount);

// Increase the total incentive limit
limit += amount;

emit ToppedUp(msg.sender, amount);
}

/// @inheritdoc AIncentive
/// @notice Preflight the incentive to determine the required budget action
/// @param data_ The data payload for the incentive `(address asset, uint256 reward, uint256 limit)`
Expand Down
12 changes: 12 additions & 0 deletions packages/evm/contracts/shared/IToppable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

interface IToppable {
event ToppedUp(address sender, uint256 amount);
/**
* @notice Tops up the contract with the specified amount. Should modify all local variables accordingly.
* @param amount The amount to top up, in wei.
*/

function topup(uint256 amount) external;
}
Loading
Loading