DEV Community

Cover image for KYC-Gated Dividend Distribution Contracts on Redbelly
Ankur Ghai
Ankur Ghai

Posted on

KYC-Gated Dividend Distribution Contracts on Redbelly

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:

  1. Who is owed a dividend? Balance at record date, captured by OpenZeppelin ERC20Snapshot on RWAToken.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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). mint reverts with CapExceeded if cap would be exceeded.
  • Burn: Holders can burn / burnFrom via ERC20Burnable.
  • Pause: _beforeTokenTransfer uses whenNotPaused.
  • Optional KYC on transfers: kycTransfersEnabled defaults to false. When enabled, both from and to (excluding mint/burn) must pass kycRegistry.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;
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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), and block.timestamp <= createdAt + reclaimWindow. Moves amount from escrowedTotal to distributed.
  • reclaim(epoch, to): Admin-only, after the per-epoch window closes. Sends totalPool - distributed to treasury (includes unclaimed escrow). Sets reclaimed = 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
Enter fullscreen mode Exit fullscreen mode

Floored by integer division, then capped to the epoch's unallocated remainder:

pending = totalPool - distributed - escrowedTotal
amount = min(amount, pending)
Enter fullscreen mode Exit fullscreen mode

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.

GitHub: https://github.com/ankurghai/kyc-dividend-token

Top comments (0)