Invental/ Writing/ KYC-Gated ERC-20
Engineering — 20 · Jul · 2026 · 9 min read

One function, one compliance chokepoint: KYC-gating an ERC-20 with OpenZeppelin's _update hook.

Programmable compliance doesn't need to be scattered across every function that touches a balance. Here's how we enforced "only verified addresses can ever hold this token" in a single OpenZeppelin v5 override — plus the mint/burn exemptions, the gas cost, and where the pattern stops being enough.

DK
Daniyar K.
Ivan V.
20 · Jul · 2026
KYC-Gating an ERC-20 in One Function: The OpenZeppelin _update Hook Pattern

Most token-gating advice tells you to check permissions "wherever tokens move." That advice is how compliance bugs get shipped. If you enforce a rule in transfer and forget it in transferFrom, or you gate mints but not the internal burn path some other contract calls, you don't have a compliance system — you have a set of checks that happen to agree with each other most of the time. On a real-world-asset tokenization prototype we built, we wanted exactly one place where the question "is this address allowed to hold this token?" gets answered, and we found it in a part of OpenZeppelin's ERC-20 implementation most teams still under-use: the _update hook introduced in v5.

This piece walks through the pattern we shipped — a KYC allowlist enforced at a single internal chokepoint, with explicit exemptions for minting and burning — why it's structurally better than scattering checks across public functions, and where it stops being enough. The contract described here ran on a public testnet only, backing a fictional asset-fractionalization demo. Nothing here is financial or legal advice, and nothing here should go near mainnet without a real audit and a real compliance provider behind it.


§ 01Why scattered checks are a liability, not a feature

The instinct when you need "only verified holders" is to add a require at every entry point that moves balance: transfer, transferFrom, your custom mint, maybe a burn. That works until it doesn't. Any contract you build on top of the token — an escrow, a staking wrapper, a vault — can call the internal minting and burning paths directly. Every one of those call sites is a place your rule can silently not apply, and every future contributor who adds an entry point has to remember to paste the same check in again.

This is the same class of bug as forgetting a nonReentrant modifier on one function out of five — except compliance bugs don't usually revert loudly in a test. They just let an unverified address end up holding a security-like instrument, which is precisely the failure mode a KYC gate exists to prevent. The fix is structural: move the check to the one function every balance change is guaranteed to pass through, so there's no second place to remember. We hold the same line on pushing invariants to a single, unavoidable checkpoint in typed backend code — the on-chain version just happens to be a Solidity function instead of a type signature.

§ 02One chokepoint: what changed with _update

Before OpenZeppelin Contracts v5, that guaranteed chokepoint didn't cleanly exist. ERC-20, ERC-721, and ERC-1155 each exposed separate _beforeTokenTransfer and _afterTokenTransfer hooks, and mint/burn/transfer logic called into them inconsistently across the token standards. OpenZeppelin's own v5 migration notes describe the change plainly: those hooks were removed, and a single internal _update(address from, address to, uint256 value) function now backs every balance mutation — transfers, mints (from == address(0)), and burns (to == address(0)) all funnel through it. Customization now happens by overriding _update once instead of hooking multiple lifecycle events.

That consolidation is the whole trick. You no longer decide where to put the check — there's only one place it can go. Override _update, call super._update at the end so balances, Transfer events, and any other extension your token uses (say, ERC20Votes or ERC20Pausable) still run correctly, and put your gate before that call. Every path that moves a balance — including ones you haven't written yet — inherits the gate for free.

// RWAShareToken.sol — the entire compliance surface lives here
function _update(address from, address to, uint256 value)
    internal override
{
    if (to != address(0) && !kyc.isVerified(to)) {
        revert RecipientNotVerified(to);
    }
    if (from != address(0) && !kyc.isVerified(from)) {
        revert SenderNotVerified(from);
    }
    super._update(from, to, value);
}

Nine lines, and it's the entire compliance surface of the token. No checks in transfer, none in transferFrom, none duplicated in a custom mint function. The registry lookup is a single external call to an owner-managed allowlist — in our prototype, a minimal KYCRegistry with a mapping(address => bool) and a setVerified function gated by onlyOwner. It's a deliberately dumb mock for a real identity provider, and that's fine for a demo; it is not fine for anything holding real value, which we'll come back to.

§ 03The gate, applied: revert reasons that tell you which side failed

One detail worth calling out because it's easy to get wrong: we check to and from with separate custom errors, RecipientNotVerified and SenderNotVerified, rather than one generic NotVerified. During a transfer between two unverified addresses, a single shared error tells a frontend nothing about which party needs to complete onboarding. Two errors, each carrying the offending address as an argument, means a wallet UI can say "the recipient isn't verified yet" instead of a bare revert.

It also matters for testing. In our test suite we assert on the exact selector and the exact address:

// verified sender, unverified recipient -> revert names the recipient
vm.expectRevert(
    abi.encodeWithSelector(RWAShareToken.RecipientNotVerified.selector, carol)
);
shares.transfer(carol, 10);

