Technical guide
Tokenised real estate: a technical guide to distributions and oracles
How a tokenised property pays investors on-chain: architecture, a Merkle-based dividend distributor in Solidity, a snapshot script, and a NAV and FX oracle setup, with notes on Solana and the XRP Ledger.
Updated 15 min read
For CTOs, engineers and technical advisers working with property developers. The companion to Real estate tokenization for developers. Last updated: 9 November 2026.
Illustrative, unaudited code. Everything below is a teaching example. It is not LANDHIVE's or Libertum T-Suite's production code, it has not been audited, and it must not hold investor money without an independent security audit and legal review.
In this guide
- The architecture of a tokenised property
- Paying investors: a Merkle-based dividend distributor
- Building a distribution round: the snapshot script
- Oracles: putting a valuation and an exchange rate on-chain
- On Solana, the XRP Ledger and other non-EVM chains
- Security checklist
- How this code was tested
1. The architecture of a tokenised property
A tokenised development is several small systems that each do one job. Keeping them separate means each can be audited, upgraded or replaced on its own.
| Component | Job | Who controls it |
|---|---|---|
| Identity registry | Records which wallets passed KYC (individuals) or KYB (companies) and eligibility checks | Issuer or transfer agent |
| Security token | Represents investors' interest in the SPV; refuses transfers to unverified wallets | Issuer multisig (mint, pause, forced transfer for lost wallets) |
| Distributor | Holds each payout round and lets verified holders claim their share | Issuer multisig funds rounds; holders claim |
| NAV oracle | Publishes the latest valuation per token with a hash of the valuation report | Appointed valuer posts; multisig can override large moves |
| FX feed | Converts a EUR or GBP valuation into USD (or the reverse) | A decentralised oracle network, e.g. Chainlink |
Token standard. For securities, use a permissioned standard. ERC-3643 (T-REX) bundles the identity registry and compliance modules with the token; ERC-1400 is an older family with partitions; a plain ERC-20 with a transfer-restriction hook also works. The distributor below only needs one function from the registry, isVerified(address), which ERC-3643 registries expose.
2. Paying investors: a Merkle-based dividend distributor
Push or pull?
There are two ways to pay token holders:
- Push: the issuer sends each holder their share in a batch. With 20 investors, as in the ROX Brighton pilot, this is the simplest option: a multisig transaction (or a short series of them) with one transfer per holder.
- Pull: the issuer funds a contract once and each holder claims. Gas is paid by the claimant, the cost to the issuer does not grow with the number of holders, and wallets that have lost their KYC status simply cannot claim until they are re-verified.
The contract below is a pull distributor. Each round stores one Merkle root that commits to every holder's entitlement, computed off-chain from balances at a fixed snapshot block. That avoids building snapshots into the token itself, and works with any token standard that emits Transfer events.
How a round works
- Snapshot. Pick a past block (well past finality) and rebuild every holder's balance at that block.
- Split. Divide the payout pro rata, rounding down. Fund the round with the exact sum of the amounts, not the headline payout.
- Commit. Build a Merkle tree of
(holder, amount)and callcreateRoundwith the root, the total, the snapshot block, a claim deadline and the hash of the distribution report. - Claim. Each holder calls
claim(roundId, amount, proof). The contract checks the deadline, that they have not claimed already, that the identity registry still marks them as verified, and the proof. - Sweep. After the deadline, the multisig can move unclaimed funds back to the SPV, according to the offering terms.
The contract
Source: contracts/DividendDistributor.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title DividendDistributor
/// @notice Pays a stablecoin to holders of a permissioned real estate token, round by round.
/// Each round has a Merkle root of (holder, amount) entitlements computed off-chain from
/// a balance snapshot at a fixed block. Holders claim with a proof; only wallets the
/// identity registry marks as verified can claim.
/// @dev ILLUSTRATIVE AND UNAUDITED. Leaves use OpenZeppelin StandardMerkleTree encoding:
/// keccak256(bytes.concat(keccak256(abi.encode(account, amount)))).
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @notice Minimal view of an ERC-3643-style identity registry (KYC/KYB whitelist).
interface IIdentityRegistry {
function isVerified(address account) external view returns (bool);
}
contract DividendDistributor is AccessControl, Pausable, ReentrancyGuard {
using SafeERC20 for IERC20;
bytes32 public constant DISTRIBUTOR_ROLE = keccak256("DISTRIBUTOR_ROLE");
struct Round {
bytes32 merkleRoot; // root of (holder, amount) leaves
uint256 total; // amount funded for this round
uint256 claimed; // amount paid out so far
uint64 snapshotBlock; // block the balances were read at (for audit)
uint64 claimDeadline; // unix time after which claims close
bytes32 reportHash; // hash of the distribution report (for audit)
}
IERC20 public immutable payoutToken; // e.g. a USD or EUR stablecoin
IIdentityRegistry public immutable identityRegistry;
Round[] private _rounds;
mapping(uint256 roundId => mapping(address account => bool)) public hasClaimed;
event RoundCreated(uint256 indexed roundId, bytes32 merkleRoot, uint256 total, uint64 snapshotBlock, uint64 claimDeadline, bytes32 reportHash);
event Claimed(uint256 indexed roundId, address indexed account, uint256 amount);
event Swept(uint256 indexed roundId, address indexed to, uint256 amount);
error InvalidRound();
error ClaimWindowClosed();
error ClaimWindowOpen();
error AlreadyClaimed();
error NotVerified();
error InvalidProof();
error ZeroValue();
constructor(IERC20 payoutToken_, IIdentityRegistry identityRegistry_, address admin) {
if (address(payoutToken_) == address(0) || address(identityRegistry_) == address(0) || admin == address(0)) {
revert ZeroValue();
}
payoutToken = payoutToken_;
identityRegistry = identityRegistry_;
_grantRole(DEFAULT_ADMIN_ROLE, admin); // use a multisig
}
/// @notice Fund a new round. The caller must have approved `total` of the payout token.
/// @param total must equal the sum of all leaf amounts in the tree.
function createRound(
bytes32 merkleRoot,
uint256 total,
uint64 snapshotBlock,
uint64 claimDeadline,
bytes32 reportHash
) external onlyRole(DISTRIBUTOR_ROLE) whenNotPaused returns (uint256 roundId) {
if (merkleRoot == bytes32(0) || total == 0) revert ZeroValue();
if (snapshotBlock >= block.number) revert InvalidRound(); // snapshot must be in the past
if (claimDeadline <= block.timestamp) revert ClaimWindowClosed();
payoutToken.safeTransferFrom(msg.sender, address(this), total);
_rounds.push(Round(merkleRoot, total, 0, snapshotBlock, claimDeadline, reportHash));
roundId = _rounds.length - 1;
emit RoundCreated(roundId, merkleRoot, total, snapshotBlock, claimDeadline, reportHash);
}
/// @notice Claim your entitlement for a round.
function claim(uint256 roundId, uint256 amount, bytes32[] calldata proof) external nonReentrant whenNotPaused {
if (roundId >= _rounds.length) revert InvalidRound();
Round storage r = _rounds[roundId];
if (block.timestamp > r.claimDeadline) revert ClaimWindowClosed();
if (hasClaimed[roundId][msg.sender]) revert AlreadyClaimed();
if (!identityRegistry.isVerified(msg.sender)) revert NotVerified();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount))));
if (!MerkleProof.verifyCalldata(proof, r.merkleRoot, leaf)) revert InvalidProof();
hasClaimed[roundId][msg.sender] = true; // effects before interaction
r.claimed += amount;
payoutToken.safeTransfer(msg.sender, amount);
emit Claimed(roundId, msg.sender, amount);
}
/// @notice After the deadline, move what was not claimed to `to` (e.g. back to the SPV).
function sweepUnclaimed(uint256 roundId, address to) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant {
if (roundId >= _rounds.length) revert InvalidRound();
if (to == address(0)) revert ZeroValue();
Round storage r = _rounds[roundId];
if (block.timestamp <= r.claimDeadline) revert ClaimWindowOpen();
uint256 remaining = r.total - r.claimed;
if (remaining == 0) revert ZeroValue();
r.claimed = r.total;
payoutToken.safeTransfer(to, remaining);
emit Swept(roundId, to, remaining);
}
function round(uint256 roundId) external view returns (Round memory) {
if (roundId >= _rounds.length) revert InvalidRound();
return _rounds[roundId];
}
function roundCount() external view returns (uint256) {
return _rounds.length;
}
function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { _pause(); }
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); }
}
Design notes
- Leaf encoding matches OpenZeppelin's
StandardMerkleTree:keccak256(bytes.concat(keccak256(abi.encode(account, amount)))). Hashing twice prevents a 64-byte internal node from being passed off as a leaf. - KYC at claim time. A holder whose verification has lapsed cannot claim; their entitlement stays in the contract until they are re-verified or the round is swept.
- Lost wallets are handled at the token level (ERC-3643 forced transfer by the issuer), not here. After a recovery, the issuer can pay the old entitlement manually or include it in the next round.
- Roles.
DEFAULT_ADMIN_ROLEandDISTRIBUTOR_ROLEshould be held by a multisig (for example a 2-of-3 Safe), never by a single key. - Stablecoin choice affects decimals (USDC and EURC use 6) and counterparty risk. The ROX pilot paid in DAI; the contract accepts any ERC-20.
3. Building a distribution round: the snapshot script
This TypeScript script (ethers v6 and @openzeppelin/merkle-tree) rebuilds balances from Transfer events, splits the payout and writes the root and every holder's proof.
Source: scripts/build-distribution.ts
/**
* build-distribution.ts
*
* Builds one distribution round for DividendDistributor:
* 1. rebuilds every holder's token balance at a snapshot block from Transfer events,
* 2. splits the payout pro rata (rounding down, so the round is funded with the exact sum),
* 3. writes the Merkle root and each holder's proof.
*
* ILLUSTRATIVE AND UNAUDITED. Usage:
* RPC_URL=... TOKEN=0x... FROM_BLOCK=<token deploy block> SNAPSHOT_BLOCK=<block> \
* PAYOUT=<amount in payout-token base units, e.g. 1250000000 for 1,250 USDC> \
* EXCLUDE=0xIssuerTreasury,0xEscrow npx tsx build-distribution.ts > round.json
*/
import { Contract, JsonRpcProvider, getAddress, ZeroAddress } from 'ethers'
import { StandardMerkleTree } from '@openzeppelin/merkle-tree'
const ERC20_ABI = ['event Transfer(address indexed from, address indexed to, uint256 value)']
/** Balances at `snapshotBlock`, rebuilt from Transfer logs (works for any ERC-20, including ERC-3643). */
export async function balancesAt(provider: JsonRpcProvider, token: string, fromBlock: number, snapshotBlock: number, step = 5_000) {
const c = new Contract(token, ERC20_ABI, provider)
const bal = new Map<string, bigint>()
for (let start = fromBlock; start <= snapshotBlock; start += step) {
const end = Math.min(start + step - 1, snapshotBlock)
const logs = await c.queryFilter(c.filters.Transfer(), start, end)
for (const log of logs) {
const [from, to, value] = (log as any).args as [string, string, bigint]
if (from !== ZeroAddress) bal.set(from, (bal.get(from) ?? 0n) - value)
if (to !== ZeroAddress) bal.set(to, (bal.get(to) ?? 0n) + value)
}
}
return bal
}
/** Pro-rata split, rounded down. Returns entitlements and the exact total to fund. */
export function entitlements(balances: Map<string, bigint>, payout: bigint, exclude: string[] = []) {
const skip = new Set(exclude.map((a) => getAddress(a)))
const holders = [...balances].filter(([a, b]) => b > 0n && !skip.has(getAddress(a)))
const supply = holders.reduce((s, [, b]) => s + b, 0n)
if (supply === 0n) throw new Error('No eligible holders at the snapshot block')
const rows = holders
.map(([a, b]) => [getAddress(a), (payout * b) / supply] as [string, bigint])
.filter(([, amt]) => amt > 0n)
const total = rows.reduce((s, [, amt]) => s + amt, 0n) // <= payout; the dust stays with the issuer
return { rows, total, supply }
}
/** Merkle tree in the leaf format DividendDistributor checks: abi.encode(address, uint256), double-hashed. */
export function buildTree(rows: [string, bigint][]) {
const tree = StandardMerkleTree.of(rows.map(([a, amt]) => [a, amt.toString()]), ['address', 'uint256'])
const claims: Record<string, { amount: string; proof: string[] }> = {}
for (const [i, [a, amt]] of tree.entries()) claims[a as string] = { amount: amt as string, proof: tree.getProof(i) }
return { root: tree.root, claims }
}
async function main() {
const env = (k: string) => { const v = process.env[k]; if (!v) throw new Error(`Set ${k}`); return v }
const provider = new JsonRpcProvider(env('RPC_URL'))
const snapshotBlock = Number(env('SNAPSHOT_BLOCK'))
const balances = await balancesAt(provider, env('TOKEN'), Number(env('FROM_BLOCK')), snapshotBlock)
const { rows, total } = entitlements(balances, BigInt(env('PAYOUT')), (process.env.EXCLUDE ?? '').split(',').filter(Boolean))
const { root, claims } = buildTree(rows)
// Pass `root`, `total` and `snapshotBlock` to DividendDistributor.createRound; publish `claims` to holders.
console.log(JSON.stringify({ snapshotBlock, root, total: total.toString(), holders: rows.length, claims }, null, 2))
}
if (process.argv[1]?.endsWith('build-distribution.ts')) main().catch((e) => { console.error(e); process.exit(1) })
Operational notes
- Use a snapshot block that is final (on Ethereum, at least two epochs old).
- Exclude the issuer's treasury, escrow and any market-maker addresses agreed in the offering terms.
- Publish the claims file to holders (in the investor portal) and keep it with the distribution report; its hash goes into
createRound. - Rounding down leaves a few base units of dust per round with the issuer. Record it in the report.
4. Oracles: putting a valuation and an exchange rate on-chain
Property has no live market price. What can go on-chain is the latest independent valuation, and, where investors and the asset use different currencies, an exchange rate.
A NAV oracle for the SPV
The valuer posts NAV per token with the valuation date and the hash of the signed report. A deviation guard stops a large jump (a typo, or a compromised valuer key) unless the multisig confirms it. The contract exposes Chainlink's AggregatorV3Interface, so tools that read Chainlink feeds can read it too.
Source: contracts/NavOracle.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title NavOracle
/// @notice On-chain record of a property SPV's net asset value (NAV) per token, posted by an
/// appointed valuer. Exposes the Chainlink AggregatorV3 read interface so existing
/// tooling can consume it.
/// @dev ILLUSTRATIVE AND UNAUDITED. A posted value is only as good as the valuation behind it;
/// the report hash links each update to the signed valuation document.
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
interface AggregatorV3Interface {
function decimals() external view returns (uint8);
function description() external view returns (string memory);
function version() external view returns (uint256);
function getRoundData(uint80 roundId)
external view returns (uint80, int256, uint256, uint256, uint80);
function latestRoundData()
external view returns (uint80, int256, uint256, uint256, uint80);
}
contract NavOracle is AccessControl, AggregatorV3Interface {
bytes32 public constant VALUER_ROLE = keccak256("VALUER_ROLE");
struct NavRound {
int256 navPerToken; // in `decimals()` units of the quote currency (e.g. EUR)
uint64 valuationDate; // date of the valuation, unix time
uint64 postedAt; // when it was posted on-chain
bytes32 reportHash; // keccak256 of the valuation report (PDF), for audit
}
uint8 private immutable _decimals;
string private _description;
uint16 public maxDeviationBps; // largest change allowed per update without admin override
NavRound[] private _rounds;
event NavPosted(uint80 indexed roundId, int256 navPerToken, uint64 valuationDate, bytes32 reportHash, bool overridden);
event MaxDeviationSet(uint16 bps);
error NoData();
error InvalidValue();
error DeviationTooLarge(int256 previous, int256 next);
constructor(address admin, uint8 decimals_, string memory description_, uint16 maxDeviationBps_) {
if (admin == address(0) || maxDeviationBps_ > 10_000) revert InvalidValue();
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_decimals = decimals_;
_description = description_;
maxDeviationBps = maxDeviationBps_;
}
/// @notice Valuer posts a new NAV. Rejected if it moves more than `maxDeviationBps`.
function postNav(int256 navPerToken, uint64 valuationDate, bytes32 reportHash) external onlyRole(VALUER_ROLE) {
_post(navPerToken, valuationDate, reportHash, false);
}
/// @notice Admin (multisig) can post a larger move, e.g. after a full revaluation.
function overrideNav(int256 navPerToken, uint64 valuationDate, bytes32 reportHash) external onlyRole(DEFAULT_ADMIN_ROLE) {
_post(navPerToken, valuationDate, reportHash, true);
}
function setMaxDeviation(uint16 bps) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (bps > 10_000) revert InvalidValue();
maxDeviationBps = bps;
emit MaxDeviationSet(bps);
}
function _post(int256 nav, uint64 valuationDate, bytes32 reportHash, bool overridden) internal {
if (nav <= 0 || valuationDate > block.timestamp || reportHash == bytes32(0)) revert InvalidValue();
uint256 n = _rounds.length;
if (n > 0) {
NavRound storage prev = _rounds[n - 1];
if (valuationDate < prev.valuationDate) revert InvalidValue();
if (!overridden) {
int256 diff = nav > prev.navPerToken ? nav - prev.navPerToken : prev.navPerToken - nav;
if (diff * 10_000 > prev.navPerToken * int256(uint256(maxDeviationBps))) {
revert DeviationTooLarge(prev.navPerToken, nav);
}
}
}
_rounds.push(NavRound(nav, valuationDate, uint64(block.timestamp), reportHash));
emit NavPosted(uint80(n + 1), nav, valuationDate, reportHash, overridden);
}
// ---- AggregatorV3Interface (round ids start at 1) ----
function decimals() external view returns (uint8) { return _decimals; }
function description() external view returns (string memory) { return _description; }
function version() external pure returns (uint256) { return 1; }
function getRoundData(uint80 roundId) public view returns (uint80, int256, uint256, uint256, uint80) {
if (roundId == 0 || roundId > _rounds.length) revert NoData();
NavRound storage r = _rounds[roundId - 1];
// startedAt = valuation date, updatedAt = when posted
return (roundId, r.navPerToken, r.valuationDate, r.postedAt, roundId);
}
function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
if (_rounds.length == 0) revert NoData();
return getRoundData(uint80(_rounds.length));
}
function valuationReportHash(uint80 roundId) external view returns (bytes32) {
if (roundId == 0 || roundId > _rounds.length) revert NoData();
return _rounds[roundId - 1].reportHash;
}
}
Converting NAV with a Chainlink FX feed
A consumer contract reads the NAV and a Chainlink EUR/USD Data Feed, refuses stale or invalid answers, and on an L2 checks that the sequencer is up before trusting any price.
Source: contracts/NavFxConsumer.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title NavFxConsumer
/// @notice Reads a EUR-denominated NAV from NavOracle and converts it to USD with a Chainlink
/// EUR/USD Data Feed, refusing stale or invalid data. On an L2, it also checks the
/// Chainlink L2 sequencer uptime feed.
/// @dev ILLUSTRATIVE AND UNAUDITED. Feed addresses differ per network: take them from
/// Chainlink's published feed list for the chain you deploy on.
import {AggregatorV3Interface} from "./NavOracle.sol";
contract NavFxConsumer {
AggregatorV3Interface public immutable nav; // NavOracle (EUR per token)
AggregatorV3Interface public immutable eurUsd; // Chainlink EUR / USD
AggregatorV3Interface public immutable sequencer; // Chainlink L2 sequencer uptime feed, or address(0) on L1
uint256 public immutable navMaxAge; // e.g. 120 days: valuations are periodic
uint256 public immutable fxMaxAge; // e.g. the feed heartbeat plus a margin
uint256 public constant SEQUENCER_GRACE = 3600; // wait an hour after the sequencer restarts
error StaleNav();
error StaleFx();
error InvalidAnswer();
error SequencerDown();
error SequencerGracePeriod();
constructor(AggregatorV3Interface nav_, AggregatorV3Interface eurUsd_, AggregatorV3Interface sequencer_, uint256 navMaxAge_, uint256 fxMaxAge_) {
nav = nav_;
eurUsd = eurUsd_;
sequencer = sequencer_;
navMaxAge = navMaxAge_;
fxMaxAge = fxMaxAge_;
}
/// @return navUsd NAV per token in USD, scaled to 18 decimals.
function navPerTokenUsd() external view returns (uint256 navUsd) {
_checkSequencer();
(, int256 navEur, , uint256 navUpdated, ) = nav.latestRoundData();
if (navEur <= 0) revert InvalidAnswer();
if (block.timestamp - navUpdated > navMaxAge) revert StaleNav();
(uint80 fxRound, int256 fx, , uint256 fxUpdated, uint80 answeredIn) = eurUsd.latestRoundData();
if (fx <= 0 || answeredIn < fxRound) revert InvalidAnswer();
if (block.timestamp - fxUpdated > fxMaxAge) revert StaleFx();
// scale both to 18 decimals, then multiply
uint256 navE18 = _to18(uint256(navEur), nav.decimals());
uint256 fxE18 = _to18(uint256(fx), eurUsd.decimals());
navUsd = (navE18 * fxE18) / 1e18;
}
function _checkSequencer() internal view {
if (address(sequencer) == address(0)) return; // L1: nothing to check
(, int256 answer, uint256 startedAt, , ) = sequencer.latestRoundData();
if (answer != 0) revert SequencerDown(); // 0 = up, 1 = down
if (block.timestamp - startedAt <= SEQUENCER_GRACE) revert SequencerGracePeriod();
}
function _to18(uint256 value, uint8 dec) internal pure returns (uint256) {
if (dec == 18) return value;
if (dec < 18) return value * 10 ** (18 - dec);
return value / 10 ** (dec - 18);
}
}
Notes
- Staleness windows differ. A valuation might be refreshed quarterly; an FX feed updates within its heartbeat (often an hour or a day, depending on the feed). Set each limit separately.
- Feed addresses differ per network. Take them from Chainlink's published feed list for your chain, and check the feed's heartbeat and deviation threshold there.
- Other data. Rental income or occupancy can be brought on-chain with Chainlink Functions (calling a property-management API) or by an authorised reporter, but income is usually better reported off-chain and paid through the distributor.
- Non-EVM chains. On Solana, Pyth and Switchboard provide price feeds; RedStone also supports several chains. Check current feed coverage for your network.
5. On Solana, the XRP Ledger and other non-EVM chains
The same design works on Solana with different building blocks:
- Token: the Token-2022 program, using the transfer hook extension to call a compliance program on every transfer, and permanent delegate for issuer recovery of lost tokens.
- Identity: an on-chain allowlist account per verified wallet, checked by the transfer hook.
- Distribution: a Merkle-distributor program (the same root-and-proof pattern as above), with snapshot balances read from token accounts at a chosen slot.
- Oracles: Pyth or Switchboard feeds.
On the XRP Ledger, much of what the Solidity contracts above do is built into the ledger itself:
- Token: a Multi-Purpose Token (MPT) or an issued token. The issuer can require authorisation before a wallet may hold it (the equivalent of the identity registry check), lock individual balances or the whole issue, and claw tokens back for recovery.
- Identity: the issuer authorises each holder before they can receive the token, which plays the role of the identity registry. Features for recording a wallet's KYC status on the ledger itself are being added; check what is live before relying on them.
- Distribution: there are no general-purpose smart contracts on the main ledger, so payouts are usually pushed from the issuer's account in batches (for example in RLUSD or another issued stablecoin), with the snapshot taken from ledger state at a chosen ledger index. The ledger's escrow feature, extended to issued tokens and MPTs in early 2026, can hold a round's funds until a release date.
- Ethereum-style code: the XRPL EVM sidechain (live since mid-2025, bridged to the main ledger) runs Solidity, so the distributor and oracle above could be deployed there. Confirm oracle coverage on that sidechain first.
- In real estate: the Dubai Land Department's title-deed tokenisation project (Prypco Mint, with Ctrl Alt) records on the XRP Ledger.
LANDHIVE's live offering, Madrid Puerta Ángel, is an ERC-20 on Ethereum mainnet issued through Libertum T-Suite, so the examples here are in Solidity. See the developer guide for a comparison of networks.
6. Security checklist
- Independent smart contract audit before mainnet, covering the token, registry, distributor and oracle together.
- Admin and distributor roles held by a multisig with hardware keys; no single-key admin.
- Pause procedure written down and tested.
- Snapshot block final; snapshot script output reproduced by a second person before funding.
- Round funded with the exact sum of leaf amounts; total checked on-chain after
createRound. - Stablecoin decimals, issuer and blacklisting powers understood (a stablecoin issuer can freeze the distributor's balance).
- Unclaimed-funds policy written into the offering terms, with the claim deadline.
- Lost-wallet recovery handled by the token's forced-transfer function and documented.
- Valuer key separate from admin keys; deviation limit set; every NAV update links to a signed report.
- Oracle staleness limits set per feed; L2 sequencer check enabled on L2s.
- Monitoring and alerts on
RoundCreated,Claimed,Swept,NavPostedand role changes.
7. How this code was tested
The contracts compile without errors or warnings with solc 0.8.24 and OpenZeppelin Contracts 5.0.2. A test suite on a local Ethereum test chain (Ganache) checks:
- verified holders receive their exact entitlement;
- a second claim, an inflated amount, someone else's proof, and a holder whose KYC has lapsed are all rejected;
- claims after the deadline are rejected, and sweeping returns exactly the unclaimed amount, once;
- the NAV oracle accepts a move within the deviation limit, rejects a larger one from the valuer, and accepts it through the admin override;
- the snapshot script's leaves match the contract's leaf hashing, every proof verifies, and the funded total equals the sum of claims.
NavFxConsumer was compiled but not run against a live Chainlink feed. Passing tests are not an audit.
Frequently asked questions
Why not pay dividends inside the token contract? Keeping payouts in a separate contract lets you change how you pay (stablecoin, frequency, push or pull) without touching the security token, which is the harder contract to change once investors hold it.
Do investors pay gas to claim? With a pull distributor, yes. On Ethereum mainnet that can be material for small payouts; for a small investor base, a push payment from the issuer's multisig may be fairer.
Can a NAV oracle give a token a market price? No. It publishes the latest independent valuation. The price any buyer pays on a secondary venue can differ.
Is this the code LANDHIVE uses? No. LANDHIVE's offerings are issued through Libertum T-Suite. This guide explains common patterns for engineers evaluating tokenised real estate.
This guide is published for information only. It is not investment, legal or tax advice, and it is not an offer to sell or a solicitation to buy any security. Capital is at risk and you may get back less than you invest. Code examples are illustrative and unaudited.