Skip to content

Commit fa28272

Browse files
author
Neo
committed
feat(real-estate): Real-Estate Escrow Engine (Component 46) — backend/contracts
Buy a property with the escrow document pipeline compressed to seconds: every closing document is pre-uploaded, content-hashed, attested on-chain, and freshness-tracked per property; when all documents are fresh + verified and the buyer's proof-of-funds covers the price, a verified buyer settles atomically in one action. Feature-gated (services.real_estate.enabled=false) and dark by default; every route crosses the _call -> gate_action seam and 403s honestly while disabled. Backend/platform/contracts only — no iOS UI. - Service (runtime/blockchain/services/real_estate/): registered like the other 45; Property / PropertyDocument / EscrowTransaction (state machine) / BuyerVerification, persisted durably in SQLite (supersession with full history retained). - Readiness engine (pure): documents + buyer verification -> {ready, blockers[ named, days-stale]}. ready is DERIVED as not(blockers) — no path fakes it; proof-of-funds is checked against the property PRICE (a self-chosen low threshold can't pass an unfundable purchase); non-finite expiry fails closed. - Document pipeline: sha256 hash (real) -> credential-gated real storage (honest not_stored otherwise, never a fabricated CID) -> EAS attestation via a new 20th schema document_verification (fail-closed: unattested = a named blocker). Config-driven freshness windows; staleness on read; expiring-within query; supersession. - Contracts (contracts/): PropertyEscrow.sol — funds lock + deed transfer + settlement atomically all-or-nothing, non-zero readiness attestation required, post-transfer ownerOf assertion (a rogue no-op ERC-721 can't release funds), reentrancy-guarded, refund buyer-only/post-deadline, NO owner backdoor; PropertyDeed.sol — operator-mintable ERC-721, one deed per property, moved atomically inside settlement. WRITTEN + FORGE-TESTED ONLY — not deployed. - One-tap purchase: re-verifies readiness at execution (never a cached green); binds the readiness snapshot into the settlement calldata; gas-sponsorable via the existing paymaster path; buyer signs (platform never touches funds). confirm_settlement advances state ONLY on a receipt that genuinely settled THIS escrow (status==1 AND our contract AND a matching Settled event) — an unrelated successful tx can never fabricate a sale; duplicate confirms settle once. Honest off-chain recording bridge (offchain_recording_pending + operator complete endpoint) — county recording is tracked, never pretended. - Buyer verification: wallet-balance (real, via the true Web3Manager available/is_connected interface) + honest 501 external stub; 30-day expiry. - Tests: 89 python (readiness math, freshness boundaries, pipeline, escrow SM, atomic settlement anti-fake, proof-of-funds coverage, disabled states) + 26 forge (lock/settle/refund/reentrancy/no-backdoor/rogue-deed). Full suites green: 797 python, 123 forge. REAL_ESTATE.md documents the full design + the honest out-of-scope list (financing, title insurance, county automation). Two adversarial-gate rounds: 6 findings found + fixed, re-verified SAFE_TO_COMMIT. foundry.toml: skip the vendored OZ-test/certora + account-abstraction-v0.6 trees that were breaking forge build/test from clean (pre-existing workspace bug; our contracts were always valid).
1 parent 9437c19 commit fa28272

17 files changed

Lines changed: 3415 additions & 7 deletions

REAL_ESTATE.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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.

contracts/PropertyDeed.sol

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
5+
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
6+
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
7+
import "@openzeppelin/contracts/access/Ownable.sol";
8+
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
9+
10+
/**
11+
* @title PropertyDeed
12+
* @notice ERC-721 where each token represents a claim on a specific real
13+
* property, minted by the platform operator after the property's
14+
* document set is verified. The deed token is transferred atomically
15+
* inside PropertyEscrow settlement — never separately from payment.
16+
*
17+
* HONESTY BOUNDARY (do not confuse the token with the world): holding
18+
* this token records the on-chain side of a transfer. Legal ownership
19+
* additionally requires county recording — a real-world step the
20+
* platform tracks explicitly off-chain and never pretends away.
21+
*
22+
* Token URIs should point at the attested document bundle (content
23+
* hashes on-chain via EAS; blobs in decentralized storage).
24+
*/
25+
contract PropertyDeed is
26+
ERC721,
27+
ERC721URIStorage,
28+
ERC721Enumerable,
29+
Ownable,
30+
ReentrancyGuard
31+
{
32+
// ---------------------------------------------------------------
33+
// State
34+
// ---------------------------------------------------------------
35+
uint256 private _nextTokenId;
36+
37+
/// @notice Off-chain property record id (platform id) per token, so the
38+
/// chain side and the platform record can always be reconciled.
39+
mapping(uint256 => string) public propertyIdOf;
40+
41+
/// @notice One deed per property record: platform property id -> token id
42+
/// (+1, so 0 means "no deed minted"). Prevents duplicate deeds
43+
/// for the same property record.
44+
mapping(bytes32 => uint256) private _tokenForProperty;
45+
46+
// ---------------------------------------------------------------
47+
// Events
48+
// ---------------------------------------------------------------
49+
event DeedMinted(uint256 indexed tokenId, string propertyId, address indexed to);
50+
51+
// ---------------------------------------------------------------
52+
// Constructor
53+
// ---------------------------------------------------------------
54+
constructor() ERC721("OpenMatrix Property Deed", "OMDEED") Ownable(msg.sender) {}
55+
56+
// ---------------------------------------------------------------
57+
// Minting — platform operator only
58+
// ---------------------------------------------------------------
59+
60+
/**
61+
* @notice Mint the deed token for a verified property record. Operator
62+
* (contract owner) only; one deed per property id, ever.
63+
* @param to Initial holder — the seller's wallet.
64+
* @param propertyId The platform's property record id.
65+
* @param uri Metadata URI (attested document bundle).
66+
*/
67+
function mint(address to, string calldata propertyId, string calldata uri)
68+
external
69+
onlyOwner
70+
nonReentrant
71+
returns (uint256)
72+
{
73+
require(to != address(0), "Zero address");
74+
require(bytes(propertyId).length > 0, "Empty property id");
75+
bytes32 key = keccak256(bytes(propertyId));
76+
require(_tokenForProperty[key] == 0, "Deed already minted");
77+
78+
uint256 tokenId = ++_nextTokenId; // token ids start at 1 (0 = none)
79+
_tokenForProperty[key] = tokenId;
80+
propertyIdOf[tokenId] = propertyId;
81+
_safeMint(to, tokenId);
82+
_setTokenURI(tokenId, uri);
83+
84+
emit DeedMinted(tokenId, propertyId, to);
85+
return tokenId;
86+
}
87+
88+
/// @notice Token id for a platform property id (0 = no deed minted).
89+
function tokenForProperty(string calldata propertyId) external view returns (uint256) {
90+
return _tokenForProperty[keccak256(bytes(propertyId))];
91+
}
92+
93+
// ---------------------------------------------------------------
94+
// ERC-165 / Override resolution (OZ v5 diamond)
95+
// ---------------------------------------------------------------
96+
97+
function supportsInterface(bytes4 interfaceId)
98+
public
99+
view
100+
override(ERC721, ERC721URIStorage, ERC721Enumerable)
101+
returns (bool)
102+
{
103+
return super.supportsInterface(interfaceId);
104+
}
105+
106+
function tokenURI(uint256 tokenId)
107+
public
108+
view
109+
override(ERC721, ERC721URIStorage)
110+
returns (string memory)
111+
{
112+
return super.tokenURI(tokenId);
113+
}
114+
115+
function _update(address to, uint256 tokenId, address auth)
116+
internal
117+
override(ERC721, ERC721Enumerable)
118+
returns (address)
119+
{
120+
return super._update(to, tokenId, auth);
121+
}
122+
123+
function _increaseBalance(address account, uint128 value)
124+
internal
125+
override(ERC721, ERC721Enumerable)
126+
{
127+
super._increaseBalance(account, value);
128+
}
129+
}

0 commit comments

Comments
 (0)