// verify carol, retry -> succeeds, balances update
kyc.setVerified(carol, true);
shares.transfer(carol, 10);

That specificity costs nothing at the Solidity level — custom errors with arguments are cheap in bytecode size and gas versus require with a string — and it turns an opaque on-chain failure into something a frontend can act on. If you're gating a token, gate it with errors precise enough to build a decent UX around, not just a generic "not allowed."

A compliance rule that lives in one function is a rule you can reason about. A compliance rule scattered across five is a rule you can only hope about.

— internal review notes, RWA prototype

§ 04Mint and burn are not transfers — treat them differently on purpose

The two zero-address checks in the snippet above are doing quiet, important work. In OpenZeppelin's ERC-20, minting is modeled as a transfer from the zero address, and burning as a transfer to the zero address. If you naively required both parties to be KYC-verified on every call to _update, minting would always revert — the zero address never passes an isVerified check — and your token would be permanently unmintable.

The exemption is deliberate and narrow: the zero address is exempt because it isn't a holder, it's the accounting sentinel for supply entering or leaving circulation. Everything else about the gate stays intact — a mint still requires the recipient to be verified (you don't want tokens minted straight into an unverified wallet), and a burn still requires the sender to be verified, consistent with a burn being "this verified holder is giving up balance."

We covered this explicitly in tests, including the case that matters most for a crowdfunding flow: an unverified backer contributes funds, the raise succeeds, and they try to claim their pro-rata shares. The escrow's claim function calls mint, mint calls _update, and it reverts with RecipientNotVerified until that backer completes verification — at which point the exact same claim call succeeds with no code change. The gate isn't a separate step bolted onto the funding flow; it's a property of the token itself, so every current and future contract that touches it inherits the rule automatically.

§ 05What this pattern is — and isn't

Be honest about what an owner-managed boolean allowlist actually buys you. It proves the mechanism: one address, one flag, one chokepoint, and the invariant "only verified addresses can ever hold a balance" holds for every code path, provably, because it's enforced in exactly one function. That's a legitimate thing to build a demo-grade prototype around.

It is not, on its own, a securities-compliance system. A production permissioned-token stack needs a real identity layer, revocable per-jurisdiction rules, and separation between "the token contract" and "the compliance logic" so rules can evolve without redeploying the token. That's the gap ERC-3643 (T-REX) was built to close: it splits the token from an IdentityRegistry (on-chain identity claims, not just a flat allowlist) and a modular Compliance contract that enforces jurisdiction-specific rules independently of the token's own code. ERC-3643's compliance module is pluggable — you add or swap rules (investor caps, lock-ups, jurisdiction restrictions) without touching transfer logic. There's also ERC-1404, a lighter "simple restricted token" standard with a detectTransferRestriction view function so wallets can pre-check a transfer before attempting it — a nice UX primitive, though it doesn't specify an identity layer the way ERC-3643 does.

The token contract shouldn't have to know what "compliant" means this quarter. It should just refuse to move balance to anyone the compliance layer hasn't cleared.
— why modular compliance exists

If you're prototyping the mechanism, our pattern is the right amount of complexity. If you're issuing anything that represents real ownership or securities exposure, graduate to a standard with a real identity registry, a real claims/topics scheme, and a compliance provider who stands behind the ruleset — not a mapping(address => bool) one engineer can flip.

§ 06Gas, upgrades, and what we'd change before this touches real value

On the cost side, the pattern is close to free. A single external view call (isVerified) per party, twice per transfer worst-case — that's the entire overhead versus an ungated ERC-20, dwarfed by the base cost of a transfer itself. Custom errors instead of require-strings help too: no string data to encode, just a selector and an address argument, which keeps deployment bytecode and revert-path gas lower than the older require(condition, "reason") style.

What we'd change before this went near mainnet, in priority order: first, the registry shouldn't be a single owner's mapping. One EOA with setVerified authority is a centralization and key-custody risk that no amount of clever _update logic fixes — that belongs behind a multisig at minimum, a real identity oracle ideally. Second, the token is immutable once deployed; if the compliance rule needs to change shape, not just add or remove addresses, you're redeploying the token — exactly the coupling ERC-3643's split architecture avoids. Third, the registry should support batch verification and revocation; right now every status change is its own transaction, which doesn't scale past a handful of investors.

None of that changes the core lesson: whatever compliance model you land on, put the rule where it's structurally impossible to bypass, not where it's conventionally expected. _update gave us that for free the moment OpenZeppelin consolidated the transfer lifecycle into one function — we just had to use it.


— End of essay. If you're scoping a token-gating or RWA-compliance build, we've shipped this pattern (and can help you graduate past it). Start a project →

title

meta

href
title

meta

href

Building something that needs programmable compliance?

We've shipped KYC-gated tokens, escrow flows, and the ERC-3643 upgrade path. Let's talk about yours.

Book an intro call