|
| 1 | +# Real-Estate Escrow Engine (Component 46) — v1 design |
| 2 | + |
| 3 | +Buy a property through the app with the escrow document pipeline compressed |
| 4 | +from 30+ days to seconds: every closing document is pre-uploaded, content- |
| 5 | +hashed, attested on-chain, and freshness-tracked per property. When every |
| 6 | +document is fresh + verified and the buyer's proof-of-funds is current, the |
| 7 | +property is **transaction-ready** — and a verified buyer executes the purchase |
| 8 | +in one action: funds lock and settle **atomically** against the property's |
| 9 | +deed token, with a full attestation trail. |
| 10 | + |
| 11 | +**Feature-gated and dark by default:** `services.real_estate.enabled = false` |
| 12 | +(the server-side analogue of the app's mvpMode gating for regulated features). |
| 13 | +While off, every route returns an honest 403 and every service method refuses. |
| 14 | + |
| 15 | +## Where things live |
| 16 | + |
| 17 | +| Piece | Path | |
| 18 | +|---|---| |
| 19 | +| Service (registered like the other 45) | `runtime/blockchain/services/real_estate/service.py` | |
| 20 | +| Pure readiness + freshness engine | `runtime/blockchain/services/real_estate/readiness.py` | |
| 21 | +| Durable SQLite store | `runtime/blockchain/services/real_estate/store.py` | |
| 22 | +| Routes (all through `_call → gate_action`) | `gateway/service_routes.py` (`/api/v1/realestate/*`) | |
| 23 | +| Escrow contract | `contracts/PropertyEscrow.sol` (+ `contracts/test/PropertyEscrow.t.sol`) | |
| 24 | +| Deed token (ERC-721) | `contracts/PropertyDeed.sol` (+ `contracts/test/PropertyDeed.t.sol`) | |
| 25 | +| EAS schema (20th, `document_verification`) | `runtime/blockchain/services/attestation/schemas.py` | |
| 26 | +| Config block | `openmatrix.config.json` → `services.real_estate` | |
| 27 | + |
| 28 | +## Document types + freshness windows (config-driven defaults) |
| 29 | + |
| 30 | +Config: `services.real_estate.freshness_days`. Staleness is **computed on |
| 31 | +read** from the stored `expires_at` — no cron, no cached verdicts. The window |
| 32 | +is `[uploaded_at, expires_at)`: the expiry instant itself is already stale, so |
| 33 | +an exact-boundary read can never stretch a window. An unknown document type |
| 34 | +gets the **shortest** default window (conservative, never generous). |
| 35 | + |
| 36 | +| Document type | Window | |
| 37 | +|---|---| |
| 38 | +| `title_report` | 30 days | |
| 39 | +| `inspection` | 90 days | |
| 40 | +| `pest_roof_inspection` | 90 days | |
| 41 | +| `appraisal` | 120 days | |
| 42 | +| `seller_disclosures` | 180 days | |
| 43 | +| `hoa_documents` | 90 days | |
| 44 | +| `insurance_binder` | 30 days | |
| 45 | +| buyer `proof_of_funds` | 30 days (`proof_of_funds_days`) | |
| 46 | + |
| 47 | +`GET /api/v1/realestate/documents/expiring?days=N` lists current documents |
| 48 | +expiring within N days (already-expired ones are readiness blockers, not |
| 49 | +reminders) — the feed for future re-upload notifications. |
| 50 | + |
| 51 | +## The document pipeline (upload → hash → store → attest) |
| 52 | + |
| 53 | +1. **Hash** — sha256 computed server-side from the uploaded content, always |
| 54 | + real. (A caller may register a pre-computed hash without the blob; that is |
| 55 | + recorded as `hash_only`, never dressed up as stored.) |
| 56 | +2. **Store** — via the existing `storage` service (`store_filecoin`, |
| 57 | + Lighthouse/web3.storage). Genuinely wired but **credential-gated**: with no |
| 58 | + `services.storage.filecoin_api_key` the document records |
| 59 | + `storage_status: not_stored` — a fabricated CID is never invented (the |
| 60 | + privacy-service IPFS/Arweave stubs are deliberately NOT used). |
| 61 | +3. **Attest** — EAS attestation through the live attestation system using the |
| 62 | + new `document_verification` schema (the 20th, following the existing 19; |
| 63 | + testnet). The EAS path is **fail-closed**: until the schema UID is |
| 64 | + registered on-chain (a human step — same runbook as the other 19: run |
| 65 | + `scripts/register_eas_schemas.py`, register from a funded signer, paste the |
| 66 | + UID into `blockchain.schemas.document_verification`), attestation attempts |
| 67 | + record `attestation_status: unattested`. **Only `attested` satisfies |
| 68 | + readiness** — an unattested document is a named blocker, never invisible. |
| 69 | +4. **Supersession** — a re-upload inserts a new version and stamps the prior |
| 70 | + version's `superseded_by`; full history is retained forever. The "current" |
| 71 | + document per type is the single non-superseded row. |
| 72 | + |
| 73 | +## Transaction readiness (the heart) |
| 74 | + |
| 75 | +`readiness.evaluate_readiness(documents, buyer_verification, now, required)` — |
| 76 | +a **pure function** (no I/O, no clock reads; callers inject `now`). Verdict: |
| 77 | + |
| 78 | +``` |
| 79 | +TransactionReadiness { ready: bool, blockers: [{item, reason, days_stale?}] } |
| 80 | +``` |
| 81 | + |
| 82 | +- Every required document must be **present**, **fresh**, and **attested**. |
| 83 | +- The buyer's proof-of-funds must be **present**, **fresh**, and **verified**. |
| 84 | +- Each failure is one named blocker: `missing` / `stale` (with whole days |
| 85 | + stale, rounded up — one second past expiry reads 1, never 0) / `unverified`. |
| 86 | +- **`ready` is structurally derived as `len(blockers) == 0`** — it is never |
| 87 | + assigned independently, so there is no code path where `ready=true` with any |
| 88 | + blocker present. This is enforced by construction and locked by tests. |
| 89 | + |
| 90 | +## The escrow state machine |
| 91 | + |
| 92 | +``` |
| 93 | +initiated → funds_locked → settled → offchain_recording_pending → complete |
| 94 | + └──────────┴──────────→ refunded (terminal states: complete, refunded) |
| 95 | +``` |
| 96 | + |
| 97 | +Transitions are enforced by a single table (`VALID_TRANSITIONS`); anything |
| 98 | +else raises. History is append-only on the escrow record, together with the |
| 99 | +readiness snapshot at purchase time, every attestation reference, and every |
| 100 | +transaction hash. |
| 101 | + |
| 102 | +## One-tap purchase + the atomicity guarantee |
| 103 | + |
| 104 | +`POST /api/v1/realestate/purchase {buyer, property_id}`: |
| 105 | + |
| 106 | +1. **Re-verify readiness server-side at execution time** — a cached green is |
| 107 | + never trusted. Not ready → honest `not_ready` with the full blocker list. |
| 108 | +2. Contracts not deployed / deed not minted → honest `not_deployed` naming the |
| 109 | + exact missing config keys. **No state is created on a refusal.** |
| 110 | +3. Otherwise: create the escrow record (`initiated`), snapshot + attest the |
| 111 | + readiness verdict, and return the prepared **`lockAndSettle`** calldata. |
| 112 | + |
| 113 | +**Non-custodial by construction:** the platform never moves buyer funds. The |
| 114 | +buyer's own account submits the settlement — gas-sponsorable through the |
| 115 | +existing `/api/v1/paymaster/sign` path so the buyer pays no gas. On-chain, |
| 116 | +`PropertyEscrow.lockAndSettle` executes funds-lock + deed-transfer + release |
| 117 | +in **one transaction, all-or-nothing**: |
| 118 | + |
| 119 | +- funds release to the seller **only** in the same transaction that transfers |
| 120 | + the deed to the buyer; |
| 121 | +- a zero readiness-attestation UID is refused; |
| 122 | +- if the deed leg fails (seller revoked approval, deed moved), the payment |
| 123 | + reverts with it; if the payment leg fails (rejecting seller wallet), the |
| 124 | + deed transfer reverts with it — proven by forge tests in both directions; |
| 125 | +- reentrancy is guarded (`nonReentrant` + checks-effects-interactions); |
| 126 | +- **there is no owner and no backdoor**: PropertyEscrow has no owner role, no |
| 127 | + pause, and no function that can move locked funds anywhere except |
| 128 | + settlement-to-seller (buyer-executed) or refund-to-buyer (buyer-only, after |
| 129 | + the immutable lock timeout). |
| 130 | + |
| 131 | +`POST /api/v1/realestate/escrow/{id}/confirm {tx_hash}` verifies the receipt |
| 132 | +**on-chain** and advances state ONLY if the receipt genuinely settled *this* |
| 133 | +escrow: status success **AND** emitted by our `escrow_contract` **AND** carrying |
| 134 | +a `Settled(escrowId, buyer, …)` log whose indexed id equals this escrow's id. |
| 135 | +An arbitrary successful transaction (e.g. a 1-wei self-transfer whose hash the |
| 136 | +buyer supplies) is rejected as `not_settlement` — it can never fabricate a sale. |
| 137 | +A reverted / unmined / unrelated receipt changes nothing and says so. Only then |
| 138 | +does it walk the machine truthfully — `funds_locked → settled → |
| 139 | +offchain_recording_pending` — attest the settlement, and mark the property sold. |
| 140 | +Concurrent duplicate confirms settle exactly once (an in-flight guard plus the |
| 141 | +state-machine's `only-from-initiated` rule). |
| 142 | + |
| 143 | +## The honest off-chain bridge |
| 144 | + |
| 145 | +County recording and notarization are real-world steps this system does not |
| 146 | +control. They are modelled as the explicit `offchain_recording_pending` state |
| 147 | +with an operator endpoint (`POST /api/v1/realestate/escrow/{id}/recording-complete`) |
| 148 | +to mark genuine completion — **never pretended away, and never blocking the |
| 149 | +on-chain settlement's honesty about what it is**: funds settled + deed token |
| 150 | +transferred + trail attested; legal recording status shown truthfully as |
| 151 | +pending/complete. |
| 152 | + |
| 153 | +## Buyer verification (proof-of-funds) |
| 154 | + |
| 155 | +- **`wallet_balance` (v1, real, automatic):** the buyer's on-chain balance is |
| 156 | + read via the shared Web3Manager and compared to the threshold. The **proven |
| 157 | + balance** is recorded, and readiness compares it against the **property |
| 158 | + price** — so a buyer cannot self-select a tiny threshold (verify $1) and pass |
| 159 | + readiness on an expensive property; underfunded → `proof_of_funds` |
| 160 | + `insufficient` blocker. Insufficient balance vs. the threshold records an |
| 161 | + honest `insufficient_funds`; no RPC configured → honest `not_deployed`, no |
| 162 | + record fabricated. (An unreachable RPC is detected via the real Web3Manager |
| 163 | + interface — `available` / `w3.is_connected()` — so a live node is never |
| 164 | + mis-reported as down.) |
| 165 | +- **`external` (honest stub):** operator-attested external verification (bank |
| 166 | + integration) returns **501 not_implemented** — never fakes. |
| 167 | +- 30-day expiry; re-verification supersedes with history retained. |
| 168 | + |
| 169 | +## Gating summary (defense in depth) |
| 170 | + |
| 171 | +| Layer | Behavior when off/unconfigured | |
| 172 | +|---|---| |
| 173 | +| `services.real_estate.enabled=false` | routes 403; service methods refuse (covers batch/dispatcher paths) | |
| 174 | +| EAS schema unregistered | documents record `unattested` → readiness blocker | |
| 175 | +| Storage uncredentialed | `not_stored` recorded; hash still real | |
| 176 | +| Contracts undeployed | `not_deployed` naming exact config keys; no state created | |
| 177 | +| No RPC | verification/confirmation refuse honestly; state unchanged | |
| 178 | + |
| 179 | +## What v1 genuinely does vs. honest out-of-scope |
| 180 | + |
| 181 | +**Does:** document pipeline with real hashing + credential-gated real storage |
| 182 | ++ fail-closed attestation; config-driven freshness with on-read staleness; |
| 183 | +pure exhaustively-tested readiness; non-custodial atomic escrow settlement |
| 184 | +(written + forge-tested, NOT deployed — testnet deploy is a separate, |
| 185 | +explicitly-triggered step; mainnet is lawyer-gated); gas-sponsored one-tap |
| 186 | +flow; tracked off-chain recording bridge; honest buyer verification. |
| 187 | + |
| 188 | +**Out of scope in v1 (future phases, not faked):** |
| 189 | +- **Financing / mortgages** — v1 is cash-equivalent (full escrow amount). |
| 190 | +- **Title insurance integration** — the title report is a document type; |
| 191 | + binding an insurer is not wired. |
| 192 | +- **County recording automation** — recording is tracked, not automated; |
| 193 | + e-recording APIs are a future phase. |
| 194 | +- **Bank-account proof-of-funds** — the `external` method 501s until a real |
| 195 | + integration exists. |
| 196 | +- **At-rest blob encryption** — content hashes anchor integrity on-chain and |
| 197 | + storage is credential-gated; a dedicated document-encryption layer (beyond |
| 198 | + the storage provider's) is future work, stated plainly. |
| 199 | +- **Legal effect** — the deed token records the on-chain side of a transfer; |
| 200 | + legal ownership additionally requires the real-world recording steps above. |
| 201 | + |
| 202 | +## Deploy (later, user-triggered — nothing deployed now) |
| 203 | + |
| 204 | +`PropertyDeed` + `PropertyEscrow` are registered in `scripts/deploy_all.py` |
| 205 | +(escrow constructor arg: `escrow_lock_timeout_seconds`, default 7 days, |
| 206 | +immutable after deploy). After the explicit testnet deploy, paste the |
| 207 | +addresses into `services.real_estate.deed_contract` / `.escrow_contract` and |
| 208 | +register the `document_verification` EAS schema UID. Seller-side listing flow |
| 209 | +must approve the escrow contract on the deed token before settlement. |
0 commit comments