Smart contracts for Commertize — compliance-gated tokenization of commercial real estate, with Chainlink CCIP cross-chain bridging and a Chainlink CRE oracle for on-chain property valuation.
pnpm add @commertize/token-contractsAddresses, ABIs, and network metadata are resolved at import time from the active deployment (see Networks):
import { ethers } from "ethers";
import {
CONTRACTS,
ABIS,
RPC_URL,
getIdentityRegistryContract,
} from "@commertize/token-contracts";
const provider = new ethers.JsonRpcProvider(RPC_URL);
// Option A: build from the exported address map + ABI
const registry = new ethers.Contract(
CONTRACTS.IdentityRegistry,
ABIS.IdentityRegistry,
provider,
);
// Option B: use a typed helper (reads the address from the active deployment)
const registry2 = getIdentityRegistryContract(provider);The system is four layers on an EVM chain (Arc by default), tied together by the compliance check that every token transfer routes through.
flowchart TD
subgraph Compliance
IR[IdentityRegistry] --> TC[TokenCompliance]
CE[ComplianceEnabled] -.-|_checkCompliance in _update| TC
end
subgraph Tokenization
PF[PropertyFactory] --> PT[PropertyToken]
PT --> BPT[BridgedPropertyToken]
end
subgraph Finance
LE[ListingEscrow]
DV[DividendVault]
end
subgraph CrossChain["Cross-chain / Oracle"]
POOL[CompliantPropertyTokenPool]
SS[IdentitySyncSender] --> SR[IdentitySyncReceiver]
NAV[PropertyNavConsumer]
end
TC --> PT
PF --> LE
PT --> DV
PT --> POOL
SR --> IR
NAV -.NAV feed.-> DV
- IdentityRegistry — role-based registry of KYC-verified investors
(
VERIFIED_ROLE), with per-address country code and identity hash. Admin registers/removes; a separate least-privilegeSYNC_ROLElets a cross-chain receiver mirror KYC state (see Cross-chain). - TokenCompliance — points at an
IdentityRegistryand holds an exemption list for infrastructure addresses (pools, escrows, vaults). - ComplianceEnabled — abstract base whose
_checkCompliance(from, to)is called from the token's_updatehook. Standard transfers require both ends verified-or-exempt; mints require the receiver verified-or-exempt; burns are unrestricted.
- PropertyToken — ERC-20 (+ ERC-2612 Permit) representing fractional
ownership of a property. Compliance-gated on every balance change, with a
snapshot mechanism used for dividend distribution and a
getCCIPAdmin()hook for Chainlink CCT registration. - BridgedPropertyToken — destination-chain variant deployed with zero
supply. Implements the
IBurnMintERC20surface for CCIP pools and uses mint-and-freeze: a bridge mint always delivers (so CCIP's OffRamp sees the exact receiver balance delta), but the tokens are non-transferable until the holder is KYC-verified. - PropertyFactory — deploys
PropertyToken+ListingEscrowpairs and tracks them.
- ListingEscrow — holds property tokens and investor funds during a
time-bound raise; finalizes to the sponsor when the target is met and
distributes tokens, or refunds on a failed raise.
ReentrancyGuard,Pausable, minimum-deposit and hard-cap protection, bounded investor tracking. Shares whose direct transfer fails at finalize park in apendingTokensreserve that admin distribution/recovery paths cannot spend. For raises with very large investor counts,finalize()'s distribution loop can exceed block gas —adminFinalize()+ chunkedadminDistributeTokens()is the escape hatch. - DividendVault — distributes property income (USDC) to holders pro-rata by
snapshot balance, with a configurable protocol fee, batch claims, and recovery
of unclaimed funds after a timeout.
ReentrancyGuard,Pausable. Deposits are restricted to vetted properties (setPropertyValid) and to the property or vault owner. Deposit only after the raise is finalized: a dividend deposited while the escrow still holds unsold supply allocates that share to the escrow, which can never claim it (recoverable only via the one-yearrecoverUnclaimedsweep).
- CompliantPropertyTokenPool — a CCIP
BurnMintTokenPoolthat gates the destination receiver against the localIdentityRegistryinlockOrBurn, before burning. Because identity is mirrored to every chain, an unverified receiver can never be the target of a cross-chain transfer. - IdentitySyncSender / IdentitySyncReceiver — broadcast KYC
register/remove events from the home chain to every configured destination
over CCIP, applied under
SYNC_ROLE, so "verified anywhere ⇒ verified everywhere." This is what makes source-side gating sound. Every message carries a per-user monotonic sequence number; receivers discard anything at or below the last applied sequence, so CCIP's lack of cross-message ordering (or a manually re-executed stale message) can never resurrect a removed identity.
- PropertyNavConsumer — a Chainlink CRE report sink (built on the keystone
ReceiverTemplate). AKeystoneForwardercallsonReportwith a signed property-NAV report; the consumer accepts strictly-newer reports and exposeslatestNav(propertyId)for the rest of the ecosystem to read. cre/nav-workflow/— the CRE workflow scaffold that produces those reports: cron trigger → per-node NAV/appraisal fetch → median consensus → on-chain write. Seecre/README.md. This is a scaffold — no production real-estate oracle is wired to a live data source yet.
| Product | Where | What it does |
|---|---|---|
| CCT (Cross-Chain Token) | BridgedPropertyToken, PropertyToken.getCCIPAdmin() |
Self-serve TokenAdminRegistry registration; IBurnMintERC20 surface |
| CCIP | CompliantPropertyTokenPool, IdentitySync{Sender,Receiver} |
Burn/mint bridging with source-side compliance gating + cross-chain KYC identity sync |
| CRE | src/oracle/, cre/ |
Property-NAV oracle: consensus-aggregated valuation written on-chain |
CCIP addresses (router, chain selector, RMN proxy, LINK, TokenAdminRegistry) are
configured per network in networks.ts.
Arbitrum One is the target home chain for production; Arbitrum Sepolia is its staging mirror. Both Chainlink integrations are code-complete and tested, but they activate in phases:
- Core protocol on Arbitrum — compliance (
IdentityRegistry,TokenCompliance), tokenization (PropertyFactory→PropertyToken), and finance (ListingEscrow,DividendVault) viapnpm deploy:arbitrum-sepolia/deploy:arbitrum-one. USDC is Circle-native on both networks. - Pricing oracles via CRE (current focus). The platform has no on-chain
pricing oracles today; the first Chainlink integration to go live is the
property-NAV oracle: deploy
PropertyNavConsumerwith the chain'sKeystoneForwarder, then run thecre/nav-workflowagainst the Arbitrum target. Downstream pricing (dividends, escrow, dashboards) readslatestNav. - CCIP / CCT cross-chain (next phase). Identity sync
(
IdentitySyncSender/Receiver) and the compliant token pool are wired per lane withscripts/setup-identity-sync.tsandscripts/ccip-register.tsonce a second chain is in play. Not part of the initial Arbitrum rollout.
Topology invariant (bridged lanes). Source-side gating is only sound if
every chain a token bridges to is mint-and-freeze. Satellite chains deploy
BridgedPropertyToken; if a lane will bridge into the home chain, the home
token must also be the role-based BridgedPropertyToken variant (deploy with
zero supply, then mint the initial supply under a temporary MINTER_ROLE) —
a plain PropertyToken cannot mint inbound deliveries and would strand
burned tokens whenever the receiver is unverified. scripts/ccip-register.ts
verifies the role/pool linkage per lane and warns when a token is not
bridge-capable.
- Single compliance chokepoint. Every balance change flows through
PropertyToken._update → _checkCompliance. There is no separate transfer path; approvals/permit set allowance only and never move balances. - Mint-and-freeze on bridged tokens. Delivery can't be blocked without stranding burned tokens, so an unverified bridge recipient holds frozen tokens until KYC completes rather than being rejected.
- Source-side bridge gating. The pool checks the destination receiver before burning, backed by cross-chain identity mirroring.
- Least privilege. KYC mirroring runs under a dedicated
SYNC_ROLEthat can only register/remove identities — not grant roles or move tokens. - Single verification path.
VERIFIED_ROLEcannot be granted, revoked, or renounced directly; it only moves throughregisterIdentity/removeIdentity(or theirSYNC_ROLEmirrors), so country validation and the identity map can never be skipped or left inconsistent. - Ordered identity sync. Register/remove broadcasts embed a per-user monotonic sequence number enforced by every receiver; sync messages are sent with out-of-order execution allowed, so a stuck message can't head-of-line block later removals and a stale replay is a no-op. Each receiver accepts exactly one active source chain (sequence spaces must not mix); registry updates and broadcasts are separate admin calls — pair them operationally so chains don't drift.
- Fault-tolerant distribution.
ListingEscrow.finalize()cannot be bricked by a single non-compliant investor: failed transfers park inpendingTokensfor a laterclaimTokens()pull once the investor is verified again. - Closed dividend recovery.
recoverUnclaimedpermanently closes a distribution, so late claimants cannot draw the recovered amount out of other distributions' funds. Claims themselves are compliance-gated: an unverified holder's share waits until they re-verify. - Reentrancy & pausability.
ListingEscrowandDividendVaultuse OpenZeppelinReentrancyGuardandPausable; all token movements useSafeERC20. - Snapshot-based dividends capture holder balances at distribution time.
Snapshots are taken by the owner or an explicitly authorized snapshotter
(
setSnapshotter) — the vault no longer needs to own the token.
The compliance and cross-chain contracts have undergone an internal security review.
Configured in networks.ts. The active network is selected by
the EVM_NETWORK env var (also VITE_EVM_NETWORK / NEXT_PUBLIC_EVM_NETWORK),
defaulting to arc-testnet.
| Network | Chain ID | Native | Notes |
|---|---|---|---|
arbitrum-one |
42161 | ETH | Production home chain (target); CCIP-configured |
arbitrum-sepolia |
421614 | ETH | Staging for the Arbitrum rollout; CCIP-configured |
arc-testnet |
5042002 | USDC | Current default; CCIP-configured |
ethereum-sepolia |
11155111 | ETH | CCIP destination / oracle target |
localhost |
5042002 | USDC | Local Anvil node (see pnpm test:e2e) |
EVM_NETWORK still defaults to arc-testnet; the default flips to Arbitrum
with the deployment cutover, not before.
USDC is not deployed by this package — it is assumed to already exist on the
target chain and is read from the deployment config or USDC_ADDRESS.
Deployment loading precedence:
DEPLOYMENT_JSONenv var (raw JSON or object) — for CI/CD.- Bundled
deployment.<network>.json(underscored, e.g.deployment.arc_testnet.json). - Fallback to the
networks.tsentry for the selected network.
pnpm install
pnpm compile # hardhat build
pnpm test # hardhat test
pnpm test:e2e # full local deployment validation (needs Foundry's anvil)
pnpm deploy:localhost # deploy to a running local node
pnpm deploy:arc-testnetpnpm test:e2e boots an Anvil chain (--chain-id 5042002, matching the
localhost network — Hardhat's own node can't serve a custom chain id), runs
scripts/deploy.ts in CI mode, then scripts/local-e2e.ts validates the full
lifecycle on it: KYC, factory-deployed token + escrow, compliance/vault wiring,
a native raise through finalize(), and CRE-consumer + identity-sync
deployment. CI runs the same thing on every push/PR
(.github/workflows/e2e.yaml).
Production deploys target Arbitrum One (pnpm deploy:arbitrum-one) per the
deployment strategy.
Note:
test/TestnetValidation.tsself-skips (viaprocess.exit(0)) when nodeployment.default.jsonis present, which ends the wholehardhat testrun early. Run the unit suites explicitly, e.g.hardhat test test/SmokeTest.ts test/CCIPCompliantPool.ts test/IdentitySync.ts test/PropertyNavOracle.ts test/PropertyTokenSnapshot.ts test/ListingEscrowRefund.ts test/ListingEscrowDistribution.ts test/DividendVault.ts.
Config (resolved at import): NETWORK, CHAIN_ID, CURRENCY, RPC_URL,
BLOCK_EXPLORER_URL, CONTRACTS, Deployment, USDC_ADDRESS.
ABIs: ABIS (IdentityRegistry, Compliance, USDC, DividendVault,
PropertyFactory, PropertyToken, ListingEscrow, IdentitySyncSender,
PropertyNavConsumer), plus ListingEscrowAbi and ErrorStringAbi.
Contract helpers. Singletons read their address from the active deployment
and take just a runner: getIdentityRegistryContract, getComplianceContract,
getUSDCContract, getDividendVaultContract, getPropertyFactoryContract.
Per-listing / per-chain contracts take an explicit address:
getPropertyTokenContract(address, runner) (alias getTokenContract),
getEscrowContract(address, runner),
getIdentitySyncSenderContract(address, runner),
getPropertyNavConsumerContract(address, runner).
Full artifacts (ABI + bytecode) for backend deployment are exported as
IdentityRegistryArtifact, TokenComplianceArtifact, IdentitySyncSenderArtifact,
and PropertyNavConsumerArtifact.
MIT, except src/ccip/CompliantPropertyTokenPool.sol (BUSL-1.1). Vendored
Chainlink keystone interfaces under src/oracle/keystone/ are MIT.