KYC-Gated Dividend Distribution Contracts on Redbelly
Tokenized real-world assets such as REITs and bonds must verify that each dividend recipient holds valid KYC at the moment of payment. Snapshot ownership alone is not enough: eligibility must be checked when funds move.
This article describes the Solidity contracts in the Redbelly KYC dividend template. The design separates two questions:
-
Who is owed a dividend? Balance at record date, captured by OpenZeppelin
ERC20SnapshotonRWAToken. -
Can the issuer pay them? KYC status at settlement time, read from Redbelly's on-chain registry via
IKYCRegistry.isAllowed(address).
Architecture
RWAToken.snapshot()
|
v
DividendDistributor.createEpoch(totalPool) -- pulls dividend ERC-20, stores epoch
|
+-- distribute(epoch, recipients[]) (OPERATOR_ROLE)
+-- claim(epoch) (holder)
|
v
_settle(epoch, account)
|
+-- isAllowed(account) == true --> Paid (direct transfer)
+-- isAllowed(account) == false --> Escrowed (claimEscrow after KYC)
|
v
After reclaim window expires --> reclaim(epoch, treasury) sweeps remainder
Three on-chain components:
| Contract | Role |
|---|---|
RWAToken |
ERC-20 security token with snapshot, optional cap, pause, optional transfer KYC hook |
DividendDistributor |
Epochs, pro-rata settlement, escrow, reclaim, admin controls |
IKYCRegistry |
External registry; single view function isAllowed(address)
|
The dividend payment token is a separate ERC-20 (for example a stablecoin). Its address is fixed at DividendDistributor deploy time.
IKYCRegistry
interface IKYCRegistry {
function isAllowed(address account) external view returns (bool);
}
DividendDistributor calls isAllowed inside _settle and claimEscrow. The registry address is set in the constructor and can be updated by admin via setKycRegistry(address) (with a KycRegistryUpdated event). Operational guidance: rotate the registry only between epochs when possible.
RWAToken
RWAToken extends OpenZeppelin ERC20, ERC20Snapshot, ERC20Burnable, Pausable, and AccessControlDefaultAdminRules.
Roles
| Role | Purpose |
|---|---|
DEFAULT_ADMIN_ROLE |
Admin transfer (two-step with delay), KYC registry updates, transfer-hook toggle |
MINTER_ROLE |
Mint new supply |
SNAPSHOT_ROLE |
Call snapshot() (granted to DividendDistributor) |
PAUSER_ROLE |
pause() / unpause() transfers |
The constructor takes an explicit admin address and initialDelay for admin transfer safety. The deploy key does not need to remain admin if roles are wired to a multisig after deploy.
Supply and transfers
-
Cap: Optional
cap_(0 = uncapped).mintreverts withCapExceededif cap would be exceeded. -
Burn: Holders can
burn/burnFromviaERC20Burnable. -
Pause:
_beforeTokenTransferuseswhenNotPaused. -
Optional KYC on transfers:
kycTransfersEnableddefaults tofalse. When enabled, bothfromandto(excluding mint/burn) must passkycRegistry.isAllowed. Dividend compliance does not require this hook; it is an issuer policy option.
DividendDistributor
State
Immutables: token (RWAToken), dividendToken (IERC20).
Configurable: kycRegistry, global reclaimWindow (default 90 days in the template).
Per epoch (Epoch struct):
| Field | Meaning |
|---|---|
snapshotId |
Snapshot taken at epoch creation |
totalPool |
Dividend tokens received (smallest units) |
supplyAt |
RWA total supply at snapshot |
createdAt |
Block timestamp at creation |
distributed |
Amount paid out (direct + escrow claims) |
escrowedTotal |
Amount still in escrow |
reclaimWindow |
Window frozen at creation |
reclaimed |
Whether admin swept remainder |
Mappings: hasClaimed[epoch][account], escrow[epoch][account].
Accounting: accountedDividendBalance tracks total dividend-token obligations across non-reclaimed epochs. Updated on epoch create, pay, escrow claim, and reclaim.
Creating an epoch
uint256 balanceBefore = dividendToken.balanceOf(address(this));
dividendToken.safeTransferFrom(msg.sender, address(this), totalPool);
uint256 received = dividendToken.balanceOf(address(this)) - balanceBefore;
The epoch stores received, not the requested totalPool. Inbound fee-on-transfer tokens therefore cannot undercollateralize the pool.
createEpoch also calls token.snapshot() and reverts with ZeroSupply if supply at snapshot is zero.
Settlement (_settle)
Both distribute and claim call the same internal path:
function _settle(uint256 epoch, address account) internal {
hasClaimed[epoch][account] = true;
uint256 amount = _entitlement(epoch, account);
if (amount == 0) {
emit Skipped(epoch, account, "zero balance");
return;
}
Epoch storage e = epochs[epoch];
if (kycRegistry.isAllowed(account)) {
e.distributed += amount;
accountedDividendBalance -= amount;
dividendToken.safeTransfer(account, amount);
emit Paid(epoch, account, amount);
} else {
escrow[epoch][account] = amount;
e.escrowedTotal += amount;
emit Escrowed(epoch, account, amount);
}
}
Batch behavior: distribute skips addresses already in hasClaimed and emits Skipped(..., "already claimed") instead of reverting. This prevents a holder from DoS-ing a large batch by front-running with claim(). Duplicate addresses in one batch are skipped the same way.
Self-claim: claim reverts with AlreadyClaimed on a second attempt.
Escrow and reclaim
-
claimEscrow(epoch): Holder withdraws escrow after becoming KYC-eligible. Requires non-zero escrow,isAllowed(msg.sender), andblock.timestamp <= createdAt + reclaimWindow. Moves amount fromescrowedTotaltodistributed. -
reclaim(epoch, to): Admin-only, after the per-epoch window closes. SendstotalPool - distributedto treasury (includes unclaimed escrow). Setsreclaimed = true.
Each epoch captures its own reclaimWindow at creation. setReclaimWindow only affects future epochs, so an admin cannot shorten an open epoch's escrow deadline retroactively.
Dividend math
Entitlement for account a in epoch e:
amount = balanceOfAt(a, snapshotId) * totalPool / supplyAt
Floored by integer division, then capped to the epoch's unallocated remainder:
pending = totalPool - distributed - escrowedTotal
amount = min(amount, pending)
Invariant: distributed + escrowedTotal <= totalPool on every path. After full settlement of all holders, tests assert distributed + escrowedTotal == totalPool. Rounding dust may remain until reclaim.
Decimal-agnostic dividend token
The contract never calls decimals(). All pool and payout values are raw ERC-20 smallest units. A 6-decimal stablecoin and an 18-decimal token both work. RWA token decimals and dividend token decimals may differ; pro-rata math uses ratios of raw balances.
Example: 1,000 USDC (6 decimals) as pool size means totalPool = 1_000_000_000.
Recommended: standard non-rebasing ERC-20 dividend tokens.
Inbound fee-on-transfer: supported via balance-delta accounting on deposit.
Avoid: rebasing tokens and outbound fee-on-transfer tokens (outbound fees can desync recorded distributed from tokens actually delivered).
Access control and security
Both contracts use AccessControlDefaultAdminRules (two-step admin transfer with configurable delay).
| Role | Contract | Capabilities |
|---|---|---|
OPERATOR_ROLE |
Distributor |
createEpoch, distribute
|
PAUSER_ROLE |
Both |
pause / unpause
|
DEFAULT_ADMIN_ROLE |
Distributor |
reclaim, setReclaimWindow, setKycRegistry, rescueToken
|
Pause symmetry: whenNotPaused applies to distribute, claim, claimEscrow, and reclaim. Without this, a pauser could block escrow claims until the window expired and then sweep via reclaim.
ReentrancyGuard: on all external mutators that move tokens.
rescueToken(token, amount, to): Admin can sweep stray ERC-20 balances. For the dividend token, only amounts above accountedDividendBalance are rescuable; committed epoch funds cannot be drained.
Custom errors include AlreadyClaimed, NotKycAllowed, EscrowClaimWindowExpired, ReclaimWindowActive, InsufficientRescuableBalance, and others for clear revert reasons.
Gas benchmarks
Per-recipient gas during distribute (verified holders, Solidity 0.8.24, optimizer 200 runs):
| Holders | distribute gas | gas / recipient |
|---|---|---|
| 20 | 1,326,382 | 66,319 |
| 50 | 3,207,027 | 64,141 |
| 100 | 6,342,100 | 63,421 |
| 500 | 31,680,464 | 63,361 |
Cost is dominated by balanceOfAt on the snapshot, isAllowed on the registry, a hasClaimed storage write, and the ERC-20 transfer. Reentrancy guards and pause checks are included in these figures.
Testing
The Hardhat suite includes 34 unit tests with approximately 95% line coverage. Cases cover pro-rata payout, escrow lifecycle, batch skip behavior, front-run resistance, per-epoch reclaim windows, pause interactions, rescueToken accounting, registry updates, and explicit admin wiring.
Repository
MIT-licensed template: contracts, tests, and deployment scripts for Redbelly Network (testnet chain ID 153, mainnet 151).
Stack: Solidity 0.8.24, OpenZeppelin 4.9.6, Hardhat, TypeScript.
Top comments (0)