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

feat: ERC20RoyaltyEnforcer #33

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion script/DeployCaveatEnforcers.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ import { OwnershipTransferEnforcer } from "../src/enforcers/OwnershipTransferEnf
import { RedeemerEnforcer } from "../src/enforcers/RedeemerEnforcer.sol";
import { TimestampEnforcer } from "../src/enforcers/TimestampEnforcer.sol";
import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol";

import { ERC20RoyaltyEnforcer } from "../src/enforcers/ERC20RoyaltyEnforcer.sol";
/**
* @title DeployCaveatEnforcers
* @notice Deploys the suite of caveat enforcers to be used with the Delegation Framework.
* @dev These contracts are likely already deployed on a testnet or mainnet as many are singletons.
* @dev run the script with:
* forge script script/DeployCaveatEnforcers.s.sol --rpc-url <your_rpc_url> --private-key $PRIVATE_KEY --broadcast
*/

contract DeployCaveatEnforcers is Script {
bytes32 salt;
IEntryPoint entryPoint;
Expand Down Expand Up @@ -127,6 +128,9 @@ contract DeployCaveatEnforcers is Script {
deployedAddress = address(new ValueLteEnforcer{ salt: salt }());
console2.log("ValueLteEnforcer: %s", deployedAddress);

deployedAddress = address(new ERC20RoyaltyEnforcer{ salt: salt }());
console2.log("ERC20RoyaltyEnforcer: %s", deployedAddress);

vm.stopBroadcast();
}
}
181 changes: 181 additions & 0 deletions src/enforcers/ERC20RoyaltyEnforcer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: MIT AND Apache-2.0
pragma solidity 0.8.23;

