Skip to content

Commit

Permalink
feat(incentives): continuous graded dutch auction incentive
Browse files Browse the repository at this point in the history
  • Loading branch information
ccashwell committed Mar 21, 2024
1 parent abdcc63 commit 5662271
Show file tree
Hide file tree
Showing 2 changed files with 447 additions and 0 deletions.
145 changes: 145 additions & 0 deletions src/incentives/CGDAIncentive.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

import {LibZip} from "lib/solady/src/utils/LibZip.sol";
import {SafeTransferLib} from "lib/solady/src/utils/SafeTransferLib.sol";

import {BoostError} from "src/shared/BoostError.sol";
import {Budget} from "src/budgets/Budget.sol";

import {Incentive} from "./Incentive.sol";

/// @title Continuous Gradual Dutch Auction Incentive
/// @notice An ERC20 incentive implementation with reward amounts adjusting dynamically based on claim volume.
contract CGDAIncentive is Incentive {
using LibZip for bytes;
using SafeTransferLib for address;

address public asset;

// Incentive parameters
struct CGDAParameters {
uint256 rewardDecay; // Reward reduction per claim
uint256 rewardBoost; // Reward increase per hour without claims
uint256 lastClaimTime;
uint256 currentReward;
}

CGDAParameters public cgdaParams;
uint256 public totalBudget;

constructor() {
_disableInitializers();
}

struct InitPayload {
address asset;
uint256 initialReward;
uint256 rewardDecay;
uint256 rewardBoost;
uint256 totalBudget;
}

/// @notice Initialize the CGDA Incentive
/// @param data_ Initialization parameters.
function initialize(bytes calldata data_) public override initializer {
InitPayload memory init_ = abi.decode(data_.cdDecompress(), (InitPayload));

uint256 available = init_.asset.balanceOf(address(this));
if (available < init_.totalBudget) {
revert BoostError.InsufficientFunds(init_.asset, available, init_.totalBudget);
}

if (
init_.initialReward == 0 || init_.rewardDecay == 0 || init_.rewardBoost == 0
|| init_.totalBudget < init_.initialReward
) revert BoostError.InvalidInitialization();

asset = init_.asset;
cgdaParams = CGDAParameters({
rewardDecay: init_.rewardDecay,
rewardBoost: init_.rewardBoost,
lastClaimTime: block.timestamp,
currentReward: init_.initialReward
});

totalBudget = init_.totalBudget;
_initializeOwner(msg.sender);
}

/// @inheritdoc Incentive
/// @notice Claim the incentive
function claim(bytes calldata data_) external virtual override onlyOwner returns (bool) {
ClaimPayload memory claim_ = abi.decode(data_.cdDecompress(), (ClaimPayload));
if (!_isClaimable(claim_.target)) revert NotClaimable();
claims++;

// Calculate the current reward and update the state
uint256 reward = currentReward();
cgdaParams.lastClaimTime = block.timestamp;
cgdaParams.currentReward =
reward > cgdaParams.rewardDecay ? reward - cgdaParams.rewardDecay : cgdaParams.rewardDecay;

// Transfer the reward to the recipient
asset.safeTransfer(claim_.target, reward);

emit Claimed(claim_.target, abi.encodePacked(asset, claim_.target, reward));
return true;
}

/// @inheritdoc Incentive
function reclaim(bytes calldata data_) external virtual override onlyOwner returns (bool) {
ClaimPayload memory claim_ = abi.decode(data_.cdDecompress(), (ClaimPayload));
(uint256 amount) = abi.decode(claim_.data, (uint256));

// 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
function isClaimable(bytes calldata data_) external view virtual override returns (bool) {
ClaimPayload memory claim_ = abi.decode(data_.cdDecompress(), (ClaimPayload));
return _isClaimable(claim_.target);
}

/// @inheritdoc Incentive
/// @notice Preflight the incentive to determine the budget required for all potential claims, which in this case is the `totalBudget`
/// @param data_ The compressed incentive parameters `(address asset, uint256 initialReward, uint256 rewardDecay, uint256 rewardBoost, uint256 totalBudget)`
/// @return The amount of tokens required
function preflight(bytes calldata data_) external view virtual override returns (bytes memory) {
InitPayload memory init_ = abi.decode(data_.cdDecompress(), (InitPayload));

return LibZip.cdCompress(
abi.encode(
Budget.Transfer({
assetType: Budget.AssetType.ERC20,
asset: init_.asset,
target: address(this),
data: abi.encode(Budget.FungiblePayload({amount: init_.totalBudget}))
})
)
);
}

/// @notice Calculates the current reward based on the time since the last claim.
/// @return The current reward
/// @dev The reward is calculated based on the time since the last claim, the available budget, and the reward parameters. It increases linearly over time in the absence of claims, with each hour adding `rewardBoost` to the current reward, up to the available budget.
/// @dev For example, if there is one claim in the first hour, then no claims for three hours, the claimable reward would be `initialReward - rewardDecay + (rewardBoost * 3)`
function currentReward() public view returns (uint256) {
uint256 timeSinceLastClaim = block.timestamp - cgdaParams.lastClaimTime;
uint256 available = asset.balanceOf(address(this));

// Calculate the current reward based on the time elapsed since the last claim
// on a linear scale, with `1 * rewardBoost` added for each hour without a claim
uint256 projectedReward = cgdaParams.currentReward + (timeSinceLastClaim * cgdaParams.rewardBoost) / 3600;
return projectedReward > available ? available : projectedReward;
}

function _isClaimable(address recipient_) internal view returns (bool) {
uint256 reward = currentReward();
return reward > 0 && asset.balanceOf(address(this)) >= reward && !claimed[recipient_];
}
}
Loading

0 comments on commit 5662271

Please sign in to comment.