Contracts
The platform is five contract types written in Solidity (Foundry) and deployed on Base. Each has one clear responsibility. The factory deploys three of them as a set per property. All are publicly verified on the explorer.
KYCRegistry — one per platform
The single source of truth for investor eligibility.
- Purpose: stores on-chain attestations confirming a wallet passed verification; read by every
PropertyFundingbefore an investment. - Stores (per wallet): verified flag, investor-type flags, country code, expiry, status. The
three type flags map to exemptions:
accreditedInvestor→ Reg D 506(c) (US accredited)nonAccreditedUS→ Reg D 506(b) (US non-accredited)regSEligible→ Reg S (non-US)
- Writes (
ATTESTER_ROLE, the backend):issueAttestation,revokeAttestation,updateExpiry. Country gating:allowCountry,restrictCountry. - Views (anyone):
isVerified,isEligibleInvestor,isAccredited,isNonAccreditedUS,isRegSEligible,getAttestation,isCountryRestricted, plus on-chain metadata history (pushMetadataUpdate/latestMetadata/getMetadataHistory). - Protections: cannot issue over an active attestation (revoke first); cannot issue with a
past expiry; cannot revoke a non-existent attestation; a compromised attester key can be
rotated by the admin Safe revoking
ATTESTER_ROLE.Pausable.
PropertyFundingFactory — one per platform
Creates and locks down a property’s contract set.
- Purpose: deploys
PropertyToken,PropertyFunding, andROIDistributorfor a new property in one transaction. createProject(...)(Platform admin only):- Validates
spvAdmin != spvTreasury. - Validates limit logic (
minInvestment ≤ maxAccredited,minInvestment ≤ maxNonAccreditedUS). - Validates
deadline ≤ now + 180 days(MAX_FUNDRAISING_DURATION). - Deploys the three contracts.
- Grants
MINTER_ROLE+BURNER_ROLEtoPropertyFunding, andBURNER_ROLEtoROIDistributor, on the token. - Revokes
DEFAULT_ADMIN_ROLEfrom everyone — includingspvAdminand the factory itself. Token roles are frozen forever.
- Validates
- Note: the child
PropertyFundingis deployed via an external library (PropertyFundingDeployer, delegatecall) so the factory stays under the EIP-170 contract-size limit while remaining the deployer.
PropertyToken (ERC-20) — one per property
On-chain proof of an investment.
- Purpose: minted to the investor on
invest(); visible in their wallet; burned onclaimRefund()or ROIclaim(). - Non-transferable: wallet-to-wallet
transfer/transferFromare permanently disabled at the contract level. Mint and burn are not transfers and are unrestricted (for the authorized roles). - Frozen roles: after the factory finishes, no one holds
DEFAULT_ADMIN_ROLE;MINTER_ROLE/BURNER_ROLEbelong only toPropertyFundingandROIDistributorand can never be changed. - Units: 1 token (
1e18) = 1 USDC (1e6) — a1e12scaling factor. Total supply equals total USDC raised.
PropertyFunding — one per property
Raises and custodies investor USDC, and runs the state machine.
- Immutable params (set at deploy): funding goal, minimum investment, deadline (≤ 180 days),
spvAdmin,spvTreasury,KYCRegistry,PropertyToken, per-type investor limits, property metadata (IPFS) hash. invest()checks: valid KYC attestation (not expired/revoked), investor type allowed, amount ≥ minimum, cumulative ≤ per-type cap, investor count <MAX_INVESTORS(2000), stillFUNDRAISING, deadline not passed. On success: pulls USDC, mintsPropertyToken, emits an event.withdrawFunds()(spvAdmin, only inFUNDED): sends exactlytotalRaisedto the hardcodedspvTreasury; state →WITHDRAWN.triggerRefund()(anyone): activatesREFUNDINGif the deadline passed without the goal, or ifFUNDEDfor > 60 days without a withdrawal.claimRefund()(investor): zeroes the record before transfer, burns the token, returns USDC (pull pattern, reentrancy-safe).- State transitions:
FUNDRAISING → FUNDED → WITHDRAWN → ACTIVE → COMPLETED, withFUNDRAISING → REFUNDING(deadline) andFUNDED → REFUNDING(timeout). Other admin functions:setActive,setCompleted,pause/unpause.
ROIDistributor — one per property
Returns capital + profit to investors after completion.
commitDistribution(claimants[])(spvTreasury, no USDC): takes the[wallet → amount]list and computes the Merkle root on-chain — the root is not a parameter and cannot be forged. Stores the claimant list for public viewing; rejects empty lists and duplicates; setsrecoveryUnlocksAt = now + 180 days. Reversible before funding viacancelCommitment.depositFunds(amount)(spvTreasury): supports multiple partial deposits; claims unlock automatically oncetotalDeposited == totalRequired.withdrawDeposit()returns a partial deposit if claims aren’t yet unlocked (e.g. a list error caught early).claim()(investor): verifies the Merkle proof against the stored root, setsclaimedbefore transfer (CEI / reentrancy-safe), burns thePropertyToken, sends USDC.recoverFunds()(spvTreasury, after 180 days): sweeps unclaimed USDC only to the hardcoded treasury address (no address parameter).
Security highlights
- Pull-over-push for both refunds and ROI — one bad wallet can’t block others, no batch-loop reentrancy surface.
- Checks-Effects-Interactions on every payout (record updated before transfer).
- Frozen token roles — mint/burn rights are set by the factory and can never be granted again.
- Hardcoded treasury —
withdrawFundsandrecoverFundscan only send to the address fixed at deploy; no address parameter to manipulate. - On-chain Merkle root — computed inside
commitDistribution, never supplied externally. - Time locks — 60-day withdrawal timeout (investor protection) and 180-day recovery window.
- Immutable offering terms — goal, minimum, deadline, limits, and treasury/admin addresses are fixed at deploy and publicly verifiable.
Deployed addresses
| Contract | Network | Address |
|---|---|---|
| KYCRegistry | Base Sepolia (testnet) | 0x16C7d0F3d116b225634791Afd75D26A573179173 |
| PropertyFundingFactory | Base Sepolia (testnet) | 0x9822861d8A41b655aa0C0DF79017299F6283A28E |
| KYCRegistry | Base mainnet | 0xABd63EE631E88a6e733c29D1ed22d3Cf61F1A651 |
| PropertyFundingFactory | Base mainnet | 0xD71ef3B37Ad099308122C592B680f481678fae3f |
Per-property PropertyFunding, PropertyToken, and ROIDistributor addresses are emitted in the
factory’s ProjectCreated event and shown on each property’s page.