import { CaveatEnforcer } from "./CaveatEnforcer.sol";
import { ModeCode } from "../utils/Types.sol";
import { ModeLib } from "@erc7579/lib/ModeLib.sol";
import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
* @title ERC20RoyaltyEnforcer
* @notice Enforces royalty payments when redeeming ERC20 token delegations
* @dev When a delegation is redeemed:
* 1. Validates the execution is a token transfer to this enforcer
* 2. Distributes royalties to recipients specified in terms
* 3. Sends remaining tokens to the redeemer
*/
contract ERC20RoyaltyEnforcer is CaveatEnforcer {
////////////////////////////// State //////////////////////////////

/// @notice Maps hash key to lock status
mapping(bytes32 => bool) public isLocked;

/// @notice Maps hash key to delegator's balance before execution
mapping(bytes32 => uint256) public delegatorBalanceCache;

/// @notice Maps hash key to enforcer's balance before execution
mapping(bytes32 => uint256) public enforcerBalanceCache;

////////////////////////////// Types //////////////////////////////

/// @notice Struct for royalty information
struct RoyaltyInfo {
address recipient;
uint256 amount;
}

/// @notice Struct to hold execution details
struct ExecutionDetails {
address token;
address recipient;
uint256 amount;
}

////////////////////////////// Hooks //////////////////////////////

/// @notice Validates and processes the beforeHook logic
/// @dev Validates transfer details and locks execution
/// @param _terms Encoded royalty terms (recipient, amount pairs)
/// @param _mode Execution mode (must be single)
/// @param _executionCallData Encoded execution details
/// @param _delegationHash Hash of the delegation
/// @param _delegator Address of the delegator
function beforeHook(
bytes calldata _terms,
bytes calldata,
ModeCode _mode,
bytes calldata _executionCallData,
bytes32 _delegationHash,
address _delegator,
address _redeemer
)
public
override
onlySingleExecutionMode(_mode)
{
// Validate redeemer is not zero address
require(_redeemer != address(0), "ERC20RoyaltyEnforcer:invalid-redeemer");

// Validate the terms info length is more than zero
RoyaltyInfo[] memory royalties_ = getTermsInfo(_terms);
require(royalties_.length > 0, "ERC20RoyaltyEnforcer:invalid-royalties-length");

// Get execution details
ExecutionDetails memory details = _parseExecution(_executionCallData);

// Validate transfer
require(details.recipient == address(this), "ERC20RoyaltyEnforcer:invalid-recipient");

// Calculate total royalties
uint256 totalRoyalties = _sumRoyalties(_terms);
require(details.amount >= totalRoyalties, "ERC20RoyaltyEnforcer:insufficient-amount");

// Cache balances
bytes32 hashKey = keccak256(abi.encode(_delegator, details.token, _delegationHash));
delegatorBalanceCache[hashKey] = IERC20(details.token).balanceOf(_delegator);
enforcerBalanceCache[hashKey] = IERC20(details.token).balanceOf(address(this));

// Lock execution
require(!isLocked[hashKey], "ERC20RoyaltyEnforcer:enforcer-is-locked");
isLocked[hashKey] = true;
}

/// @notice Processes royalty distribution
/// @dev Distributes royalties and sends remaining tokens to redeemer
function afterHook(
bytes calldata _terms,
bytes calldata,
ModeCode,
bytes calldata _executionCalldata,
bytes32 _delegationHash,
address _delegator,
address _redeemer
)
public
override
{
ExecutionDetails memory details = _parseExecution(_executionCalldata);
require(_redeemer != address(0), "ERC20RoyaltyEnforcer:invalid-redeemer");

// Process royalties
_distributeRoyalties(details.token, _terms);

// Send remaining balance
uint256 remaining = IERC20(details.token).balanceOf(address(this));
if (remaining > 0) {
require(IERC20(details.token).transfer(_redeemer, remaining), "ERC20RoyaltyEnforcer:invalid-transfer");
}

// Unlock
bytes32 hashKey = keccak256(abi.encode(_delegator, details.token, _delegationHash));
isLocked[hashKey] = false;
}

////////////////////////////// Public Methods //////////////////////////////

/// @notice Returns decoded terms info
/// @param _terms Encoded royalty terms
/// @return royalties_ Array of royalty info structs
function getTermsInfo(bytes calldata _terms) public pure returns (RoyaltyInfo[] memory royalties_) {
require(_terms.length % 64 == 0, "ERC20RoyaltyEnforcer:invalid-terms-length");
uint256 count = _terms.length / 64;
royalties_ = new RoyaltyInfo[](count);

for (uint256 i; i < count; ++i) {
(address recipient, uint256 amount) = abi.decode(_terms[(i * 64):((i + 1) * 64)], (address, uint256));
royalties_[i] = RoyaltyInfo({ recipient: recipient, amount: amount });
}
}

////////////////////////////// Internal Methods //////////////////////////////

/// @notice Parses execution calldata into structured data
/// @param _calldata Raw execution calldata
/// @return Structured execution details
function _parseExecution(bytes calldata _calldata) internal pure returns (ExecutionDetails memory) {
(address token, uint256 value, bytes calldata data) = ExecutionLib.decodeSingle(_calldata);
require(value == 0, "ERC20RoyaltyEnforcer:non-zero-value");
require(data.length >= 4, "ERC20RoyaltyEnforcer:invalid-calldata-length");
require(bytes4(data[0:4]) == IERC20.transfer.selector, "ERC20RoyaltyEnforcer:invalid-selector");

(address recipient, uint256 amount) = abi.decode(data[4:], (address, uint256));
return ExecutionDetails({ token: token, recipient: recipient, amount: amount });
}

/// @notice Calculates total royalties from terms
/// @param _terms Encoded royalty terms
/// @return Total royalty amount
function _sumRoyalties(bytes calldata _terms) internal pure returns (uint256) {
require(_terms.length % 64 == 0, "ERC20RoyaltyEnforcer:invalid-terms-length");
uint256 total;
uint256 chunks = _terms.length / 64;

for (uint256 i; i < chunks; ++i) {
(, uint256 amount) = abi.decode(_terms[(i * 64):((i + 1) * 64)], (address, uint256));
total += amount;
}
return total;
}

/// @notice Distributes royalties to recipients
/// @param _token Token address
/// @param _terms Encoded royalty terms
function _distributeRoyalties(address _token, bytes calldata _terms) internal {
uint256 chunks = _terms.length / 64;
for (uint256 i; i < chunks; ++i) {
(address recipient, uint256 amount) = abi.decode(_terms[(i * 64):((i + 1) * 64)], (address, uint256));
require(IERC20(_token).transfer(recipient, amount), "ERC20RoyaltyEnforcer:invalid-transfer");
}
}
}
Loading