[TOC]
This rule requires that each transfer be explicitly approved by an operator before it can execute. It is an operation rule: it modifies state during the transfer call (consuming one approval count), unlike validation rules which are read-only.
Each approval is tracked by a hash of (from, to, value). The same transfer tuple can be approved multiple times (approval count > 1) to allow repeated transfers between the same parties for the same amount.
Mints (from == address(0)) and burns (to == address(0)) are exempt: they always pass without requiring an approval. created and destroyed follow the same path as transferred and return immediately for those cases.
The sequence below shows the two-phase flow: an operator first approves a (from, to, value) transfer, then the CMTAT token (with this rule configured in its RuleEngine) validates and consumes that approval during the transfer.
Diagram source: doc/img/rule-conditional-transfer-light-flow.puml.
| Constant | Code | Meaning |
|---|---|---|
CODE_TRANSFER_REQUEST_NOT_APPROVED |
46 | No approval exists for this (from, to, value) tuple |
| Role | Description |
|---|---|
DEFAULT_ADMIN_ROLE |
Manages all roles; implicitly holds all roles below |
OPERATOR_ROLE |
May approve or cancel transfer approvals, and call approveAndTransferIfAllowed |
COMPLIANCE_MANAGER_ROLE |
May bind and unbind token contracts (bindToken, unbindToken) |
Token execution (transferred) is restricted to bound token contracts only.
Increments the approval count for the (from, to, value) hash by 1. Restricted to OPERATOR_ROLE. Emits TransferApproved.
Decrements the approval count for the (from, to, value) hash by 1. Reverts if no approval exists. Restricted to OPERATOR_ROLE. Emits TransferApprovalCancelled.
Discards every outstanding approval for the (from, to, value) hash in one call and returns the count that was cleared. Reverts if no approval exists. Restricted to OPERATOR_ROLE. Emits TransferApprovalReset.
Unlike approveTransfer, this deliberately does not require a token to be bound — its main use is discarding approvals that survived an unbindToken (see below), at which point nothing is bound.
Approves the transfer and immediately calls SafeERC20.safeTransferFrom on the bound token, using this rule contract as the spender. Requires from to have previously approved this contract for at least value tokens. Restricted to OPERATOR_ROLE.
Works in both topologies, provided the bindings are set correctly — see Binding: token vs RuleEngine.
Returns the current approval count for the (from, to, value) tuple.
The rule keeps two independent bindings, because they answer two different questions:
| Binding | Answers | Used for |
|---|---|---|
bindToken(token) |
Which ERC-20 does this rule act on? | the token approveAndTransferIfAllowed calls safeTransferFrom on — and, in direct-binding mode, an authorized caller of transferred |
bindRuleEngine(engine) |
Who else may call transferred? |
under the RuleEngine topology the engine, not the token, is the caller of the compliance hooks |
transferred accepts a call from either the bound token or the bound RuleEngine (isTransferExecutor(caller)).
Wire it like this:
// Direct binding (token calls the rule itself)
cmtat.setRuleEngine(address(rule));
rule.bindToken(address(cmtat));
// Behind a RuleEngine (the engine calls the rule)
cmtat.setRuleEngine(address(ruleEngine));
ruleEngine.addRule(rule);
rule.bindToken(address(cmtat)); // the ERC-20 target
rule.bindRuleEngine(address(ruleEngine)); // the authorized callerWhy two bindings? They were originally one slot, which had to be both the ERC-20 target and the authorized caller. In direct mode those coincide, so it worked. Behind a RuleEngine they are different addresses, and a single slot could only hold one: binding the engine broke
approveAndTransferIfAllowed(the engine is not an ERC-20), while binding the token left the engine unauthorized and reverted every transfer and mint. Splitting the roles fixes both. SeeRESULT.mdfinding F-3.
Always bind the token with bindToken — putting the RuleEngine there instead makes getTokenBound() a non-ERC-20 and approveAndTransferIfAllowed will revert.
Binds or unbinds the ERC-20 token. Restricted to COMPLIANCE_MANAGER_ROLE. A second bindToken reverts until the current token is unbound.
⚠️ unbindTokendoes not clearapprovalCounts. Approvals recorded while the previous token was bound remain in storage and become consumable by the next token that is bound. The operator who controls rebinding also controls approvals, so the trust model is preserved — but when migrating to a different token, callresetApprovalfor each affected(from, to, value)before rebinding if the old approvals must not carry over.
Authorises (or revokes) a RuleEngine to call the transfer execution hooks. Restricted to COMPLIANCE_MANAGER_ROLE. Reverts on the zero address, and on a second binding until unbindRuleEngine is called. Emits RuleEngineBound / RuleEngineUnbound.
Like unbindToken, unbindRuleEngine does not clear approvalCounts.
Returns true if caller is the bound token or the bound RuleEngine — i.e. whether it may call transferred.
- The token holder calls the CMTAT
transfer(to, value). - The CMTAT calls
detectTransferRestrictionvia the RuleEngine — returns code 46 if no approval exists. - The operator (off-chain or via contract) calls
approveTransfer(from, to, value). - The token holder retries the transfer.
detectTransferRestrictionreturns 0 (OK). - The CMTAT calls
transferred(from, to, value)which consumes one approval count.
- The operator calls
approveTransfer(from, to, value)(orapproveAndTransferIfAllowedfor immediate execution). - If using
approveAndTransferIfAllowed, the token holder must have previously calledapprove(ruleAddress, value)on the token.
Each call to approveTransfer increments the counter by 1. Each successful transfer via transferred decrements it by 1. This allows the same (from, to, value) tuple to be approved multiple times for repeated transfers.
For transferred(spender, from, to, value), the spender address is ignored. Approval lookup is based solely on (from, to, value).
Multiple approvals for the same (from, to, value) tuple are allowed and stack. This enables scenarios where the same transfer is expected to occur multiple times.
The issuer deploys RuleConditionalTransferLight, grants OPERATOR_ROLE to a compliance officer, and calls bindToken(cmtat) with COMPLIANCE_MANAGER_ROLE. Investor Alice wants to transfer 1,000 tokens to Bob. The compliance officer reviews the request off-chain and calls approveTransfer(alice, bob, 1000). Alice's transfer then succeeds, and the approval is consumed. A subsequent transfer of the same amount requires a new approval.


