ERC-7994 Conditionally Released Purpose-Bound Token (Purpose-Bound ERC-20 with Conditional Unlock)
Final · Tokens · created 2025-07-29
A standard that attaches multiple release conditions, such as time, identity verification, and intended use, to an ERC-20 token transfer, so that the recipient can only claim the tokens once all conditions are met.
Why it is needed
An ordinary ERC-20 token transfer is final once sent. The sender has no way to control when, where, or under what qualifications the recipient uses it. The earlier standard ERC-7291 introduced the concept of purpose-bound money, restricting how and where a token can be used, but many real-world cases need more than a single condition. A scholarship, for example, may require the recipient to simultaneously satisfy three conditions: having completed KYC, being under 25, and being enrolled at a university. A relief organization's grant may need to be usable only on food and medicine after a specific release date, while a payroll token may need to unlock monthly and be spendable only with authorized counterparties such as banks or medical institutions. This standard defines a common structure for layering such multiple conditions on top of ERC-20.
What to implement
function claim(bytes32 bindingId) external;: Called by the recipient to claim locked tokens once all conditions have been met.function isUnlocked(bytes32 bindingId) external view returns (bool);: Returns whether every condition attached to the given binding has been satisfied.
In depth
Purpose Binding and the UnlockCondition Structure
In this standard, a single conditional transfer is called a purpose binding. A binding contains a recipient, an amount, a set of UnlockCondition objects, and an optional expiry timestamp. Once expiry has passed, that transfer can no longer be claimed. UnlockCondition consists of two fields. conditionType is a bytes32 value representing the kind of condition, such as "TIME", "KYC", or "WHITELIST", and conditionData is the bytes data used to evaluate that condition, such as a timestamp or a Merkle root. Rather than fixing condition types as an enum, the combination of bytes32 and bytes allows new condition types to be defined freely.
How bindPurpose Works and bindingId Generation
In the reference implementation, bindPurpose checks that recipient is not the zero address and that amount is greater than zero, then locks the sender's tokens by transferring them to the contract itself via _transfer. The bindingId is computed as keccak256(abi.encodePacked(msg.sender, recipient, amount, conditions, expiry, block.timestamp)), and it reverts if a binding with the same bindingId already exists (stored.amount != 0). It then copies the conditions array into storage, records recipient, amount, and expiry, and emits a PurposeBound event. Because the call timestamp (block.timestamp) is included in the hash, bindings with identical content made at different times will have different IDs.
How isUnlocked Evaluates Conditions
The reference implementation's isUnlocked evaluates in three steps. First, if the binding has already been claimed, it returns false. Next, if expiry is greater than zero and the current block.timestamp has passed expiry, it returns false. Finally, it iterates over every UnlockCondition in the binding, looking up the checker contract registered for that conditionType in the conditionResolvers mapping and calling isConditionMet(recipient, conditionData). If no checker is registered, it reverts with "Checker not set", and if even one condition is not satisfied, it returns false. Only if every condition passes does it return true.
Verification and Payout in claim
claim can only be called by the recipient themselves. The reference implementation checks in sequence that msg.sender is binding.recipient, that it has not already been claimed, and that isUnlocked(bindingId) is true, then sets claimed to true and transfers the tokens held by the contract to recipient via _transfer. Finally, it emits a Claimed event. The security considerations require that claim() verify all conditions atomically, meaning condition verification and payout must happen together within a single transaction, leaving no gap between verification and payout during which state could change.
Pluggable Condition Checkers
Condition-checking logic is not hardcoded into the token contract but delegated to separate contracts implementing the IConditionChecker interface. A checker implements isConditionMet(address recipient, bytes calldata conditionData), returning true or false. An administrator registers or replaces the checker for a given condition type via setConditionResolver(conditionType, checker). In the reference implementation, this function is left public for demonstration purposes, with a comment noting that in production it should be protected by onlyOwner or AccessControl. The example TimeConditionChecker provided decodes conditionData as a uint256 timestamp and checks whether the current time is after it.
Design Rationale: Modularity and On-Chain Proof
The Rationale gives three reasons. First, flexibility: conditions are modular and extensible, so new condition types can be added. Second, composability: it can be integrated into DAO, payroll, education, and compliance tokens. Third, security: it backs off-chain verification such as KYC with on-chain proof such as a Merkle root. In other words, screening that involves personal information is done off-chain, while the result is posted on-chain in a verifiable form to confirm that conditions are met. This standard requires ERC-20 and ERC-7291, and its status is finalized as Final.
Things to watch out for
- Tamper resistance of the condition-checking mechanism: Condition-checking mechanisms such as Merkle roots or timestamps must be resistant to tampering. If the basis for a check is manipulated, tokens could be released even though conditions were not actually met.
- Atomic verification in claim: The claim() function must verify all conditions atomically. It must not check only some conditions before paying out, nor separate verification from payout.
- Preventing replay attacks: Replay attacks that reuse the same binding must be prevented with a unique bindingId and an expiration field.