ERC-7144: ERC-20 with Transaction Validation Step
ERC-7144 adds a pluggable pre-transfer validation hook to ERC-20. Every transfer and transferFrom calls out to a configurable validator contract that returns true/false for each transfer; false reverts. This is a lighter-weight alternative to the full ERC-3643 (T-REX) framework when you need only a transfer gate (whitelist, blacklist, jurisdiction filter) without identity-claim infrastructure.
Use cases:
- Stablecoin freeze lists — issuer maintains a deny-list contract;
transferfrom frozen addresses reverts. - Whitelist tokens — early-stage protocol restricts holders to approved addresses.
- Per-pair compliance — DeFi pools that only allow swaps between KYC'd addresses.
- Time-locked release — token launches with a transfer freeze that the validator lifts at a scheduled timestamp.
The contract owner can swap the validator contract at runtime (subject to whatever access control the issuer chose), so the compliance policy can evolve without the token contract being upgraded.
Required Interface
interface IERC7144Validator {
/// Return true if the transfer is allowed.
function isTransferValid(
address token,
address from,
address to,
uint256 amount
) external view returns (bool);
}
interface IERC7144 {
event ValidatorUpdated(address indexed validator);
function validator() external view returns (address);
function setValidator(address newValidator) external;
// Inherits ERC-20; transfer/transferFrom call validator first.
}The validator pattern keeps the token contract small and the policy contract isolated; multiple tokens can share one validator (issuer-wide freeze list) or have their own (per-token whitelist).
Neo Equivalent: NEP-17 Transfer Override + Validator Contract
The same shape ports cleanly. The NEP-17 base's Transfer override calls out to a configurable validator contract before mutating balances:
public new static bool Transfer(UInt160 from, UInt160 to, BigInteger amount, object data)
{
var validator = ValidatorAddress();
if (validator is not null)
{
var ok = (bool)Contract.Call(validator, "isTransferValid", CallFlags.ReadOnly,
new object[] { Runtime.ExecutingScriptHash, from, to, amount });
if (!ok) throw new Exception("NEP17:ValidatorRejected");
}
return Nep17Token.Transfer(from, to, amount, data);
}| ERC-7144 (Ethereum) | Neo Equivalent | Notes |
|---|---|---|
IERC7144Validator.isTransferValid(...) | IsTransferValid(token, from, to, amount) view on validator contract | Same shape |
validator() view | Validator() view returning the validator contract hash | |
setValidator(addr) issuer-only | SetValidator(hash) gated on owner witness | |
transfer + transferFrom validated | NEP-17 Transfer override checks before each call | |
ValidatorUpdated event | OnValidatorUpdated notification |
Composition
- ERC-3643 — T-REX framework. ERC-7144 is the lightweight subset; T-REX adds identity registry + multiple compliance modules. Many real deployments use ERC-7144 first, graduate to T-REX when regulatory complexity grows.
- ERC-6093 — custom errors. Validator rejections throw
NEP17:ValidatorRejectedconsistently. - ERC-1271 — smart-contract signatures. Validators that need user-authenticated approvals (e.g. one-shot whitelisting) read signatures via 1271.
Migration Notes
For existing NEP-17 tokens adding ERC-7144:
- Add the validator-address storage slot.
- Add a
SetValidator(hash)owner-gated method. - Add the validator call in your
Transferoverride. - Deploy a separate validator contract implementing
IsTransferValid.
The token contract stays small; all policy lives in the validator. Future policy changes hot-swap the validator hash without redeploying the token.
Validator Examples
Freeze-list validator:
[DisplayName("FreezeList")]
public class FreezeList : SmartContract
{
private const byte Prefix_Frozen = 0x10;
public static bool IsTransferValid(UInt160 token, UInt160 from, UInt160 to, BigInteger amount)
=> !IsFrozen(from) && !IsFrozen(to);
public static bool IsFrozen(UInt160 a)
=> Storage.Get(Storage.CurrentContext, FrozenKey(a)) is not null;
public static void Freeze(UInt160 a, bool frozen)
{
if (!Runtime.CheckWitness(GetAdmin())) throw new Exception("admin only");
if (frozen) Storage.Put (Storage.CurrentContext, FrozenKey(a), 1);
else Storage.Delete(Storage.CurrentContext, FrozenKey(a));
}
private static byte[] FrozenKey(UInt160 a) => new byte[] { Prefix_Frozen }.Concat(a);
private static UInt160 GetAdmin() => (UInt160)"0x0000000000000000000000000000000000000000";
}Time-lock validator:
// TGE_TIMESTAMP is the token-generation-event time as unix milliseconds
// (matches Runtime.Time's unit). For seconds-precision deadlines, divide
// Runtime.Time by 1000 here instead of changing TGE_TIMESTAMP.
private const ulong TGE_TIMESTAMP = 1_700_000_000_000UL; // example: 2023-11-14 in ms
public static bool IsTransferValid(UInt160 token, UInt160 from, UInt160 to, BigInteger amount)
{
if (Runtime.Time < TGE_TIMESTAMP) return false;
return true;
}Whitelist validator:
public static bool IsTransferValid(UInt160 token, UInt160 from, UInt160 to, BigInteger amount)
=> IsAllowed(from) && IsAllowed(to);