Abstract
This ERC defines how an ERC-7943 token delegates its compliance decisions to a separate policy contract.
For every movement of its balances, the token builds a TransferContext. The context is labelled with the authority under which the movement happens, determined from on-chain facts. The token asks the policy for a bytes32 decision before any balance changes. One fixed non-zero value permits; every other value, zero included, rejects.
The same policy answers the token's ERC-7943 canSend, canReceive and canTransfer checks. The token evaluates canTransfer with the same checks as the movements that check predicts. A policy that keeps state observes completed movements through an authenticated callback. The token also calls the policy on every installation, so that it can initialise that state.
Motivation
ERC-7943 standardises what a permissioned token exposes:
- account eligibility (
canSend,canReceive); - a transfer pre-flight (
canTransfer); - frozen balances;
forcedTransfer.
It deliberately leaves open how the token reaches its decisions.
Those decisions, about identity, jurisdiction, lock-ups, holder caps and settlement hours, change more often than a token's accounting. They are written and audited by different teams, and one set of rules often serves many instruments. Tokens therefore keep them in a separate contract, and each implementation invents its own interface to that contract. The result:
- A compliance provider integrates with every token family separately.
- A token cannot replace its compliance logic with another provider's without a custom adapter.
- Auditors must work out, for each implementation, which operations reach the compliance contract and with what information.
- Integrators cannot know whether a token's pre-flight answer is computed the same way as the transfer it predicts.
ERC-3643 defines such a boundary for its own token: a canTransfer(from, to, amount) that returns a bool, plus transferred, created and destroyed callbacks. Its check is told neither who initiated a movement nor under what authority, and it cannot say why it rejects. ERC-7943 also notes that ERC-3643's canTransfer does not perform the send and receive checks ERC-7943 requires.
This ERC standardises the boundary behind ERC-7943. Tokens and policies written independently then interoperate, and when both meet this ERC, the guarantees ERC-7943 asks of a token's checks hold.
Specification
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.
Terms
- Token: a contract that implements one of the ERC-7943 interfaces and
IERC8473. - Policy: a contract that implements
IERC8473Policy. - Movement: one change recorded by the token's base-standard transfer events: each event, and each item of a batch event, is one movement. ERC-7943 calls the operation that causes a movement a transfer. Transfers, mints, burns and forced transfers are movements of any amount, including zero. One operation may cause several movements, for example a batch, or a forced transfer implemented as a burn and a mint. An event with both
fromandtoequal toaddress(0), which ERC-1155 allows to announce a token type, is not a movement. - Holder of a movement: the account whose tokens move (
from). Whenfromis an escrow that the token itself operates, the holder is the account the token records as entitled to the escrowed tokens, such as the controller of an ERC-7540 request. A mint has a holder only when it settles a claim the token records: the holder is then the account the token records as entitled to the claim, such as the controller of an ERC-7540 request. Elsewhere, "holder caps" and "holder counts" use the word in its ordinary sense: any account with a balance. - Directed by the holder: the movement's accounts and amount follow from an authorisation of the holder's that the token can check on-chain. That authorisation may be a call to the token, an allowance or operator approval, a signature the token verifies, or a request the token records. A recorded request directs every destination that it, or the token's documented request lifecycle (fulfilment, claim, rejection, cancellation), names, whoever made the request.
- The token's own checks: the permissioned conditions the token applies to a movement outside its policy: the frozen amount that ERC-7943 defines, a pause if the token has one, and any account-level conditions the token adds to
canSendorcanReceive, such as a frozen wallet. They exclude the base standard's own validations, such as ownership, allowances and approvals, and access control on the functions that cause movements. - Check:
checkSend,checkReceiveorcheckTransfer. A check permits when it returnsTRANSFER_OKand rejects when it returns anything else. - Installation: each time the token sets the policy it consults, including the first time, a policy it consulted before, and the policy it already consults.
Interfaces
/// @dev The only value a check returns to permit. Every other value, including zero, rejects.
bytes32 constant TRANSFER_OK = keccak256('TRANSFER_OK');
/**
* @title IERC8473Policy
* @notice Decides whether an account may send or receive an ERC-7943 token, and whether a movement of it may happen
*/
interface IERC8473Policy is IERC165 {
/*///////////////////////////////////////////////////////////////
ENUMS
//////////////////////////////////////////////////////////////*/
/**
* @notice The authority under which a movement happens. The set is closed
* @param TRANSFER Any other movement between two accounts, typically one the holder directed
* @param EXECUTED A signed holder instruction the token verified, executed by an account the token authorises
* @param FORCED The token's enforcement authority, which does not depend on the holder directing the movement
* @param MINT Issuance under the token's ordinary issuance rules
* @param BURN Destruction the holder directed
*/
enum TransferKind {
TRANSFER,
EXECUTED,
FORCED,
MINT,
BURN
}
/*///////////////////////////////////////////////////////////////
STRUCTS
//////////////////////////////////////////////////////////////*/
/**
* @notice One movement, as the token describes it to its policy
* @param token The token whose balance or ownership changes
* @param from The account whose position decreases, or address(0) when the movement creates the position
* @param to The account whose position increases, or address(0) when the movement destroys the position
* @param tokenId 0 for an ERC-20 token; the token identifier for ERC-721, ERC-1155 and ERC-6909 tokens
* @param amount The quantity moved; 1 for an ERC-721 token
* @param operator The account that called the token to cause the movement
* @param kind The authority under which the movement happens
* @param data Token-defined data. For EXECUTED and FORCED, whatever the token passes. For TRANSFER, MINT and BURN,
* empty or only values the token derives from its own state, never anything the caller supplied
*/
struct TransferContext {
address token;
address from;
address to;
uint256 tokenId;
uint256 amount;
address operator;
TransferKind kind;
bytes data;
}
/*///////////////////////////////////////////////////////////////
LOGIC
//////////////////////////////////////////////////////////////*/
/**
* @notice Records a movement after balances changed, in the same transaction
* @dev Only the token named in the context may call it
* @param _context The context the token passed to `checkTransfer` for this movement
*/
function afterTransfer(
TransferContext calldata _context
) external;
/**
* @notice Initialises the policy for a token that installs it
* @dev Called by the token on every installation, once its `policy()` returns this policy. Only `_token` may call it
* @param _token The token installing the policy
* @param _previousPolicy The policy the token consulted until now; address(0) on the first installation
* @param _data Data from the account that installs the policy, passed on unchanged
*/
function onInstall(
address _token,
address _previousPolicy,
bytes calldata _data
) external;
/**
* @notice Account-level eligibility to receive `_token`; backs the token's ERC-7943 `canReceive`
* @param _token The token
* @param _account The account
* @return _reason TRANSFER_OK to permit, any other value to reject
*/
function checkReceive(
address _token,
address _account
) external view returns (bytes32 _reason);
/**
* @notice Account-level eligibility to send `_token`; backs the token's ERC-7943 `canSend`
* @param _token The token
* @param _account The account
* @return _reason TRANSFER_OK to permit, any other value to reject
*/
function checkSend(
address _token,
address _account
) external view returns (bytes32 _reason);
/**
* @notice Decides one movement
* @dev Called by the token before balances change, and by anyone to simulate
* @param _context The movement
* @return _reason TRANSFER_OK to permit, any other value to reject
*/
function checkTransfer(
TransferContext calldata _context
) external view returns (bytes32 _reason);
/**
* @notice Whether the token must call `afterTransfer` after each movement
* @dev May change with the policy's configuration
* @return _wantsAfter True if the token must call `afterTransfer`
*/
function wantsAfterTransfer() external view returns (bool _wantsAfter);
}
/**
* @title IERC8473
* @notice An ERC-7943 token governed by an external policy
*/
interface IERC8473 is IERC165 {
/*///////////////////////////////////////////////////////////////
EVENTS
//////////////////////////////////////////////////////////////*/
/**
* @notice Emitted on every installation, including the first
* @param _previousPolicy The policy before the change; address(0) on the first installation
* @param _newPolicy The policy after the change
*/
event PolicyChanged(address indexed _previousPolicy, address indexed _newPolicy);
/*///////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
/**
* @notice Recommended error when the policy rejects a movement
* @param _reason The policy's reason code
*/
error ERC8473PolicyRejected(bytes32 _reason);
/*///////////////////////////////////////////////////////////////
VIEW
//////////////////////////////////////////////////////////////*/
/**
* @notice The policy the token consults for every movement and for its ERC-7943 checks
* @return _policy The policy
*/
function policy() external view returns (address _policy);
}TransferKind is ABI-encoded as uint8, and TransferContext as a tuple. The selectors are:
| Function | Signature | Selector |
|---|---|---|
checkSend | checkSend(address,address) | 0xec1cb5d7 |
checkReceive | checkReceive(address,address) | 0x8b203e96 |
checkTransfer | checkTransfer((address,address,address,uint256,uint256,address,uint8,bytes)) | 0x64a1a08e |
wantsAfterTransfer | wantsAfterTransfer() | 0x38bff1a5 |
afterTransfer | afterTransfer((address,address,address,uint256,uint256,address,uint8,bytes)) | 0x2595b359 |
onInstall | onInstall(address,address,bytes) | 0x820203aa |
policy | policy() | 0x0505c8c9 |
The ERC-165 identifiers are:
0x9cb56a99forIERC8473Policy, the XOR of its six selectors;0x0505c8c9forIERC8473.
Decisions and reason codes
TRANSFER_OK is keccak256("TRANSFER_OK"), which is 0xf059ed422c5a003e2d831cdef17f0af416cf22aae18c781d82aaacaa5c77fd3f.
A check permits if and only if it returns TRANSFER_OK. Every other value is a rejection, including bytes32(0).
Policies SHOULD derive each rejection reason as the keccak256 of an upper-case name, for example keccak256("RECEIVER_NOT_ELIGIBLE"). They SHOULD document the reasons they return. Integrators MUST NOT treat an unrecognised value as permission.
Transfer kinds
The kind records the authority under which a movement happens, as determined from on-chain facts. It is not the name of the function or business process that caused the movement. The token MUST give each movement the first kind in this list whose condition holds:
FORCED: the token makes the movement under its enforcement authority. That is the case when any of the following holds:- the movement is an ERC-7943
forcedTransfer, or one of the two movements of aforcedTransferimplemented as a burn and a mint; - it takes tokens from their holder, or settles a claim the token records, without being directed by the holder;
- it goes through a path the token provides so that it can happen even when ERC-7943's account-level checks or the frozen amount would block it. ERC-7943 allows such bypasses for permissioned mints and burns.
- the movement is an ERC-7943
MINT:fromisaddress(0).BURN:toisaddress(0).EXECUTED: the holder directed the movement by a signed instruction the token verified itself, such as an EIP-712 signature, or an ERC-1271 answer from a holder that is a contract. The token carries the instruction out through a function it restricts to accounts it has granted that capability, independently of the instruction. A function that any account may call, or that an account the instruction names may call without that capability (such as the payee), does not qualify. A request the token records is not a signed instruction: carrying it out is neverEXECUTED.TRANSFER: any other movement. These include movements the holder directed:- directly;
- through an allowance or operator approval;
- through a signature that any account, or the account it names, may submit;
- through a request the token records.
This order has two consequences:
- A
forcedTransferisFORCEDwhatever its reason, including a recovery the holder asked for off-chain. - A movement directed by the holder is never
FORCEDunless it goes through an enforcement path, even when a privileged account triggers it. For example, returning escrowed tokens to an account that the request, or its documented lifecycle, names is aTRANSFER.
MINT and BURN are therefore ordinary issuance, subject to the account-level checks, and holder-directed destruction, subject to those checks and the frozen amount. A FORCED movement MUST NOT have both from and to equal to address(0). A movement caused through the base standard's transfer functions (such as transfer, transferFrom, safeTransferFrom and their batch forms) MUST NOT be EXECUTED or FORCED.
The set of kinds is closed. The encodings are TRANSFER = 0, EXECUTED = 1, FORCED = 2, MINT = 3 and BURN = 4. Adding a kind requires a new interface.
Transfer context
tokenMUST be the address of the token whose balance or ownership changes. When the token calls its policy,tokenisaddress(this).fromMUST beaddress(0)forMINTandtoMUST beaddress(0)forBURN.TRANSFERandEXECUTEDmovements have non-zerofromandto.tokenIdMUST be0for an ERC-20 token. For other tokens it MUST be the identifier of the token moved.amountMUST be the quantity moved, and1for an ERC-721 token.operatorMUST be the account that called the token to cause the movement. That ismsg.sender, or the sender resolved by a trusted forwarder as in ERC-2771. It MUST NOT betx.origin. ForEXECUTED,operatoris the executing account. ForFORCED, it is the enforcing account. In the context ofcanTransfer,operatoris as check rule 3 defines it; in anEXECUTEDpre-flight, as check rule 6 does.kindMUST follow the rules above.dataMUST be empty forTRANSFER,MINTandBURN, or hold only values the token derives from its own state, such as the balances offromandtobefore the movement. It MUST NOT hold anything the caller supplied. ForEXECUTEDandFORCED, the token MAY pass other data, for example an instruction reference and deadline, or a reference to a legal order. A token that passes data MUST document its encoding.
Token requirements
Movements
- Before any balance or ownership change of a movement takes effect, the token MUST call
checkTransferon its policy with that movement's context, and MUST revert unless the call returnsTRANSFER_OK. ForTRANSFER,MINTandBURN, the operation MUST NOT have changed the token's balances, ownership, total supply or frozen amounts before this call, except through earlier movements of the same operation. - Between that call and the balance change, the token MUST NOT make any external call other than to its policy.
- The token SHOULD revert with
ERC8473PolicyRejected(reason)when the policy rejects. When the token rejects through one of its own checks, it SHOULD use the more specific error ERC-7943 recommends, such asERC7943InsufficientUnfrozenBalance. - The movement MUST revert if any of the following happens:
checkTransferorwantsAfterTransferreverts;- either returns fewer than 32 bytes of data;
wantsAfterTransferreturns a word other than0or1;- the policy address has no code.
- The token MUST read its policy address once per movement. It MUST use that one address for the movement's
checkTransfer,wantsAfterTransferandafterTransfercalls. - After the movement's balances change and its base-standard transfer event is emitted, the token MUST call
wantsAfterTransferon that policy. If it returnstrue, the token MUST then callafterTransferwith a context identical to the one it passed tocheckTransfer. A revert ofafterTransferMUST revert the movement. - The calls in rule 6 MUST complete before the token makes any call to the movement's recipient, such as an ERC-721 or ERC-1155 receiver hook, and before another movement begins.
- The token MUST call
checkSend,checkReceive,checkTransferandwantsAfterTransferwithSTATICCALL. - The token MUST NOT allow a movement to begin while
afterTransferruns, for example by guarding its balance-changing path against reentrancy. - The token MUST process the items of a batch operation in the order given. For each item it checks, changes balances, emits that item's transfer event and notifies, all before checking the next item. An ERC-1155 token therefore emits one
TransferSingleper item, which ERC-1155 allows. If any item is rejected, the whole batch MUST revert. - When
policy()returnsaddress(0), the token MUST reject every movement. - A
TRANSFER,EXECUTED,MINTorBURNmovement MUST NOT happen unless both the token's own checks and the policy permit it. The token's account-level conditions apply tofromas forcanSend, and totoas forcanReceive. ForTRANSFER,MINTandBURN, the token's own checks MUST NOT depend onmsg.sender,operator,tx.origin, the remaining gas or any other transaction context. Nor may they depend on state the operation changes before the check, other than through earlier movements of the same operation. - For
FORCEDmovements, the token applies its own checks as ERC-7943 specifies forforcedTransferand for permissioned minting and burning. For example,forcedTransferMAY bypass them in single-party permissioned contexts, where it SHOULD still apply those onto, and SHOULD apply them in multi-party ones. If aFORCEDmovement moves frozen tokens, the token MUST first reduce the frozen amount by at least the frozen part of what moves (for a non-fungible token, unfreeze it). It MUST then emitFrozen, before the base standard's transfer event. - The token's views of balances, ownership, total supply and frozen amounts MUST NOT revert while a movement is in progress or while
onInstallruns. A policy may read them duringcheckTransfer,afterTransferandonInstall.
Checks and pre-flights
-
canSend(address(0))MUST returntruewithout calling the policy. For any other account,canSend(account)MUST returntrueif and only ifcheckSend(address(this), account)returnsTRANSFER_OKand the token's own account-level conditions for sending permit. -
canReceive(address(0))MUST returntruewithout calling the policy. For any other account,canReceive(account)MUST returntrueif and only ifcheckReceive(address(this), account)returnsTRANSFER_OKand the token's own account-level conditions for receiving permit. -
canTransferis the pre-flight for theTRANSFER,MINTorBURNmovement its arguments describe:- a
TRANSFERwhenfromandtoare both non-zero; - a
MINTwhenfromis zero; - a
BURNwhentois zero.
It MUST return
trueif and only if both of the following hold:- the token's own checks permit that movement;
checkTransferreturnsTRANSFER_OKfor this context:token:address(this);from,to: the arguments;tokenId: thetokenIdargument, or0for the fungible interface;amount: theamountargument, or1for the non-fungible interface;operator:from, which is zero for a mint;kind: as listed above;data: as the movement would build it for this kind from the token's current state, or empty if the token passes none.
When both
fromandtoare zero,canTransferMUST returnfalse. The zero side of a mint or a burn passes the account checks (check rules 1 and 2), so the account checks of a mint fall ontoalone, and those of a burn onfromalone. - a
-
The token's own checks in
canTransferinclude ERC-7943's unfrozen-amount validation: when a non-zerofromhas a non-zero frozen amount,canTransferreturnsfalsefor anamountabove the unfrozen amount. Balances, ownership of a non-fungible token and allowances stay outside the token's own checks, as ERC-7943 specifies for base-standard validations: with nothing frozen, anamountabove the balance does not fail those checks, although the movement fails. This ERC also leaves access control on the functions that cause movements outside them, such as a role on a mint or burn function. -
canSend,canReceiveandcanTransferMUST NOT revert, as ERC-7943 requires. When they call the policy, they MUST returnfalseif any of the following happens:- the policy call reverts;
- it runs out of gas;
- it returns fewer than 32 bytes of data;
- the policy has no code.
This holds provided the caller leaves the token enough gas to finish after a call that received at most 63/64 of the remaining gas. The token SHOULD copy at most 32 bytes of the policy's return data.
-
A token that offers a pre-flight for
EXECUTEDmovements, for example a view that takes a signed instruction and the executing account, MUST build the context exactly as that account's execution would, includingoperatoranddata. It MUST NOT answer that the movement would succeed when the token's own checks would reject it, or whencheckTransferfor that context reverts, returns fewer than 32 bytes, or returns anything other thanTRANSFER_OK.
These rules, together with the policy requirements below, have the following consequences:
canTransferperforms ERC-7943'scanSendcheck onfromandcanReceivecheck onto, the zero address included.- Given enough gas,
canTransferreturns the decision that theTRANSFER,MINTorBURNmovement with these accounts and amount would receive at the same state, whoever submits it. That movement may be atransfer, atransferFromby a spender, a mint, or a holder-directed burn. The validations and access control in check rule 4 stay outside it, as does a failure ofwantsAfterTransferorafterTransfer, whichcanTransferdoes not call. - For an operation that causes several movements,
canTransferpredicts each one at the state it starts from. Integrators can simulate the operation as a whole.
canTransfer does not predict EXECUTED or FORCED movements, because their decisions may depend on operator and data. A policy MAY treat them more strictly or more leniently than TRANSFER.
Installing a policy
policy()MUST return the policy the token currently consults.- The token MUST emit
PolicyChangedon every installation, including the first, where_previousPolicyisaddress(0). - The token MUST NOT install a policy that does not return
trueforsupportsInterface(0x9cb56a99). Who may install a policy is outside this ERC. - The token MUST return
trueforsupportsInterface(0x0505c8c9)and for the identifier of the ERC-7943 interface it implements. - On every installation, once
policy()returns the new policy, the token MUST callonInstallon it withaddress(this), the policy it consulted until then (address(0)on the first installation), and the data supplied by the account that installs the policy, unchanged. IfonInstallreverts, the installation MUST revert, so the token keeps its previous policy. - The token itself MUST NOT make an installation depend on the outgoing policy: it MUST NOT revert it because a call the token makes to that policy reverts, or because of what that policy returns to the token.
- The token MUST NOT allow a movement or another installation to begin while
onInstallruns. - The token MUST NOT install a policy before its own code is in place, for example from its constructor, because
onInstallmust be able to read it (token rule 14).
Policy requirements
checkSend,checkReceive,checkTransferandwantsAfterTransferMUST NOT modify state. They MUST NOT revert to express a decision. They reject by returning a value other thanTRANSFER_OK.- Their results MAY depend on their arguments, on persistent contract storage, and on the block's timestamp, number and chain identifier. They MUST NOT depend on
msg.sender,tx.origin, the remaining gas or any other transaction context. This deliberately narrows ERC-7943, which allows its checks to depend onmsg.sender. checkSendandcheckReceiveMUST NOT encode transfer-specific or quantitative rules, such as balance checks or amount limits. ERC-7943 requires this ofcanSendandcanReceive.- For every kind except
FORCED,checkTransferMUST NOT returnTRANSFER_OKif either of these holds:fromis non-zero andcheckSend(_context.token, _context.from)does not returnTRANSFER_OK;tois non-zero andcheckReceive(_context.token, _context.to)does not returnTRANSFER_OK.
- For
FORCEDwith a non-zeroto,checkTransferSHOULD NOT returnTRANSFER_OKwhencheckReceive(_context.token, _context.to)does not. This mirrors the receiver check that ERC-7943 recommends for forced transfers and permissioned mints. A policy MAY depart from it where the token documentsFORCEDpaths that ERC-7943 allows to bypasscanReceive. - For
TRANSFER,MINTandBURN, the result ofcheckTransferMUST NOT depend onoperator. It also MUST NOT depend on state that the operation causing the movement changes before callingcheckTransfer, other than through earlier movements of the same operation. Such state includes the asset holdings of an ERC-4626 vault, which a deposit raises before shares are minted. ForEXECUTED, it SHOULD NOT depend on state the operation changes before callingcheckTransfer, such as the record that an instruction was used, because a pre-flight under check rule 6 cannot reproduce it. The policy MAY read:- the token's balances, ownership, total supply and frozen amounts;
- its own storage;
- other contracts' state that no operation of the token changes, such as an identity registry.
checkTransferMUST NOT returnTRANSFER_OKfor akindoutside the enumeration. In Solidity, an out-of-range enum value in acalldatastruct causes a revert whenever the member is read. That includes copying the struct, ABI-encoding it, forwarding it in an external call or emitting it. Left untouched, the struct does not revert. A policy MUST NOT rely on this unless every path that can permit reads_context.kind.- A policy MUST NOT return
TRANSFER_OKif any call it makes in reaching the decision reverts or runs out of gas. - Any account can call the checks with any context. A permitting result authorises nothing. A policy MUST NOT treat a call to a check as evidence that the caller is the token, that a signature was verified, or that a movement took place.
afterTransferMUST revert unlessmsg.senderequals_context.token. A call from one token MUST NOT change state that governs another token's movements. The one exception is a policy that authenticates every token whose movements that state governs, for example through a list of the tokens it serves.- While
afterTransferruns, the token's balances are those after the movement. The policy MAY make view calls to the token. It cannot start another movement (token rule 9).afterTransferMUST NOT enforce a rule on aTRANSFER,MINTorBURNmovement. A rule that can reject one belongs incheckTransfer, becausecanTransferdoes not callafterTransferand ERC-7943 requirescanTransferto returnfalsewhen a permissioned rule would prevent the transfer. It SHOULD NOT enforce a rule on anEXECUTEDmovement, because a pre-flight under check rule 6 does not callafterTransfereither. wantsAfterTransferMAY change its answer as the policy's configuration changes. While it returnsfalse, the policy MUST NOT depend on receivingafterTransfer.- The policy MUST return
trueforsupportsInterface(0x9cb56a99). - The policy SHOULD bound the gas its checks consume. A check that runs out of gas rejects the movement and, given enough gas for the token to finish (check rule 5), makes the token's pre-flight return
false. onInstallMUST revert unlessmsg.senderequals_token. A call from one token MUST NOT change state that governs another token's movements, except as policy requirement 10 allows.- On every call,
onInstallMUST either set all state the policy derives from_token's movements, such as a holder count, or revert. It MUST NOT keep such state from an earlier installation, including an earlier installation on the same token. A policy without such state MAY do nothing else inonInstall. What it accepts in_data, and what it reads on-chain, is up to the policy.
Rationale
A policy behind ERC-7943
A pause stays in the token, and is not an account-level condition: it makes canTransfer return false and leaves canSend and canReceive unchanged. ERC-7943 likewise lists a pausing mechanism among the permissioned checks of canTransfer.
checkSend and checkReceive take the token as a parameter, as TransferContext does. One policy deployment can then serve many tokens, and a simulated call always names the token it is about.
Kinds record the authority
Policies treat movements differently by authority:
- A lock-up binds the holder but not a court order.
- A holder cap binds ordinary transfers and issuance, not enforcement.
An open list of business operations seems more expressive, but it fails twice:
- Names overlap. An escrow release to a recovery account is a release, a recovery and a forced transfer at once.
- Unknown names. Every policy must decide how to treat names it has never seen. If it permits them, an unknown name bypasses its checks. If it rejects them, every new operation breaks every deployed policy.
The ordered rules record a real defect in the implementation this ERC is extracted from. An asynchronous vault returned the escrowed shares of a rejected redemption request to the request's controller, an address chosen when the request was made, and labelled the movement FORCED. Enforcement was exempt from the holder cap and from the pause, so that path delivered shares past both checks. The movement was directed by the request, so it is a TRANSFER.
The kind of the less obvious operations (non-normative):
| Operation | Kind |
|---|---|
| A transfer an agent makes for the holder under an ERC-8226 mandate, directly or through an executor, using the base standard's transfer functions | TRANSFER |
| Returning escrowed shares to an account that the request, or its documented lifecycle, names, when the request is rejected or cancelled | TRANSFER |
| Burning escrowed shares when a redemption request is fulfilled | BURN |
| An authorised executor carries out a holder-signed burn or mint instruction | BURN or MINT |
| Moving escrowed shares to a recovery account because the account entitled to them is no longer eligible | FORCED |
| Minting a fulfilled claim to a recovery account instead of the claimant | FORCED (from zero) |
| Burning tokens without the holder's on-chain direction, or despite failed account checks or frozen tokens | FORCED (to zero) |
EXECUTED is separate from TRANSFER even though the holder directs both. Its movements arrive through a settlement channel the token restricts, and policies commonly apply rules to that channel that they do not apply to a holder's own transfers, such as business-day windows and cut-off times. A signature that any account may submit, or that the account it names may submit without a grant from the token, as in ERC-3009, stays a TRANSFER, so canTransfer can predict it.
MINT and BURN come before EXECUTED to keep one kind for each economic event. A policy's rules on issuance and redemption, such as a holder cap, see a burn as BURN whether the holder or an executor submits it, and canTransfer predicts both. A token that needs a condition on instructed redemptions, such as a dealing cut-off, enforces it in the function that carries out instructions, as it enforces an instruction's deadline, and canTransfer reflects neither. Moving EXECUTED ahead of MINT would not reach signed subscription orders either: a mint has a holder only when it settles a claim the token records, so no holder directs it.
What canTransfer predicts
ERC-7943 requires that canTransfer "MUST return false if any permissioned rule would prevent a given transfer from succeeding". It also requires that public transfers "MUST NOT succeed in cases where canTransfer would return false".
This ERC reads "a given transfer" as the TRANSFER, MINT or BURN movement described by canTransfer's arguments. canTransfer(from, to, amount) receives no instruction, signature, nonce or submitter, so it cannot predict a movement whose outcome depends on them, such as an EXECUTED settlement or a FORCED enforcement action. Neither may go through the base standard's transfer functions.
ERC-7943 itself lets forcedTransfer bypass canTransfer, and lets permissioned mints and burns bypass the account checks or the frozen amount. This ERC labels forced transfers, and mints and burns made through such a bypass, FORCED. The mints and burns that canTransfer predicts are therefore those that cannot use these exceptions.
Under that reading, a policy may close a settlement window for EXECUTED while direct transfers remain possible. It may also disable direct transfers entirely while authorised executors settle instructions.
ERC-7943 counts mints and burns as transfers, and requires canTransfer to perform canSend on from and canReceive on to, but says nothing about a zero address. The zero address is the source of every mint and the sink of every burn, so it passes both account checks, and canTransfer still performs both (check rules 1 to 3).
Passing canReceive does not make the zero address a destination. ERC-721 and ERC-1155 reject a transfer to it. ERC-20 does not, and where a token allows one, the movement is a BURN.
Pre-flight parity
The boundary between a token and its policy is where answers drift: a different code path, a different context, a caller the token cannot know, or state that changes inside the operation before the policy is asked. Check rule 3, token rules 1, 12 and 14, and policy requirements 2 and 6 close these gaps.
TRANSFER, MINT and BURN decisions cannot depend on operator, so the token can answer without knowing whether the holder or a spender will submit. An allowance is the holder's authority, delegated. A token that wants to limit which contracts act for its holders can restrict approve and operator approvals, where that authority is granted. A policy's answer cannot depend on msg.sender either, so anyone who calls it directly gets the answer the token would get.
For the same reason, data carries nothing the caller supplied for TRANSFER, MINT and BURN. canTransfer has no data argument, so a decision that read the data of a safeTransferFrom, or a payload such as travel-rule information, could not be predicted. Values the token derives from its own state, such as the balances before the movement, are different: at the same state, canTransfer builds the same data.
EXECUTED and FORCED decisions may use operator and data. The submitter is part of what the policy is asked to approve, and a pre-flight for those kinds is specific to the token and is told the submitter. In canTransfer's context, operator is from, zero for a mint, so the context depends only on its arguments and the token's state, the same whoever runs the simulation.
ERC-7943 forbids canTransfer from reverting, so a policy that reverts or misbehaves must turn into false. In Solidity, try/catch is not enough for this: it does not catch a failure to decode malformed return data.
Account checks live in the policy
Deriving canSend and canReceive by probing checkTransfer would need an invented counterparty and amount, and a transfer-level rule such as a lock-up would then leak into an account-level answer, so the policy answers them directly. Policy requirement 4 keeps checkTransfer consistent with both, so the token can make one check per movement and still meet ERC-7943's requirement that canTransfer perform them.
A non-zero success value
Zero is what Solidity returns from a function whose named return value is never assigned, and what an uninitialised variable holds. A boundary where zero permits therefore fails open on the most common bugs.
ERC-1271 and ERC-721's receiver hook use a function selector as their magic success value. That would be unsafe here: checkTransfer takes one dynamic argument, so a contract that echoes its calldata returns exactly bytes32(checkTransfer.selector). A string hash cannot be produced by accident that way.
Decisions are bytes32, not bool, because operators and integrators need to know why a movement was rejected. Hashing a name lets every policy define its own reasons without a central registry.
Decisions are returned, not reverted, because a pre-flight cannot tell a revert that expresses a decision from a bug or an out-of-gas condition. For the same reason, a policy may not turn a failed internal call into a permission (policy requirement 8), or a caller could choose a gas limit that makes one rule run out of gas while the policy still permits.
The after-transfer callback
Policies that keep state, such as holder counts, need to observe completed movements. Stateless policies should not pay for that. wantsAfterTransfer says whether the call is needed now. ERC-165 is not a substitute: it says which functions a contract implements, integrators commonly cache its answers, and a composed policy's need for the callback changes with its configuration.
The callback runs before any call to the recipient, so a recipient that starts another movement meets policy state that already includes the first (token rule 7).
Installing a policy
A policy that keeps state derived from movements, such as a holder count, is only correct for one unbroken installation. While another policy is installed, it sees none of the token's movements, and without being told it cannot know that it was replaced. onInstall tells it on every installation, so a policy can rebuild or verify its state at the moment it goes live, or refuse to go live by reverting.
The call is required of every token, not offered as an extension. A policy can only refuse to go live with stale state if every token that may install it makes the call.
Each call initialises again. A guard that skips initialisation for a token the policy has served before, as one-time initialisers do, would keep the state from the earlier installation, which is the case the call exists for. Mappings cannot be cleared, so a policy can key such state by an installation number that onInstall increments.
The token passes _previousPolicy itself. A policy that copies state from its predecessor, for example from the interim policy of a staged migration (see Security Considerations), then reads the contract the token actually consulted, not an address chosen by whoever prepared the installation. Copying still trusts that contract.
This ERC defines no call from the token to the outgoing policy. If the outgoing policy could revert its own removal, a broken or hostile policy could keep itself installed. ERC-7579 names this risk for modules that revert in onUninstall.
The token sets the new policy before it calls onInstall, so the policy can confirm through policy() that it is the one being installed.
Until a policy is installed the token rejects every movement (token rule 11), so a token deployed without one is safe. A token behind a proxy can install from an initializer called after the proxy is deployed, not from one the proxy's constructor runs.
Batches are processed in order
ERC-1155 already requires batch balance changes to occur in array order. Checking, moving and notifying each item before the next makes every item's decision the decision a single movement would get at that point in the batch. Rules on aggregate state, such as holder caps, then stay exact.
OpenZeppelin's ERC1155 applies a whole batch in _update and then emits TransferBatch, so a token built on it overrides _update to apply the items one at a time.
Related work
- Earlier security-token proposals that were never merged, among them the Security Token Standard and the Simple Restricted Token Standard, already returned a code from a transfer check, with a reason or a message. They define the check a token offers to integrators, as ERC-7943 now does, not an interface between a token and a separate policy.
- ERC-7746 defines generic security layers. A layer must decode each protected function's calldata to learn what moves where, and rejects by reverting from a hook that is not a view, so it cannot back a non-reverting pre-flight.
- Rule-composition engines, including the Universal Policy Engine proposal currently under discussion, define how rules combine inside an engine. An engine whose evaluation can run in a
STATICCALLcan implementIERC8473Policyas its entry point.
Backwards Compatibility
This ERC does not change ERC-7943's interface. A token that adopts it keeps that interface, so integrators call it unchanged. This ERC narrows one freedom: the checks may not depend on msg.sender. ERC-7943 says nothing about the zero address, and it does not address transfers an executor settles. The Rationale gives this ERC's choices on both.
An adapter can present an ERC-3643 token's compliance contract and identity registry as an IERC8473Policy:
checkSendpermits when the account's wallet is not frozen on the ERC-3643 token.checkReceivepermits when the account is verified in the identity registry and its wallet is not frozen.checkTransferapplies both account checks toTRANSFERandEXECUTED, then the compliance contract'scanTransfer(from, to, amount). ForMINT, it applies onlycheckReceive, which policy requirement 4 demands, although ERC-3643's own mint ignores frozen wallets. ForFORCEDwith a non-zeroto, it requires only that the receiver is verified, as ERC-3643'sforcedTransferdoes. ERC-3643 has no holder-directed burn: its agent burns areFORCEDwith a zeroto, and the adapter permits them.afterTransfercalls the compliance contract'screatedfor movements from zero,destroyedfor movements to zero, andtransferredotherwise. This works only where the compliance contract accepts the adapter as its caller.onInstall: ERC-3643 defines no call that rebuilds a compliance contract's state. An adapter over one that keeps state can meet policy requirement 16 only by reverting when it cannot tell that the state is current, which in practice limits it to one installation.
The adapter, not the compliance modules, decides the exemptions, so it is a migration path, not an equivalent.
A token that already consults a policy through its own interface can install an adapter that implements its existing hook interface on top of an IERC8473Policy, or migrate to a new token implementation.
Test Cases
Sent to the reference policy with that policy's own token as token, a TransferContext whose kind is encoded as 5 reverts, because the policy reads kind. A kind of 4 (BURN) decodes.
The executable tests in ERC8473.t.sol and ERC8473Executed.t.sol, and the unit tests beside the contracts, run against the reference implementation:
- Fail-closed returns. For a reverting policy, one that returns 31 bytes, or one that runs out of gas,
canTransferreturnsfalsewithout reverting, and the movement reverts. A policy with an unassigned return value, or a contract that echoes its calldata, rejects. - Account checks. The zero address passes
canSendandcanReceive, and a plain transfer to it still reverts. - Fuzzed pre-flight parity. Over eligibility, pause, frozen amounts, holder caps and amounts,
canTransferequals the outcome oftransfer,transferFromby a spender, a mint and a holder burn, except that with nothing frozen an amount above the balance fails in the movement althoughcanTransferis true. - Rejections. A policy rejection reverts with
ERC8473PolicyRejectedand the policy's reason. A frozen amount reverts withERC7943InsufficientUnfrozenBalance, and an amount above the balance with the base standard'sERC20InsufficientBalance. - Installation. If
onInstallreverts, the previous policy stays. A policy reinstalled after another sets its holder count again. - Forced transfers. A forced transfer succeeds while the token is paused and the holder cap is full, and still requires an eligible receiver. It unfreezes only the frozen part of what moves: with 1000 held and 950 frozen, moving 100 leaves 900 frozen.
- Executed movements. The policy sees
EXECUTED, the executor asoperator, and the instruction's reference and deadline indata. A replayed, cancelled, expired, altered or wrongly signed instruction reverts, and so does one that names another executor.canExecuteequals the outcome of the execution, and builds the same context.
Reference Implementation
The assets folder contains:
IERC8473Policy.solandIERC8473.sol: the interfaces above;ExampleToken.sol: an ERC-20 token that implements ERC-7943's fungible interface through this ERC;ExampleExecutorToken.sol:ExampleTokenwith a settlement channel. An account withEXECUTOR_ROLEcarries out a holder's signed EIP-712 instruction as anEXECUTEDmovement, andcanExecuteis its pre-flight. An instruction can name its executor, and the holder can cancel it;ExamplePolicy.sol: a policy that combines an allowlist with a holder cap. It sets its count inonInstallfrom a list of holders it checks againsttotalSupply(), and keeps it throughafterTransfer.
The examples are for illustration and are not audited. Imports use the remappings interfaces/=contracts/interfaces/ and contracts/=contracts/.
_update gives the movements OpenZeppelin's ERC20 causes their kind, and every movement goes through _move:
function _update(
address _from,
address _to,
uint256 _value
) internal override {
IERC8473Policy.TransferKind _kind = _forcedPending ? IERC8473Policy.TransferKind.FORCED : _inferKind(_from, _to);
_forcedPending = false;
_move(_from, _to, _value, _kind, '');
}
function _move(
address _from,
address _to,
uint256 _value,
IERC8473Policy.TransferKind _kind,
bytes memory _data
) internal nonReentrant {
bytes32 _tokenReason = _tokenCheck(_from, _value, _kind);
if (_tokenReason == _PAUSED) revert EnforcedPause();
if (_tokenReason == _INSUFFICIENT_UNFROZEN) {
uint256 _balance = balanceOf(_from);
if (_value > _balance) revert ERC20InsufficientBalance(_from, _balance, _value);
revert ERC7943InsufficientUnfrozenBalance(_from, _value, _unfrozen(_from));
}
IERC8473Policy.TransferContext memory _ctx = _context(_from, _to, _value, _msgSender(), _kind, _data);
IERC8473Policy _activePolicy = IERC8473Policy(_policy); // read once for this movement
// A revert, missing code or short return data reverts here too: the movement fails closed.
bytes32 _reason = _activePolicy.checkTransfer(_ctx);
if (_reason != TRANSFER_OK) revert ERC8473PolicyRejected(_reason);
super._update(_from, _to, _value);
if (_activePolicy.wantsAfterTransfer()) _activePolicy.afterTransfer(_ctx);
}Its pre-flights call the policy without reverting, and permit only on a full word equal to TRANSFER_OK:
function _permits(
bytes memory _payload
) internal view returns (bool _permitted) {
address _activePolicy = _policy;
bytes32 _ok = TRANSFER_OK;
assembly ('memory-safe') {
let _success := staticcall(gas(), _activePolicy, add(_payload, 0x20), mload(_payload), 0x00, 0x00)
if and(_success, gt(returndatasize(), 0x1f)) {
returndatacopy(0x00, 0x00, 0x20)
_permitted := eq(mload(0x00), _ok)
}
}
}Security Considerations
Mislabelled kinds
The kind is the token's statement about authority, and policies have to trust it. A holder-directed movement labelled FORCED skips the rules policies apply to holders, and usually the token's own pause. An enforcement action labelled TRANSFER meets rules it was meant to override. Implementers should review every path that changes a balance against the kind rules, especially paths that move tokens through escrow.
Enforcement authority
FORCED movements are usually exempt from most rules. Access to them is therefore as sensitive as ERC-7943 describes for forcedTransfer. Multisignature control and timelocks are recommended.
Signed instructions
This ERC does not define the instruction behind an EXECUTED movement. A token that offers one decides how it protects the signature:
- One use. Without a nonce, or a reference the token records as used, an executor can repeat a holder's instruction until the balance runs out. Because a rejected movement reverts, the record is undone with it, and the instruction stays usable until its deadline.
- Binding. The signature should cover every value the movement takes from it: the accounts, the amount, and anything passed in
data. Where a policy treats executors differently, the signature should also coveroperator, or any executor can carry out the instruction first under its own rules. An EIP-712 domain that names the chain and the token stops the signature being used elsewhere. - Expiry and cancelling. The token enforces a deadline itself. An instruction a policy refused can still settle once the reason has gone, such as when a window opens or a receiver becomes eligible, at any time before the deadline. A contract holder can stop that by changing its ERC-1271 answer. An account without code cannot, unless the token offers a way to cancel, and a cancel can still lose a race with an execution already submitted.
- Contract holders. A contract holder's ERC-1271 answer can change between a pre-flight and the execution.
- Who executes. The capability extends to whoever can make the granted account call the token. Granting it to a contract that anyone can call opens the function in practice, which kind rule 4 excludes, and the token cannot detect this.
Which policy is installed
The policy decides every movement, so installing or replacing it is equivalent to rewriting the token's rules. Tokens SHOULD install only policies whose code has been reviewed. They SHOULD enforce that at installation by comparing the policy's EXTCODEHASH with a code hash attested after review, for example in a registry maintained by a security council. Attesting code hashes rather than addresses lets one review cover every deployment of the same code. Immutables, such as the reference policy's TOKEN, make the code differ per deployment.
A code-hash check only means something for code that cannot change:
- Proxies. A proxy keeps its code hash while its implementation changes, so a policy installed this way SHOULD NOT be a proxy.
- Delegated decisions. A policy that delegates decisions to other contracts, such as rules, SHOULD apply the same check to each of them when it adds them.
- Dependencies. The contracts a policy reads, such as identity registries or oracles, are outside the check and need their own trust assessment.
Attestations are checked only at installation, because a revocation that disabled a running policy could silently remove checks or block every transfer.
Failing closed
A movement reverts, and a pre-flight that calls the policy returns false, when the policy is missing or has no code, when a call the token makes to it fails, or when a check rejects (token rules 1, 4, 6 and 11, check rules 1 to 3 and 5).
Pre-flights are snapshots
A permitting pre-flight reflects the state when it ran. Eligibility, frozen amounts, holder counts, the policy's configuration and the block time can all change before the movement executes, and the movement is decided again at that point.
Escrow and entitled accounts
An escrow contract receives and sends tokens like any other account, so it must pass checkReceive and checkSend itself. The context shows the escrow as from or to, not the account entitled to the tokens. Only the kind follows the holder the token records. This ERC defines no field for the entitled account, so a policy cannot rely on being told it.
An escrow that must check the entitled account, for example before paying out a claim, has to do so itself, for instance by calling the token's canTransfer or canReceive for that account.
Callbacks, state and reentrancy
The token's guard stops afterTransfer and onInstall from starting a movement, and onInstall from starting another installation (token rule 9, installation rule 7).
A policy whose wantsAfterTransfer changes to true must initialise its state, and no installation tells it, so it does so when its configuration changes. A policy that takes state from _data without checking it trusts whoever installs it, who need not be the policy's own administrator.
Reinstalling a policy is an installation. The same holds for one token of a policy shared by several tokens, when that token switches away and back. Because onInstall runs inside the installation, nothing can happen between initialising and installing. A gap remains between reading the state and the moment the installation executes. When the state was computed off-chain and passed in _data, a governance action carries fixed calldata, so a value read when it was proposed may be stale when it runs. That gap closes only if:
- the state lives in a contract that stays in place across policy replacements;
onInstallreads or verifies the state on-chain, for example by copying it from_previousPolicy, or, for state derived from balances, by checking a sorted list of holders whose balances add up tototalSupply();- the token is paused from the snapshot until the installation, remembering that a pause may not stop
FORCEDmovements; or - the token exposes a count of its movements, and the installation reverts if the count changed since the snapshot. Such a counter is an optional extension, not part of this ERC.
A list of holders checked against totalSupply() must be strictly ascending, so that no account is counted twice. Only its non-zero balances count, so the list may name accounts that hold nothing, including holders that have emptied their balance since the list was made.
A check against totalSupply() rejects stale state, but it does not make the installation complete. If an account outside the list can become a holder while a timelocked installation is pending, a transfer to it makes the installation revert, and whoever can make that transfer can repeat it on every attempt. A movement counter is stricter still: any movement makes it revert. The installation completes reliably only if the set of possible holders is closed while it is pending, for example:
- the list names every account the outgoing policy would let receive, and no account is made eligible until the installation executes. This needs eligibility that can be enumerated, such as an allowlist, and is limited by how many entries fit in one transaction: about 4,000 for the reference policy, whose NatSpec gives the measured figures;
- a first installation puts in an interim policy that admits a receiver without a balance only by
FORCED, while still applying the token's eligibility rules, so ordinary movements cannot create holders. This costs one more installation. The second installation then takes the state from the current holders, either:- from a list checked in one transaction, as above. A
FORCEDmovement to a new account before it executes makes it revert, so enforcement that moves tokens to new accounts waits until it has run, or the list is prepared again; or - from a count the interim policy builds itself, over as many transactions as needed. Anyone may register an account once, while it holds a balance, which adds the balance to a running sum and one to the count. In
afterTransferthe interim policy keeps the sum and the count current for registered accounts, and ignores movements of zero and movements from an account to itself: otherwise a self-transfer of a whole balance would raise the count, and a zero transfer from an emptied account would lower it, while the sum stays the same. That includes a registered account that emptied and is then refilled byFORCED: its flag stays set, so it cannot register again, and the count rises by one when its balance after the move equals the amount it received. An account that is not registered and that aFORCEDmovement leaves with a balance is registered in the sameafterTransfer, with its balance after the move added to the sum and one to the count. Once the sum equalstotalSupply(), no unregistered account holds tokens and the count is exact. The second installation'sonInstallcopies the count from the interim policy, which it receives as_previousPolicy, and reverts unless the sum still equalstotalSupply()in that transaction. This has no limit on the number of holders. Once every holder is registered, ordinary movements cannot create a holder and aFORCEDmovement registers the one it creates, so the sum stays equal tototalSupply()and the installation completes whenever it executes;
- from a list checked in one transaction, as above. A
- the state lives in a contract that stays in place across policy replacements, so no list is needed.
Copying the state from the outgoing policy completes in one step, but only when the outgoing policy is trusted. In the staged way above, the outgoing policy at the second installation is the interim one, whose sum the second installation's onInstall checks against totalSupply().
Balance changes that are not movements
The policy sees only movements: changes recorded by the base standard's transfer events. Balance changes that emit no transfer event, such as rebasing or interest accrual, are never checked.
Gas
A broken or hostile policy can return a large amount of data to make a pre-flight run out of gas. Copying at most one word of return data prevents this.
Data
A policy that reads data depends on the encoding of the token it serves. Nothing in the context identifies that encoding, and abi.decode ignores extra words, so a policy shared by tokens with different encodings can read one value as another without reverting. It should fix the encoding per token.
Copyright
Copyright and related rights waived via CC0.
