Architecture
The platform is a set of smart contracts on Base (Ethereum L2), plus an off-chain backend and website that read from and write to those contracts. The contracts are the source of truth; the off-chain components exist for verification (KYC), indexing, and a friendly UI.
On-chain components
Two contracts are deployed once for the whole platform:
- KYCRegistry — the single source of truth for who is verified. Stores on-chain attestations: whether a wallet passed KYC, its investor type (US accredited / US non-accredited / Reg S), country, expiry, and status. Written only by the attester role wallet; read by every funding contract before an investment.
- PropertyFundingFactory — deploys a new property’s contracts as one atomic set and locks their roles. Guarantees the three per-property contracts always appear together and correctly configured.
Three contracts are deployed per property by the factory:
- PropertyFunding — accepts and custodies investor USDC during the raise, enforces all participation rules, and runs the project state machine.
- PropertyToken (ERC-20) — a non-transferable proof-of-investment token, minted on invest and burned on refund or ROI claim.
- ROIDistributor — distributes return of capital + profit to investors after completion, using an on-chain Merkle commitment.
See Contracts for each contract’s functions and security properties.
Off-chain components
- Backend (NestJS) — runs the KYC verification integrations (Didit for identity/sanctions), writes attestations to
KYCRegistryvia anATTESTER_ROLEhot wallet, indexes on-chain events, and sends notifications. It holds no investor funds and no financial authority. - Frontend (Next.js) — reads on-chain data and presents listings, funding progress, the invest flow, and dashboards. Investors can also bypass the UI and call the contracts directly.
- Treasury & fiat — two Gnosis Safe multisigs per property plus a regulated fiat ramp (Coinbase Prime) handle the USDC↔USD conversion for construction (below).
Roles: separation of powers
Each property has two distinct Gnosis Safe multisigs — enforced at deploy time
(spvAdmin != spvTreasury):
- spvAdmin (Operational Safe) — drives the state machine:
withdrawFunds,setActive,setCompleted. It cannot send funds to itself — withdrawals only ever go to the hardcoded treasury address. - spvTreasury (Financial Safe) — receives the raised USDC, converts to/from fiat, and later funds the ROI distribution. It cannot change project state.
Proposed signer set for each Safe: 3-of-4, with each signer holding a Trezor hardware wallet — no single person can move funds or change project state alone.
Lifecycle
The full flow from creating a property to returning profit.
Flow 0 — Create a property
An admin calls createProject() on the factory. In one transaction it deploys
PropertyToken, PropertyFunding, and ROIDistributor, wires their roles, then revokes all
admin roles — minting/burning rights are frozen forever. All project parameters (goal, minimum,
deadline ≤ 180 days, investor limits, treasury/admin addresses, IPFS metadata hash) are set at
deploy and immutable.
Flow 1 — Investor onboarding
Register (email, for notifications only) → KYC + Didit identity verification → the backend writes an attestation to KYCRegistry
for your wallet. This is a public on-chain record that a wallet is verified, without exposing
personal data.
Flow 2 — Invest
A verified investor calls invest() (via the UI or directly). PropertyFunding checks: valid
KYC attestation, investor type, minimum amount, per-type cap, investor count < 2000, open state,
and deadline. If all pass, USDC moves into the contract and PropertyToken is minted to the
investor (1 token 1e18 = 1 USDC 1e6). Funds stay in the contract until withdrawal.
Investor wallet ── USDC ──▶ PropertyFunding ──(checks KYC, limits, deadline)──▶ KYCRegistry └── mint ──▶ PropertyToken ──▶ investor walletFlow 3a — Raise succeeds
Goal met → state becomes FUNDED and investing stops. The operations Safe (spvAdmin) calls
withdrawFunds(), which sends exactly totalRaised (not balanceOf) to the hardcoded
spvTreasury. Treasury off-ramps via Coinbase Prime → USD → construction. State advances to
ACTIVE, then COMPLETED.
PropertyFunding ──withdrawFunds() [spvAdmin]──▶ spvTreasury ──▶ Coinbase Prime ──▶ USD ──▶ buildFlow 3b — Refund (raise fails or admin is inactive)
Refunds go directly through PropertyFunding — no team action required. Anyone can call
triggerRefund() when either:
- the deadline passed without reaching the goal (
FUNDRAISING→REFUNDING), or - the project sat in
FUNDEDfor more than 60 days without a withdrawal (FUNDED→REFUNDING).
Each investor then calls claimRefund() themselves: the contract zeroes their record (before
transfer), burns their PropertyToken, and returns their USDC.
Flow 4 — ROI distribution
After COMPLETED, proceeds return to USDC and flow back via ROIDistributor in two steps by
spvTreasury:
commitDistribution(claimants[])— submits the[wallet → amount]list. The contract computes the Merkle root on-chain (the root is not an input, so it can’t be forged), stores the list.depositFunds(amount)— deposits the USDC (partial deposits allowed). Claims unlock automatically oncetotalDepositedreachestotalRequired. Also, contract sets a 180-day recovery clock - this intentionally done to make funds recoverable from contract by spvAdmin, in case investor loosed access to his wallet or any else life surcumstances - we keep this ability to send funds to investor off-chain, once he proves the identity & wallet ownership.
Each investor then calls claim() with a Merkle proof; the contract verifies it, marks claimed
(before transfer), burns the PropertyToken, and sends USDC. Unclaimed funds after 180 days can
be recovered only to the hardcoded treasury.
spvTreasury ──commitDistribution([...])──▶ ROIDistributor (root computed on-chain)spvTreasury ──depositFunds(USDC)─────────▶ ROIDistributor (claims unlock when fully funded)Investor ──claim()────────────────────▶ ROIDistributor ── burn ──▶ PropertyToken ── USDC ──▶ investorProject state machine
FUNDRAISING ──(goal met)──▶ FUNDED ──(spvAdmin withdraws)──▶ WITHDRAWN ──▶ ACTIVE ──▶ COMPLETED │ │ │ (deadline, goal missed) │ (60-day timeout, no withdrawal) ▼ ▼ ────────────────▶ REFUNDINGTransparency
Every meaningful action emits an on-chain event — an immutable, cryptographically anchored log inside the transaction. Anyone can read them for free on a block explorer (Basescan), with no cooperation from the company:
- Project created (goal, deadline, minimum, metadata hash, spvAdmin, spvTreasury)
- KYC attestation issued / revoked
- Investment accepted (investor, amount, timestamp, running total)
- Raise closed (
FUNDED), funds withdrawn to treasury - Construction started / completed
- Distribution committed (Merkle root, investor count, total, recovery unlock)
- Funds deposited, ROI claimed, refund triggered / claimed, recovery executed
Each PropertyFunding contract hosts list of IPFS CIDs. We will push each property updates as IPFS documents, our front-end read/parse those data directly from blockchain and represend as Timeline on project’s page.
The website aggregates this on-chain data: property listings with live funding progress and status, per-project investor lists (wallet, amount, country — never email or personal data), and platform-wide analytics. Nothing shown on the site requires trusting the company — it can all be re-derived from the chain.