This ERC extends the concept introduced in ERC-7291 by enabling ERC-20-compatible tokens to carry multi-condition unlocking constraints, combining temporal, identity, and usage restrictions into a programmable structure. It aims to support controlled disbursement of tokens where funds are only accessible under predefined, auditable, and verifiable conditions.
ERC-7291 introduced purpose-bound money by restricting how and where tokens can be spent. However, many real-world applications require multiple conditions to be satisfied simultaneously before tokens can be used. Examples include:
This proposal generalizes and formalizes such use cases by layering unlocking conditions on top of the ERC-20 standard.
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.
The IPurposeBoundERC20 interface defines the standard for programmable token transfers that are conditional. Each transfer, referred to as a purpose binding, includes:
recipient and amount.UnlockCondition objects, each consisting of a conditionType (like "TIME", "KYC", or "WHITELIST") and conditionData used to evaluate the condition.expiry timestamp after which the transfer can no longer be claimed.The interface provides the following functions:
- bindPurpose(...): Locks a specified amount of tokens to a recipient with defined conditions.
- claim(...): Allows the recipient to claim the tokens once all conditions are fulfilled.
- isUnlocked(...): Returns whether all associated conditions have been satisfied.
This enables flexible, composable transfer mechanisms for various use cases including compliance, grants, payroll, and more.
pragma solidity 0.8.23;
interface IPurposeBoundERC20 {
struct UnlockCondition {
bytes32 conditionType; // e.g., "TIME", "KYC", "WHITELIST"
bytes conditionData; // e.g., timestamp, Merkle root, etc.
}
function bindPurpose(
address recipient,
uint256 amount,
UnlockCondition[] calldata conditions,
uint256 expiry
) external returns (bytes32 bindingId);
function claim(bytes32 bindingId) external;
function isUnlocked(bytes32 bindingId) external view returns (bool);
}
Flexibility: Conditions are modular and extensible.
Composability: Can be integrated into DAOs, payroll, education, and compliance tokens.
Security: Off-chain verification (e.g., KYC) backed by on-chain proofs (e.g., Merkle roots).
pragma solidity 0.8.23;
/// @title Reference Implementation - Purpose-Bound ERC20 with Multi-Condition Unlocking
/// @notice Implements IPurposeBoundERC20 with conditionType mapping to on-chain checkers.
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./IPurposeBoundERC20.sol";
/// @dev Interface for pluggable condition checker contracts
interface IConditionChecker {
function isConditionMet(address recipient, bytes calldata conditionData) external view returns (bool);
}
/// @title PurposeBoundERC20 Implementation
contract PurposeBoundERC20 is ERC20, IPurposeBoundERC20 {
struct StoredBinding {
address recipient;
uint256 amount;
UnlockCondition[] conditions;
bool claimed;
uint256 expiry;
}
/// @dev Maps conditionType → on-chain checker contract
mapping(bytes32 => address) public conditionResolvers;
/// @dev Maps bindingId → locked transfer
mapping(bytes32 => StoredBinding) public boundTransfers;
event PurposeBound(bytes32 indexed bindingId, address indexed from, address indexed to, uint256 amount);
event Claimed(bytes32 indexed bindingId, address indexed recipient);
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {
_mint(msg.sender, 1_000_000 ether); // for demo purposes
}
/// @notice Admin can register or update condition checkers for condition types
function setConditionResolver(bytes32 conditionType, address checker) external {
// For demo purposes: public function. In production: onlyOwner or AccessControl.
conditionResolvers[conditionType] = checker;
}
/// @inheritdoc IPurposeBoundERC20
function bindPurpose(
address recipient,
uint256 amount,
UnlockCondition[] calldata conditions,
uint256 expiry
) external override returns (bytes32 bindingId) {
require(recipient != address(0), "Invalid recipient");
require(amount > 0, "Invalid amount");
bindingId = keccak256(abi.encodePacked(msg.sender, recipient, amount, conditions, expiry, block.timestamp));
StoredBinding storage stored = boundTransfers[bindingId];
require(stored.amount == 0, "Binding exists");
_transfer(msg.sender, address(this), amount);
for (uint i = 0; i < conditions.length; i++) {
stored.conditions.push(conditions[i]);
}
stored.recipient = recipient;
stored.amount = amount;
stored.expiry = expiry;
emit PurposeBound(bindingId, msg.sender, recipient, amount);
}
/// @inheritdoc IPurposeBoundERC20
function isUnlocked(bytes32 bindingId) public view override returns (bool) {
StoredBinding storage binding = boundTransfers[bindingId];
if (binding.claimed) return false;
if (binding.expiry > 0 && block.timestamp > binding.expiry) return false;
for (uint i = 0; i < binding.conditions.length; i++) {
UnlockCondition storage cond = binding.conditions[i];
address checker = conditionResolvers[cond.conditionType];
require(checker != address(0), "Checker not set");
if (!IConditionChecker(checker).isConditionMet(binding.recipient, cond.conditionData)) {
return false;
}
}
return true;
}
/// @inheritdoc IPurposeBoundERC20
function claim(bytes32 bindingId) external override {
StoredBinding storage binding = boundTransfers[bindingId];
require(msg.sender == binding.recipient, "Not recipient");
require(!binding.claimed, "Already claimed");
require(isUnlocked(bindingId), "Conditions not met");
binding.claimed = true;
_transfer(address(this), binding.recipient, binding.amount);
emit Claimed(bindingId, binding.recipient);
}
}
/// @dev Example of Time-Based Condition Checker
contract TimeConditionChecker is IConditionChecker {
function isConditionMet(address, bytes calldata conditionData) external view override returns (bool) {
uint256 unlockTime = abi.decode(conditionData, (uint256));
return block.timestamp >= unlockTime;
}
}
Condition-checking mechanisms (e.g., Merkle roots, timestamps) must be secure against tampering.
The claim() function must ensure atomic verification of all conditions.
Replay attacks must be mitigated using unique binding IDs and expiration fields.
Copyright and related rights waived via CC0.